217 lines
11 KiB
Text
217 lines
11 KiB
Text
|
|
"use client";
|
||
|
|
/**
|
||
|
|
* TEE OFF SYSTEM INSTRUCTIONS - FACILITY CARDS v3.8 (BLOB SEARCH)
|
||
|
|
* ---------------------------------------------------------------------------
|
||
|
|
* REGEL 1: Status-badge SKAL vises øverst til venstre FOR ALLE BANER.
|
||
|
|
* Bruk STATUS_MAP for tekst.
|
||
|
|
* REGEL 2: DATA-PARSING: Bruk parseJson() for 'course_statuses', 'amenities' og 'nsg_data'.
|
||
|
|
* REGEL 3: Avstand-pillen skal ha fargen #2d3319 (Mørk oliven) med hvit tekst.
|
||
|
|
* REGEL 4: NSG (Blå 'N') og Golfamore (Oransje 'G') sirkler skal ha hvit kant (border-2).
|
||
|
|
* REGEL 5: Bunnen: Antall Hull (grønn pill), Banetype (grå pill), og Ikon-sirkler.
|
||
|
|
* REGEL 6: Viser dato (f.eks "05. mars 2026") rett til høyre for øverste status-pille.
|
||
|
|
* REGEL 7: Natural Language Search bruker en "Search Blob" for å støtte delvise
|
||
|
|
* ord og skrivefeil slik at listen ikke tømmes mens brukeren skriver.
|
||
|
|
* ---------------------------------------------------------------------------
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { STATUS_MAP, REGIONS } from "@/config/constants";
|
||
|
|
import { useState, useEffect, useMemo } from 'react';
|
||
|
|
import Link from 'next/link';
|
||
|
|
|
||
|
|
function 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 (e) { return Infinity; }
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function FacilitySearch({ initialFacilities }: { initialFacilities: any[] }) {
|
||
|
|
const [searchQuery, setSearchQuery] = useState("");
|
||
|
|
const [userLocation, setUserLocation] = useState<{ lat: number, lng: number } | null>(null);
|
||
|
|
const [sortMethod, setSortMethod] = useState<'dist' | 'alpha'>('alpha');
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if ("geolocation" in navigator) {
|
||
|
|
navigator.geolocation.getCurrentPosition(p => {
|
||
|
|
setUserLocation({ lat: p.coords.latitude, lng: p.coords.longitude });
|
||
|
|
setSortMethod('dist');
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const processed = useMemo(() => {
|
||
|
|
if (!Array.isArray(initialFacilities)) return [];
|
||
|
|
|
||
|
|
// Fyllord som fjernes slik at "Åpne baner i Oslo" blir til søkeordene ["åpne", "oslo"]
|
||
|
|
const stopWords = new Set(["i", "på", "for", "med", "av", "og"]);
|
||
|
|
|
||
|
|
return initialFacilities.map(f => {
|
||
|
|
// --- ROBUST DATA-PARSING ---
|
||
|
|
const parseJson = (val: any, fallback: any) => {
|
||
|
|
if (!val) return fallback;
|
||
|
|
if (typeof val === 'object') return val;
|
||
|
|
try { return JSON.parse(val); } catch (e) { return fallback; }
|
||
|
|
};
|
||
|
|
|
||
|
|
const rawStatuses = parseJson(f.course_statuses, []);
|
||
|
|
const sArr = Array.isArray(rawStatuses) && rawStatuses.length > 0
|
||
|
|
? rawStatuses
|
||
|
|
: [{ status: 'ukjent', name: 'Hovedbane' }];
|
||
|
|
|
||
|
|
const amenities = parseJson(f.amenities, {});
|
||
|
|
const nsgData = parseJson(f.nsg_data, {});
|
||
|
|
|
||
|
|
const dist = userLocation && f.lat && f.lng ? getDistance(userLocation.lat, userLocation.lng, f.lat, f.lng) : Infinity;
|
||
|
|
const hasNSG = nsgData && Object.keys(nsgData).length > 0;
|
||
|
|
const hasGolfamore = f.golfamore === true;
|
||
|
|
|
||
|
|
// --- THE SEARCH BLOB ---
|
||
|
|
// Vi starter med å legge navn, by og fylke i en stor, usynlig tekststreng
|
||
|
|
let searchableText = `${f.name} ${f.city} ${f.county}`.toLowerCase();
|
||
|
|
|
||
|
|
// 1. Injiser statuser i tekststrengen
|
||
|
|
const hasOpen = sArr.some((c: any) => (c.status || "") === 'aapen');
|
||
|
|
const hasClosed = sArr.some((c: any) => (c.status || "") === 'stengt');
|
||
|
|
const hasWinter = sArr.some((c: any) => (c.status || "") === 'aapen_med_vintergreener');
|
||
|
|
const hasNedlagt = sArr.some((c: any) => (c.status || "") === 'nedlagt');
|
||
|
|
|
||
|
|
if (hasOpen) searchableText += " åpen åpne aapen";
|
||
|
|
if (hasClosed) searchableText += " stengt stengte";
|
||
|
|
if (hasWinter) searchableText += " vinter vintergreener vinterbane";
|
||
|
|
if (hasNedlagt) searchableText += " nedlagt nedlagte";
|
||
|
|
|
||
|
|
// 2. Injiser spesial-tags
|
||
|
|
if (hasNSG) searchableText += " nsg norsk seniorgolf";
|
||
|
|
if (hasGolfamore) searchableText += " golfamore amore";
|
||
|
|
|
||
|
|
// 3. Injiser landsdel (f.eks. hvis fylket er Akershus, legger vi til "østlandet")
|
||
|
|
const fylke = (f.county || "").toLowerCase();
|
||
|
|
Object.entries(REGIONS).forEach(([regionName, counties]) => {
|
||
|
|
if (counties.includes(fylke)) {
|
||
|
|
searchableText += ` ${regionName}`;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Splitter brukerens søk inn i enkeltord og fjerner stopWords + ordene "bane"/"baner"
|
||
|
|
const words = searchQuery
|
||
|
|
.toLowerCase()
|
||
|
|
.trim()
|
||
|
|
.split(/\s+/)
|
||
|
|
.filter(w => w.length > 0 && !stopWords.has(w) && w !== "bane" && w !== "baner");
|
||
|
|
|
||
|
|
// Sjekker at ALLE ordene brukeren har skrevet, finnes et sted i "Search Blob"-en
|
||
|
|
const matches = words.every(w => searchableText.includes(w));
|
||
|
|
|
||
|
|
return { ...f, statuses: sArr, amenities, dist, hasNSG, hasGolfamore, matches };
|
||
|
|
})
|
||
|
|
.filter(f => f.matches)
|
||
|
|
.sort((a, b) => {
|
||
|
|
if (sortMethod === 'dist' && a.dist !== b.dist) return a.dist - b.dist;
|
||
|
|
return a.name.localeCompare(b.name, 'nb');
|
||
|
|
});
|
||
|
|
}, [searchQuery, initialFacilities, userLocation, sortMethod]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="max-w-[1400px] mx-auto px-6 py-12 relative z-40">
|
||
|
|
<div className="text-center mb-6">
|
||
|
|
<button onClick={() => setSortMethod(sortMethod === 'dist' ? 'alpha' : 'dist')} className="bg-white px-6 py-3 rounded-full shadow-md text-[10px] font-black text-[#8bc34a] uppercase tracking-widest border border-gray-100 transition-colors">
|
||
|
|
{sortMethod === 'dist' ? "📍 Nærmeste baner først" : "🔠 Alfabetisk visning"} • {processed.length} baner
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<input className="w-full p-8 rounded-[2.5rem] shadow-2xl mb-16 text-gray-900 border-none ring-1 ring-black/5 text-2xl outline-none focus:ring-4 focus:ring-[#8bc34a]/20 transition-all bg-white" placeholder='Søk baner, fylke, status eller spesial (f.eks "Åpne baner i Akershus" eller "NSG")...' value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
|
||
|
|
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
|
||
|
|
{processed.map((f: any) => {
|
||
|
|
const sArr = f.statuses; // Sikret via pre-prosesseringen over
|
||
|
|
|
||
|
|
// Formater datoen pent: "05. mars 2026"
|
||
|
|
const lastUpdated = f.status_updated_at
|
||
|
|
? new Date(f.status_updated_at).toLocaleDateString('nb-NO', { day: '2-digit', month: 'long', year: 'numeric' })
|
||
|
|
: 'Ukjent';
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Link href={`/golfbaner/${f.slug}`} key={f.id} className="bg-white rounded-[2.5rem] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-gray-100 flex flex-col group relative">
|
||
|
|
<div className="h-64 relative overflow-hidden bg-gray-100">
|
||
|
|
<img src={f.image_url || "/Toppbilde-standard.jpg"} className="w-full h-full object-cover transition duration-1000 group-hover:scale-105" alt={f.name} />
|
||
|
|
|
||
|
|
{/* Status Badges for ALLE baner på anlegget */}
|
||
|
|
<div className="absolute top-5 left-5 flex flex-col gap-2 z-20">
|
||
|
|
{sArr.map((course: any, idx: number) => {
|
||
|
|
const rawStatus = (course.status || "ukjent").toLowerCase();
|
||
|
|
|
||
|
|
let statusColor = "bg-gray-400";
|
||
|
|
if (rawStatus === 'aapen') statusColor = "bg-[#8bc34a]";
|
||
|
|
else if (rawStatus.includes('vinter') || rawStatus === 'stenger_snart') statusColor = "bg-[#ff5722]";
|
||
|
|
else if (rawStatus === 'aapner_snart') statusColor = "bg-amber-500";
|
||
|
|
else if (rawStatus === 'stengt') statusColor = "bg-red-600";
|
||
|
|
else if (rawStatus === 'nedlagt') statusColor = "bg-black";
|
||
|
|
else if (rawStatus === 'under_utvikling') statusColor = "bg-blue-500";
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div key={idx} className="flex items-center gap-3">
|
||
|
|
<div className={`${statusColor} text-white px-3 py-1.5 rounded-xl text-[9px] font-black uppercase shadow-lg backdrop-blur-sm bg-opacity-90 flex items-center gap-2 max-w-[200px]`}>
|
||
|
|
{sArr.length > 1 && (
|
||
|
|
<span className="opacity-80 border-r border-white/30 pr-2 truncate max-w-[90px]" title={course.name}>
|
||
|
|
{course.name}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
<span>{STATUS_MAP[rawStatus] || rawStatus}</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Dato-pille ved siden av den øverste status-pillen */}
|
||
|
|
{idx === 0 && (
|
||
|
|
<div className="bg-white/30 backdrop-blur-sm text-[#11280f]/90 px-3 py-1.5 rounded-xl text-[11px] font-bold shadow-lg">
|
||
|
|
{lastUpdated}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Avstandspille */}
|
||
|
|
{f.dist !== Infinity && (
|
||
|
|
<div className="absolute bottom-5 right-5 bg-[#2d3319] text-white px-4 py-2 rounded-2xl text-[10px] font-black shadow-lg z-20">
|
||
|
|
{Math.round(f.dist)} km unna
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="p-8 flex flex-col flex-grow">
|
||
|
|
<h3 className="font-black text-3xl text-[#11280f] mb-1 group-hover:text-[#8bc34a] transition-colors leading-tight">{f.name}</h3>
|
||
|
|
<p className="text-gray-400 text-[11px] font-bold uppercase tracking-widest mb-8">{f.city} • {f.county}</p>
|
||
|
|
|
||
|
|
<div className="mt-auto flex items-center justify-between">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
{/* Hull-pille */}
|
||
|
|
<span className="bg-[#f1f7ed] text-[#8bc34a] px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest">
|
||
|
|
{f.amenities?.antall_hull || '--'} HULL
|
||
|
|
</span>
|
||
|
|
{/* Banetype-pille */}
|
||
|
|
<span className="bg-gray-50 text-gray-400 px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-gray-100">
|
||
|
|
{f.banetype || 'SKOGSBANE'}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Sirkel-ikoner (NSG / Golfamore) */}
|
||
|
|
<div className="flex gap-2">
|
||
|
|
{f.hasNSG && (
|
||
|
|
<div className="w-9 h-9 bg-blue-600 text-white rounded-full flex items-center justify-center font-black text-sm shadow-lg border-2 border-white translate-y-1">N</div>
|
||
|
|
)}
|
||
|
|
{f.hasGolfamore && (
|
||
|
|
<div className="w-9 h-9 bg-[#ff5722] text-white rounded-full flex items-center justify-center font-black text-sm shadow-lg border-2 border-white translate-y-1">G</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</Link>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|