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

1269 lines
48 KiB
TypeScript
Executable file

"use client";
import { STATUS_MAP } from "@/config/constants";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { getPublicCourseDisplayName, type EnrichedFacility } from "@/app/facilityData";
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;
golfamore_url?: string | null;
nsg_url?: string | null;
has_golfpakker?: boolean | 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;
weather_forecast?: unknown;
};
type WeatherForecastDay = {
day_offset?: number;
dry_all_day?: boolean;
dry_daylight?: boolean;
precip_mm?: number;
precip_probability_max?: number;
daylight_precip_mm?: number;
daylight_precip_probability_max?: number;
confidence?: string;
};
type FacilitySearchProps = {
initialFacilities: Facility[];
variant?: Variant;
eyebrow?: string;
title?: string;
intro?: string;
fixedAreaFilter?: string;
hideTitleBlock?: boolean;
onFilteredFacilitiesChange?: (facilities: EnrichedFacility[]) => void;
filterHeading?: string;
};
type SpecialFlags = {
hasGolfamore: boolean;
hasNSG: boolean;
hasGolfPackages: boolean;
hasSimulator: boolean;
};
const HIDDEN_COUNTY_SLUGS = new Set(["innlandet", "viken"]);
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"],
ostlandet: ["telemark", "vestfold", "ostfold", "buskerud", "hedmark", "oppland", "innlandet", "viken", "akershus", "oslo"],
"oslo-og-akershus": ["akershus", "oslo", "viken"],
};
const COUNTY_FILTER_ALIASES: Record<string, string[]> = {
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: "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<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 MONTH_LABELS = ["jan.", "feb.", "mars", "apr.", "mai", "juni", "juli", "aug.", "sep.", "okt.", "nov.", "des."];
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 = <T,>(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 trimmed = String(value).trim();
const isoDateMatch = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (isoDateMatch) {
const [, year, month, day] = isoDateMatch;
const monthIndex = Number(month) - 1;
if (monthIndex >= 0 && monthIndex < MONTH_LABELS.length) {
return `${day}. ${MONTH_LABELS[monthIndex]} ${year}`;
}
}
const date = new Date(trimmed);
if (Number.isNaN(date.getTime())) return "Ukjent";
const day = String(date.getUTCDate()).padStart(2, "0");
const monthLabel = MONTH_LABELS[date.getUTCMonth()];
const year = date.getUTCFullYear();
return `${day}. ${monthLabel} ${year}`;
};
const getStatusLabel = (status: string) => STATUS_MAP[status] || "Ukjent";
type StatusBadge = {
label: string;
status: string;
};
const buildFacilityStatusBadges = (statuses: CourseStatus[]): StatusBadge[] => {
const normalizedStatuses = (Array.isArray(statuses) ? statuses : [])
.map((status) => ({
name: String(status?.name || "").trim(),
status: normalizeStatus(status?.status) || "ukjent",
}))
.filter((status) => status.status);
if (normalizedStatuses.length === 0) {
return [{ label: getStatusLabel("ukjent"), status: "ukjent" }];
}
const uniqueStatuses = [...new Set(normalizedStatuses.map((status) => status.status))];
if (normalizedStatuses.length === 1 || uniqueStatuses.length === 1) {
const status = uniqueStatuses[0] || "ukjent";
return [{ label: getStatusLabel(status), status }];
}
return normalizedStatuses.map((course, index) => {
const courseName = getPublicCourseDisplayName(course.name, index, normalizedStatuses.length);
return {
label: courseName ? `${courseName}: ${getStatusLabel(course.status)}` : getStatusLabel(course.status),
status: course.status,
};
});
};
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 formatPhoneHref = (phone: string) => {
const normalized = phone.replace(/[^\d+]/g, "");
return normalized.startsWith("00") ? `+${normalized.slice(2)}` : normalized;
};
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 : "#";
};
const getSiteOrigin = () => {
if (typeof window !== "undefined" && window.location?.origin) {
return window.location.origin;
}
return process.env.NEXT_PUBLIC_SITE_URL || "https://teeoff.no";
};
const isInternalTeeoffHref = (href: string) => {
if (!href || href.startsWith("/") || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
return true;
}
try {
const url = new URL(href, getSiteOrigin());
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] || "#");
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;
};
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 === "golfpakke") return flags.hasGolfPackages;
if (specialFilter === "simulator") return flags.hasSimulator;
return true;
};
const OSLO_TIME_ZONE = "Europe/Oslo";
const WEATHER_DAY_BASE_OPTIONS = [
{ value: "", label: "Alle dager" },
{ value: "0", label: "I dag" },
{ value: "1", label: "I morgen" },
{ value: "2", label: "Om 2 dager" },
{ value: "3", label: "Om 3 dager" },
{ value: "4", label: "Om 4 dager" },
{ value: "5", label: "Om 5 dager" },
{ value: "6", label: "Om 6 dager" },
{ value: "7", label: "Om en uke" },
];
const osloDateFormatter = new Intl.DateTimeFormat("nb-NO", {
timeZone: OSLO_TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const osloWeekdayFormatter = new Intl.DateTimeFormat("nb-NO", {
timeZone: OSLO_TIME_ZONE,
weekday: "long",
});
const getOsloDateParts = (date: Date) => {
const parts = osloDateFormatter.formatToParts(date);
const year = Number(parts.find((part) => part.type === "year")?.value ?? "0");
const month = Number(parts.find((part) => part.type === "month")?.value ?? "1");
const day = Number(parts.find((part) => part.type === "day")?.value ?? "1");
return { year, month, day };
};
const getOsloDateAtNoonUtc = (date: Date) => {
const { year, month, day } = getOsloDateParts(date);
return new Date(Date.UTC(year, month - 1, day, 12));
};
const getWeatherDayOptions = (date: Date = new Date()) => {
const osloToday = getOsloDateAtNoonUtc(date);
return WEATHER_DAY_BASE_OPTIONS.map((option) => {
if (!option.value) return option;
const dayOffset = Number.parseInt(option.value, 10);
const targetDate = new Date(osloToday);
targetDate.setUTCDate(targetDate.getUTCDate() + dayOffset);
return {
...option,
label: `${option.label} (${osloWeekdayFormatter.format(targetDate)})`,
};
});
};
const getOsloDateKey = (date: Date = new Date()) => {
const { year, month, day } = getOsloDateParts(date);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
};
const getMsUntilNextOsloDateChange = (date: Date = new Date()) => {
const minuteMs = 60_000;
const currentKey = getOsloDateKey(date);
let low = date.getTime();
let high = low + minuteMs;
while (getOsloDateKey(new Date(high)) === currentKey) {
high += 30 * minuteMs;
}
while (high - low > minuteMs) {
const mid = Math.floor((low + high) / 2);
if (getOsloDateKey(new Date(mid)) === currentKey) {
low = mid;
} else {
high = mid;
}
}
return Math.max(minuteMs, high - date.getTime() + minuteMs);
};
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,
onFilteredFacilitiesChange,
filterHeading = "Søk golfbaner",
}: FacilitySearchProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
const [areaFilter, setAreaFilter] = useState(fixedAreaFilter);
const [statusFilter, setStatusFilter] = useState("");
const [holeFilter, setHoleFilter] = useState("");
const [specialFilter, setSpecialFilter] = useState("");
const [weatherDayFilter, setWeatherDayFilter] = useState("");
const [weatherDayOptions, setWeatherDayOptions] = useState(() => getWeatherDayOptions());
const [architectFilter, setArchitectFilter] = useState("");
const [sortMethod, setSortMethod] = useState<SortMethod>("updated");
const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null);
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
useEffect(() => {
setAreaFilter(fixedAreaFilter);
}, [fixedAreaFilter]);
useEffect(() => {
let timeoutId: number | undefined;
let cancelled = false;
const scheduleRefresh = () => {
timeoutId = window.setTimeout(() => {
if (cancelled) return;
setWeatherDayOptions(getWeatherDayOptions());
scheduleRefresh();
}, getMsUntilNextOsloDateChange());
};
scheduleRefresh();
return () => {
cancelled = true;
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
};
}, []);
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<string, string>();
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<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 architectOptions = useMemo(() => {
const unique = new Map<string, string>();
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<Record<string, unknown>>(facility.amenities, {});
const golfamoreData = parseJson<Record<string, unknown>>(facility.golfamore_data, {});
const nsgData = parseJson<Record<string, unknown>>(facility.nsg_data, {});
const rawStatuses = parseJson<CourseStatus[]>(facility.course_statuses, []);
const weatherForecast = parseJson<WeatherForecastDay[]>(facility.weather_forecast, []);
const statuses =
Array.isArray(rawStatuses) && rawStatuses.length > 0
? rawStatuses
: [{ status: "ukjent", name: "" }];
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 ||
Boolean(facility.golfamore_url) ||
Object.keys(golfamoreData).length > 0;
const hasNSG = Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0;
const hasGolfPackages = facility.has_golfpakker === true;
const hasSimulator = hasTruthyAmenity(amenities.simulator);
const hasDrivingRange = hasTruthyAmenity(amenities.drivingrange);
const vtgDates = parseJson<unknown[]>(facility.vtg_datoer, []);
const hasVtg =
Boolean(facility.vtg_pris) ||
Boolean(facility.vtg_lenke) ||
Boolean(facility.vtg_beskrivelse) ||
(Array.isArray(vtgDates) && vtgDates.length > 0);
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 (hasGolfPackages) searchBlob += " golfpakke golfpakker";
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,
hasGolfPackages,
hasSimulator,
});
const selectedWeatherDayOffset = Number.parseInt(weatherDayFilter, 10);
const weatherDay = Number.isNaN(selectedWeatherDayOffset)
? null
: weatherForecast.find((entry) => Number(entry?.day_offset) === selectedWeatherDayOffset);
const matchesWeather = !weatherDayFilter || Boolean(weatherDay?.dry_daylight);
const matchesArchitect = !architectFilter || architectKey === architectFilter;
return {
...facility,
holeValue,
countySlug,
regions,
statuses,
primaryStatus,
hasGolfamore,
hasNSG,
hasGolfPackages,
hasSimulator,
hasDrivingRange,
hasVtg,
distance,
lastUpdatedTs,
matchesSearch,
matchesArea,
matchesStatus,
matchesHoles,
matchesSpecial,
matchesWeather,
matchesArchitect,
};
})
.filter(
(facility) =>
facility.matchesSearch &&
facility.matchesArea &&
facility.matchesStatus &&
facility.matchesHoles &&
facility.matchesSpecial &&
facility.matchesWeather &&
facility.matchesArchitect
)
.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,
holeFilter,
initialFacilities,
searchQuery,
sortMethod,
specialFilter,
statusFilter,
userLocation,
weatherDayFilter,
]);
const filtersCount = [
areaFilter,
statusFilter,
holeFilter,
specialFilter,
weatherDayFilter,
architectFilter,
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]";
const isCollapsibleHomeSearch = variant === "home";
const searchPanelOpen = !isCollapsibleHomeSearch || isMobileSearchOpen;
const handleFacilitySelect = (slug: string) => {
if (!slug) return;
router.push(`/golfbaner/${slug}`);
};
useEffect(() => {
if (isCollapsibleHomeSearch && filtersCount > 0) {
setIsMobileSearchOpen(true);
}
}, [filtersCount, isCollapsibleHomeSearch]);
useEffect(() => {
onFilteredFacilitiesChange?.(processedFacilities);
}, [onFilteredFacilitiesChange, processedFacilities]);
return (
<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 && (
<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">
{isCollapsibleHomeSearch ? (
<>
<button
type="button"
onClick={() => setIsMobileSearchOpen((current) => !current)}
aria-expanded={searchPanelOpen}
className="flex w-full items-center justify-between gap-4 rounded-[1.4rem] border border-white/12 bg-white/6 px-4 py-4 text-left transition hover:bg-white/10 md:hidden"
>
<span className="section-title text-[1.7rem] text-white">{filterHeading}</span>
<span
className={`inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-white/15 bg-white/10 text-white transition-transform duration-300 ${
searchPanelOpen ? "rotate-180" : ""
}`}
aria-hidden="true"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="m6 9 6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</button>
<div className="hidden md:block">
<h2 className="section-title text-3xl sm:text-4xl">{filterHeading}</h2>
</div>
</>
) : (
<h2 className="section-title text-3xl sm:text-4xl">Filtrer oversikten</h2>
)}
</div>
<div
className={`overflow-hidden transition-[max-height,opacity,margin] duration-300 ease-out md:!visible md:!mt-0 md:!max-h-none md:!opacity-100 ${
isCollapsibleHomeSearch
? searchPanelOpen
? "visible mt-0 max-h-[1200px] opacity-100"
: "invisible -mt-2 max-h-0 opacity-0 md:visible"
: "visible mt-0 max-h-none opacity-100"
}`}
aria-hidden={isCollapsibleHomeSearch && !searchPanelOpen}
>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
{!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>
)}
<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="under-utvikling">Under utvikling</option>
<option value="nedlagt">Nedlagt</option>
<option value="ukjent">Ukjent status</option>
</FieldSelect>
<FieldSelect label="Antall hull" value={holeFilter} onChange={setHoleFilter} labelClassName={labelClassName}>
<option value="">Alle antall hull</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="Ikke regn meldt"
value={weatherDayFilter}
onChange={setWeatherDayFilter}
labelClassName={labelClassName}
>
{weatherDayOptions.map((option) => (
<option key={option.value || "all"} value={option.value}>
{option.label}
</option>
))}
</FieldSelect>
<FieldSelect label="NSG / GOLFAMORE / GOLFPAKKE" value={specialFilter} onChange={setSpecialFilter} labelClassName={labelClassName}>
<option value="">Ikke hensyntatt</option>
<option value="golfamore">Golfamore</option>
<option value="nsg">Seniorgolf / NSG</option>
<option value="golfpakke">Tilbyr golfpakke</option>
</FieldSelect>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_minmax(0,1fr)_220px_auto]">
<FieldSelect
label="Arkitekt"
value={architectFilter}
onChange={setArchitectFilter}
labelClassName={labelClassName}
>
<option value="">Alle arkitekter</option>
{architectOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</FieldSelect>
<FieldSelect
label="Golfanlegg"
value=""
onChange={handleFacilitySelect}
labelClassName={labelClassName}
>
<option value=""> direkte til golfanlegg</option>
{facilityOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</FieldSelect>
<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);
setStatusFilter("");
setHoleFilter("");
setSpecialFilter("");
setWeatherDayFilter("");
setArchitectFilter("");
setSortMethod(userLocation ? "dist" : "updated");
}}
className={`btn btn-md mt-[1.72rem] h-[52px] ${
variant === "home" ? "btn-primary" : "btn-secondary"
}`}
>
Nullstill
</button>
</div>
<p className={`mt-3 text-xs leading-5 ${variant === "home" ? "text-white/70" : "text-[#617063]"}`}>
Værfilteret bygger prognosen fra MET akkurat . Lengre fram i tid gir lavere sikkerhet.
</p>
<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>
</div>
</div>
{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) => {
const statusBadges = buildFacilityStatusBadges(facility.statuses);
return (
<article
key={facility.id}
className="surface-card group flex h-full flex-col overflow-hidden rounded-[2rem] transition hover:-translate-y-1 hover:shadow-xl"
>
<Link href={`/golfbaner/${facility.slug}`} className="block shrink-0">
<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%-7rem)] flex-col items-start gap-2">
<div className="flex flex-wrap gap-2">
{statusBadges.map((badge) => (
<span
key={badge.label}
className={`rounded-full px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] ${
STATUS_CLASSES[badge.status] || STATUS_CLASSES.ukjent
}`}
>
{badge.label}
</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>
)}
</div>
</div>
{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>
)}
<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>
</div>
</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>
)}
</div>
{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>
<div
className="mt-2 text-[15px] italic leading-7 text-[#7B3D2C] [&_a]:font-bold [&_a]:text-[#C94F2D] [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-[#9F3117] [&_em]:italic [&_strong]:font-extrabold"
style={noteClampStyle}
dangerouslySetInnerHTML={{ __html: sanitizeRichText(facility.footnote) }}
/>
</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>
<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]">
{facility.phone ? (
<a
href={`tel:${formatPhoneHref(facility.phone)}`}
className="truncate text-[#617063] transition hover:text-[#FF5722]"
aria-label={`Ring ${facility.name}${facility.phone}`}
>
{facility.phone}
</a>
) : (
<span className="truncate text-[#617063]">{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>
)}
</div>
<Link href={`/golfbaner/${facility.slug}`} className="justify-self-end shrink-0 text-right text-[#FF5722] transition hover:text-[#C94F2D]">
Baneprofil
</Link>
</div>
</div>
</article>
);
})}
</div>
)}
</section>
);
}
function ActionIcon({ type }: { type: "web" | "booking" | "trophy" | "pin" | "weather" }) {
return (
<svg
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>
);
}
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>
);
}