Etter første endring av steder og kart

This commit is contained in:
Erol 2026-04-12 15:57:37 +02:00
parent d29b4a8976
commit a9ba2660ca
7 changed files with 771 additions and 11 deletions

View file

@ -9,12 +9,15 @@
"lint": "eslint"
},
"dependencies": {
"leaflet": "^1.9.4",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"react-leaflet": "^5.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/leaflet": "^1.9.21",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",

View file

@ -50,6 +50,8 @@ type FacilitySearchProps = {
eyebrow?: string;
title?: string;
intro?: string;
fixedAreaFilter?: string;
hideTitleBlock?: boolean;
};
type SpecialFlags = {
@ -325,15 +327,21 @@ export default function FacilitySearch({
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,
}: FacilitySearchProps) {
const [searchQuery, setSearchQuery] = useState("");
const [areaFilter, setAreaFilter] = useState("");
const [areaFilter, setAreaFilter] = useState(fixedAreaFilter);
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);
useEffect(() => {
setAreaFilter(fixedAreaFilter);
}, [fixedAreaFilter]);
useEffect(() => {
if (!("geolocation" in navigator)) return;
@ -511,7 +519,7 @@ export default function FacilitySearch({
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" && (
{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>
@ -527,13 +535,15 @@ export default function FacilitySearch({
</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>
{!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>
@ -588,7 +598,7 @@ export default function FacilitySearch({
type="button"
onClick={() => {
setSearchQuery("");
setAreaFilter("");
setAreaFilter(fixedAreaFilter);
setStatusFilter("");
setHoleFilter("");
setSpecialFilter("");

300
frontend/src/app/facilityData.ts Executable file
View file

@ -0,0 +1,300 @@
import { STATUS_MAP } from "@/config/constants";
export type CourseStatus = {
status?: string;
name?: string;
};
export type FacilityRecord = {
id: number;
slug: string;
name: string;
description?: string | null;
city?: string | null;
county?: string | null;
banetype?: string | null;
image_url?: string | null;
phone?: string | null;
email?: 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;
nsg_url?: string | null;
vtg_pris?: number | null;
vtg_lenke?: string | null;
vtg_beskrivelse?: string | null;
amenities?: unknown;
golfamore_data?: unknown;
nsg_data?: unknown;
vtg_datoer?: unknown;
social_links?: unknown;
course_statuses?: unknown;
footnote?: string | null;
footnote_updated_at?: string | null;
status_updated_at?: string | null;
};
export type EnrichedFacility = FacilityRecord & {
holeValue: string;
primaryStatus: string;
hasGolfamore: boolean;
hasNSG: boolean;
hasSimulator: boolean;
hasDrivingRange: boolean;
hasVtg: boolean;
countySlug: string;
regions: string[];
statuses: CourseStatus[];
distance: number;
lastUpdatedTs: number;
};
export type PlaceConfig = {
slug: string;
areaFilter: string;
label: string;
title: string;
intro: string;
};
export 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"],
};
export const HIERARCHICAL_AREA_OPTIONS = [
{ value: "", label: "Hele Norge", slug: "norge" },
{ value: "region:nord-norge", label: "Nord-Norge", slug: "nord-norge" },
{ value: "county:finnmark", label: "Finnmark", slug: "finnmark" },
{ value: "county:troms", label: "Troms", slug: "troms" },
{ value: "county:nordland", label: "Nordland", slug: "nordland" },
{ value: "region:midt-norge", label: "Midt-Norge", slug: "midt-norge" },
{ value: "county:nord-trondelag", label: "Nord-Trøndelag", slug: "nord-trondelag" },
{ value: "county:sor-trondelag", label: "Sør-Trøndelag", slug: "sor-trondelag" },
{ value: "county:trondelag", label: "Trøndelag", slug: "trondelag" },
{ value: "region:vestlandet", label: "Vestlandet", slug: "vestlandet" },
{ value: "county:more-og-romsdal", label: "Møre og Romsdal", slug: "more-og-romsdal" },
{ value: "county:sogn-og-fjordane", label: "Sogn og Fjordane", slug: "sogn-og-fjordane" },
{ value: "county:hordaland", label: "Hordaland", slug: "hordaland" },
{ value: "county:rogaland", label: "Rogaland", slug: "rogaland" },
{ value: "county:vestland", label: "Vestland", slug: "vestland" },
{ value: "region:sorlandet", label: "Sørlandet", slug: "sorlandet" },
{ value: "county:vest-agder", label: "Vest-Agder", slug: "vest-agder" },
{ value: "county:aust-agder", label: "Aust-Agder", slug: "aust-agder" },
{ value: "county:agder", label: "Agder", slug: "agder" },
{ value: "region:ostlandet", label: "Østlandet", slug: "ostlandet" },
{ value: "county:telemark", label: "Telemark", slug: "telemark" },
{ value: "county:vestfold", label: "Vestfold", slug: "vestfold" },
{ value: "county:ostfold", label: "Østfold", slug: "ostfold" },
{ value: "county:buskerud", label: "Buskerud", slug: "buskerud" },
{ value: "county:hedmark", label: "Hedmark", slug: "hedmark" },
{ value: "county:oppland", label: "Oppland", slug: "oppland" },
{ value: "region:oslo-og-akershus", label: "Oslo og Akershus", slug: "oslo-og-akershus" },
{ value: "county:akershus", label: "Akershus", slug: "akershus" },
{ value: "county:oslo", label: "Oslo", slug: "oslo" },
{ value: "county:innlandet", label: "Innlandet", slug: "innlandet" },
{ value: "county:viken", label: "Viken", slug: "viken" },
];
export const STATUS_ICON_PATHS: Record<string, string> = {
aapen: "/icons/open24.png",
aapen_med_vintergreener: "/icons/open-winter24.png",
stengt: "/icons/closed24.png",
aapner_snart: "/icons/open-soon24.png",
stenger_snart: "/icons/close-soon24.png",
under_utvikling: "/icons/under-development24.png",
nedlagt: "/icons/discontinued24.png",
ukjent: "/icons/unknown24.png",
};
export const normalizeText = (value: unknown) =>
String(value ?? "")
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, " ")
.trim();
export const normalizeStatus = (value: unknown) =>
String(value ?? "")
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/\s+/g, "_")
.replace(/[^a-z0-9_]+/g, "")
.trim();
export const slugify = (value: unknown) =>
normalizeText(value)
.replace(/\s+/g, "-")
.replace(/^-+|-+$/g, "");
export 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 hasTruthyAmenity = (value: unknown) => {
const normalized = normalizeText(value);
return Boolean(normalized) && !["nei", "no", "false", "0", "ingen"].includes(normalized);
};
export const getFacilityRegions = (county: string) => {
const countySlug = slugify(county);
return Object.entries(AREA_GROUPS)
.filter(([, counties]) => counties.includes(countySlug))
.map(([region]) => region);
};
const STATUS_ORDER = [
"aapen",
"aapen_med_vintergreener",
"stenger_snart",
"aapner_snart",
"ukjent",
"stengt",
"under_utvikling",
"nedlagt",
];
export const getPrimaryStatus = (statuses: Array<{ status?: string }>) => {
for (const candidate of STATUS_ORDER) {
if (statuses.some((status) => normalizeStatus(status.status) === candidate)) {
return candidate;
}
}
return "ukjent";
};
export const getStatusLabel = (status: string) => STATUS_MAP[status] || "Ukjent status";
export 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",
});
};
export 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}`;
};
export const getDistance = (lat1: number, lon1: number, lat2: number, lon2: number) => {
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));
};
export const enrichFacilities = (
facilities: FacilityRecord[],
userLocation?: { lat: number; lng: number } | null
): EnrichedFacility[] =>
(Array.isArray(facilities) ? facilities : []).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 holeValue = String(amenities.antall_hull || "").trim();
const countySlug = slugify(facility.county || "");
const regions = getFacilityRegions(facility.county || "");
const updatedTsRaw = facility.status_updated_at ? new Date(facility.status_updated_at).getTime() : 0;
return {
...facility,
holeValue,
countySlug,
regions,
statuses,
primaryStatus: getPrimaryStatus(statuses),
hasGolfamore: facility.golfamore === true || Object.keys(golfamoreData).length > 0,
hasNSG: Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0,
hasSimulator: hasTruthyAmenity(amenities.simulator),
hasDrivingRange: hasTruthyAmenity(amenities.drivingrange),
hasVtg:
Boolean(facility.vtg_pris) ||
Boolean(facility.vtg_lenke) ||
Boolean(facility.vtg_beskrivelse) ||
(Array.isArray(vtgDates) && vtgDates.length > 0),
distance:
userLocation && typeof facility.lat === "number" && typeof facility.lng === "number"
? getDistance(userLocation.lat, userLocation.lng, facility.lat, facility.lng)
: Number.POSITIVE_INFINITY,
lastUpdatedTs: Number.isFinite(updatedTsRaw) ? updatedTsRaw : 0,
};
});
export const matchesAreaFilter = (facility: EnrichedFacility, areaFilter: string) => {
if (!areaFilter) return true;
const selectedArea = areaFilter.replace(/^(region:|county:)/, "");
if (areaFilter.startsWith("region:")) {
return (
facility.regions.includes(selectedArea) ||
(AREA_GROUPS[selectedArea] ? AREA_GROUPS[selectedArea].includes(facility.countySlug) : false)
);
}
if (areaFilter.startsWith("county:")) {
return facility.countySlug === selectedArea;
}
return true;
};
export const filterFacilitiesByArea = (facilities: EnrichedFacility[], areaFilter: string) =>
facilities.filter((facility) => matchesAreaFilter(facility, areaFilter));
export const getPlaceConfigFromSlug = (slug: string): PlaceConfig | null => {
const option = HIERARCHICAL_AREA_OPTIONS.find((entry) => entry.slug === slug);
if (!option) return null;
if (!option.value) {
return {
slug,
areaFilter: "",
label: option.label,
title: "Alle golfanlegg i Norge",
intro: "Se alle norske golfanlegg på kartet, med statusikoner og listevisning under.",
};
}
const isRegion = option.value.startsWith("region:");
return {
slug,
areaFilter: option.value,
label: option.label,
title: `Alle golfanlegg i ${option.label}`,
intro: isRegion
? `Utforsk golfanlegg i ${option.label} på kartet og gå videre til hvert anlegg under.`
: `Utforsk golfanlegg i ${option.label} på kartet og sammenlign anleggene i listen under.`,
};
};
export const getAvailablePlaceConfigs = () => HIERARCHICAL_AREA_OPTIONS.map(({ slug }) => slug);

View file

@ -1,4 +1,5 @@
@import "tailwindcss";
@import "leaflet/dist/leaflet.css";
:root {
--background: #f3f6ee;
@ -91,3 +92,47 @@ textarea {
0 1px 2px rgba(17, 32, 21, 0.04),
0 10px 30px rgba(17, 32, 21, 0.05);
}
.teeoff-map .leaflet-container {
height: 100%;
width: 100%;
font: inherit;
background: #dfe7d5;
}
.teeoff-map .leaflet-popup-content-wrapper {
border-radius: 1.25rem;
box-shadow: 0 12px 30px rgba(17, 32, 21, 0.12);
}
.teeoff-map .leaflet-popup-content {
margin: 1rem;
min-width: 220px;
}
.teeoff-map .leaflet-control-zoom {
border: none;
box-shadow: 0 8px 24px rgba(17, 32, 21, 0.12);
}
.teeoff-map .leaflet-control-zoom a {
color: #112015;
}
.map-popup-icon {
display: inline-flex;
height: 2rem;
width: 2rem;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid #d7ded0;
background: #fff;
transition: border-color 150ms ease, color 150ms ease, transform 150ms ease;
}
.map-popup-icon:hover {
border-color: #ff5722;
color: #ff5722;
transform: translateY(-1px);
}

View file

@ -0,0 +1,90 @@
import dynamicImport from "next/dynamic";
import { notFound } from "next/navigation";
import FacilitySearch from "@/app/FacilitySearch";
import {
type FacilityRecord,
enrichFacilities,
filterFacilitiesByArea,
getAvailablePlaceConfigs,
getPlaceConfigFromSlug,
} from "@/app/facilityData";
import { API_URL } from "@/config/constants";
const PlaceMap = dynamicImport(() => import("@/components/PlaceMap"), {
ssr: false,
loading: () => (
<div className="overflow-hidden rounded-[2rem] border border-[#D7DED0] bg-white p-8 shadow-sm">
<p className="text-sm text-[#617063]">Laster kartet...</p>
</div>
),
});
export const dynamicParams = true;
export const dynamic = "force-dynamic";
export async function generateStaticParams() {
return getAvailablePlaceConfigs().map((slug) => ({ slug }));
}
export default async function PlacePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const place = getPlaceConfigFromSlug(slug);
if (!place) {
notFound();
}
let facilities: FacilityRecord[] = [];
try {
const res = await fetch(`${API_URL}/facilities`, {
next: { revalidate: 0 },
cache: "no-store",
});
if (!res.ok) {
throw new Error(`API returnerte status ${res.status}`);
}
facilities = await res.json();
} catch (error) {
console.error("Kritisk feil ved henting av sted-data:", error);
facilities = [];
}
const safeData = Array.isArray(facilities) ? facilities : [];
const facilitiesInPlace = filterFacilitiesByArea(enrichFacilities(safeData), place.areaFilter);
return (
<main className="site-shell min-h-screen">
<section className="mx-auto max-w-[1400px] px-4 py-8 sm:px-6 sm:py-10 lg:px-8 lg:py-12">
<div className="max-w-4xl">
<p className="mb-3 text-[11px] font-extrabold uppercase tracking-[0.3em] text-[#6FA786]">Steder</p>
<h1 className="section-title text-4xl text-[#112015] sm:text-5xl lg:text-6xl">{place.title}</h1>
<p className="mt-4 max-w-3xl text-base leading-8 text-[#617063]">{place.intro}</p>
<div className="mt-5 flex flex-wrap gap-3">
<span className="rounded-full bg-white px-4 py-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#112015] shadow-sm">
{facilitiesInPlace.length} anlegg
</span>
<span className="rounded-full bg-[#25312A] px-4 py-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-white">
Kart og liste i samme visning
</span>
</div>
</div>
<div className="mt-8">
<PlaceMap facilities={facilitiesInPlace} placeLabel={place.label} />
</div>
</section>
<FacilitySearch
initialFacilities={safeData}
variant="catalog"
title={place.title}
intro={`Filtrer anleggene i ${place.label} videre etter banestatus, antall hull og andre egenskaper.`}
fixedAreaFilter={place.areaFilter}
hideTitleBlock
/>
</main>
);
}

View file

@ -7,6 +7,7 @@ export default function Header() {
const [isOpen, setIsOpen] = useState(false);
const navItems = [
{ href: "/", label: "Hjem" },
{ href: "/sted/norge", label: "Steder" },
{ href: "/golfbaner", label: "Golfbaner" },
];

View file

@ -0,0 +1,311 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo } from "react";
import { Icon, LatLngBounds } from "leaflet";
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
import {
type EnrichedFacility,
STATUS_ICON_PATHS,
buildMapUrl,
formatUpdatedDate,
getStatusLabel,
parseJson,
} from "@/app/facilityData";
type PlaceMapProps = {
facilities: EnrichedFacility[];
placeLabel: string;
};
const markerIconCache: Record<string, Icon> = {};
const getMarkerIcon = (status: string) => {
const key = STATUS_ICON_PATHS[status] ? status : "ukjent";
if (!markerIconCache[key]) {
markerIconCache[key] = new Icon({
iconUrl: STATUS_ICON_PATHS[key],
iconSize: [17, 24],
iconAnchor: [8, 24],
popupAnchor: [0, -22],
});
}
return markerIconCache[key];
};
function FitMapBounds({ facilities }: { facilities: EnrichedFacility[] }) {
const map = useMap();
useEffect(() => {
const withCoords = facilities.filter(
(facility) => typeof facility.lat === "number" && typeof facility.lng === "number"
);
if (withCoords.length === 0) return;
if (withCoords.length === 1) {
map.setView([withCoords[0].lat as number, withCoords[0].lng as number], 10);
return;
}
const bounds = new LatLngBounds(
withCoords.map((facility) => [facility.lat as number, facility.lng as number] as [number, number])
);
map.fitBounds(bounds, { padding: [36, 36] });
}, [facilities, map]);
return null;
}
function ActionGlyph({ type }: { type: "teeoff" | "phone" | "mail" | "home" | "calendar" | "weather" | "facebook" | "instagram" }) {
if (type === "teeoff") {
return <span className="text-lg font-black leading-none text-[#FF5722]">t</span>;
}
return (
<svg
className="h-4 w-4 text-[#FF5722]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{type === "phone" && <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />}
{type === "mail" && (
<>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</>
)}
{type === "home" && (
<>
<path d="M3 11.5 12 4l9 7.5" />
<path d="M5 10.5V20h14v-9.5" />
</>
)}
{type === "calendar" && (
<>
<path d="M3 10h18" />
<path d="M8 3v4" />
<path d="M16 3v4" />
<rect x="4" y="5" width="16" height="16" rx="2" />
</>
)}
{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" />
</>
)}
{type === "facebook" && <path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />}
{type === "instagram" && (
<>
<rect x="2" y="2" width="20" height="20" rx="5" ry="5" />
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" />
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5" />
</>
)}
</svg>
);
}
function MapLegend() {
const statusRows = [
["aapen", "Banen er åpen"],
["aapen_med_vintergreener", "Banen er åpen med vintergreener"],
["stengt", "Banen er stengt"],
["aapner_snart", "Banen åpner snart"],
["stenger_snart", "Banen stenger snart"],
["under_utvikling", "Golfanlegg under utvikling"],
["nedlagt", "Banen er nedlagt"],
["ukjent", "Banestatus er ukjent"],
] as const;
const actionRows = [
["teeoff", "Se banen på teeoff.no"],
["phone", "Ring klubben"],
["mail", "Send e-post til klubben"],
["home", "Besøk klubbens hjemmeside"],
["calendar", "Klubbens turneringer på Golfbox"],
["weather", "Værmelding fra Yr.no"],
["facebook", "Klubben på Facebook"],
["instagram", "Klubben på Instagram"],
] as const;
return (
<details className="group rounded-[1.75rem] border border-[#D7DED0] bg-white/95 shadow-sm backdrop-blur-sm">
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 text-left">
<div>
<p className="text-[11px] font-extrabold uppercase tracking-[0.28em] text-[#6FA786]">Kart</p>
<h3 className="mt-2 text-2xl text-[#112015]">Tegnforklaring</h3>
</div>
<span className="rounded-full bg-[#F3F6EE] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#617063] group-open:hidden">
Åpne
</span>
<span className="hidden rounded-full bg-[#25312A] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-white group-open:inline-flex">
Lukk
</span>
</summary>
<div className="grid gap-8 border-t border-[#E5EADF] px-5 py-5 lg:grid-cols-2">
<div className="space-y-3">
{statusRows.map(([status, label]) => (
<div key={status} className="flex items-center gap-3 text-sm text-[#112015]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={STATUS_ICON_PATHS[status]} alt="" className="h-6 w-[17px] shrink-0" />
<span>{label}</span>
</div>
))}
</div>
<div className="space-y-3">
{actionRows.map(([icon, label]) => (
<div key={icon} className="flex items-center gap-3 text-sm text-[#112015]">
<div className="flex h-6 w-6 items-center justify-center">
<ActionGlyph type={icon} />
</div>
<span>{label}</span>
</div>
))}
</div>
</div>
</details>
);
}
export default function PlaceMap({ facilities, placeLabel }: PlaceMapProps) {
const mapFacilities = useMemo(
() =>
facilities.filter(
(facility) => typeof facility.lat === "number" && typeof facility.lng === "number"
),
[facilities]
);
return (
<section className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<p className="text-[11px] font-extrabold uppercase tracking-[0.28em] text-[#6FA786]">Kartoversikt</p>
<h2 className="mt-2 text-3xl text-[#112015] sm:text-4xl">Golfanlegg i {placeLabel}</h2>
<p className="mt-3 text-base leading-7 text-[#617063]">
Klikk en markør for å åpne anlegget og bruke hurtiglenkene videre.
</p>
</div>
<div className="lg:max-w-[34rem]">
<MapLegend />
</div>
</div>
<div className="teeoff-map overflow-hidden rounded-[2rem] border border-[#D7DED0] bg-white shadow-sm">
<div className="h-[26rem] w-full sm:h-[34rem]">
<MapContainer
center={[64.5, 15.5]}
zoom={5}
scrollWheelZoom={false}
zoomControl={false}
className="h-full w-full"
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<FitMapBounds facilities={mapFacilities} />
{mapFacilities.map((facility) => {
const socialLinks = parseJson<Array<{ platform?: string; url?: string }>>(facility.social_links, []);
const facebook = socialLinks.find((entry) => entry.platform?.toLowerCase() === "facebook")?.url;
const instagram = socialLinks.find((entry) => entry.platform?.toLowerCase() === "instagram")?.url;
return (
<Marker
key={facility.id}
position={[facility.lat as number, facility.lng as number]}
icon={getMarkerIcon(facility.primaryStatus)}
>
<Popup>
<div className="space-y-3">
<div>
<Link href={`/golfbaner/${facility.slug}`} className="text-lg font-extrabold text-[#112015] hover:text-[#FF5722]">
{facility.name}
</Link>
<p className="mt-1 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#617063]">
{facility.city} {facility.county}
</p>
</div>
<div className="inline-flex rounded-full bg-[#F3F6EE] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#112015]">
{getStatusLabel(facility.primaryStatus)}
</div>
{facility.status_updated_at && (
<p className="text-[11px] text-[#617063]">Oppdatert {formatUpdatedDate(facility.status_updated_at)}</p>
)}
<div className="flex flex-wrap gap-2">
<a href={`/golfbaner/${facility.slug}`} className="map-popup-icon" aria-label={`Se ${facility.name} på TeeOff`}>
<ActionGlyph type="teeoff" />
</a>
{facility.phone && (
<a href={`tel:${facility.phone.replace(/\s/g, "")}`} className="map-popup-icon" aria-label={`Ring ${facility.name}`}>
<ActionGlyph type="phone" />
</a>
)}
{facility.email && (
<a href={`mailto:${facility.email}`} className="map-popup-icon" aria-label={`Send e-post til ${facility.name}`}>
<ActionGlyph type="mail" />
</a>
)}
{facility.website_url && (
<a href={facility.website_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Besøk nettsiden til ${facility.name}`}>
<ActionGlyph type="home" />
</a>
)}
{facility.golfbox_tournament_url && (
<a href={facility.golfbox_tournament_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Turneringer hos ${facility.name}`}>
<ActionGlyph type="calendar" />
</a>
)}
{facility.weather_url && (
<a href={facility.weather_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Vær for ${facility.name}`}>
<ActionGlyph type="weather" />
</a>
)}
{facebook && (
<a href={facebook} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Facebook for ${facility.name}`}>
<ActionGlyph type="facebook" />
</a>
)}
{instagram && (
<a href={instagram} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Instagram for ${facility.name}`}>
<ActionGlyph type="instagram" />
</a>
)}
{buildMapUrl(facility.lat, facility.lng) && (
<a
href={buildMapUrl(facility.lat, facility.lng) || "#"}
target="_blank"
rel="noreferrer"
className="rounded-full border border-[#D7DED0] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#617063] transition hover:border-[#FF5722] hover:text-[#FF5722]"
>
Åpne kart
</a>
)}
</div>
</div>
</Popup>
</Marker>
);
})}
</MapContainer>
</div>
</div>
</section>
);
}