diff --git a/frontend/package.json b/frontend/package.json index dc832c5..5e6303e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/app/FacilitySearch.tsx b/frontend/src/app/FacilitySearch.tsx index de64ba8..5a52c94 100755 --- a/frontend/src/app/FacilitySearch.tsx +++ b/frontend/src/app/FacilitySearch.tsx @@ -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("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 (
- {variant === "catalog" && ( + {variant === "catalog" && !hideTitleBlock && (

{eyebrow}

{title}

@@ -527,13 +535,15 @@ export default function FacilitySearch({
- - {areaOptions.map((option) => ( - - ))} - + {!fixedAreaFilter && ( + + {areaOptions.map((option) => ( + + ))} + + )} @@ -588,7 +598,7 @@ export default function FacilitySearch({ type="button" onClick={() => { setSearchQuery(""); - setAreaFilter(""); + setAreaFilter(fixedAreaFilter); setStatusFilter(""); setHoleFilter(""); setSpecialFilter(""); diff --git a/frontend/src/app/facilityData.ts b/frontend/src/app/facilityData.ts new file mode 100755 index 0000000..a117563 --- /dev/null +++ b/frontend/src/app/facilityData.ts @@ -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 = { + "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 = { + 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 = (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>(facility.amenities, {}); + const golfamoreData = parseJson>(facility.golfamore_data, {}); + const nsgData = parseJson>(facility.nsg_data, {}); + const vtgDates = parseJson(facility.vtg_datoer, []); + const rawStatuses = parseJson(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); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index e0ca15d..981631b 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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); +} diff --git a/frontend/src/app/sted/[slug]/page.tsx b/frontend/src/app/sted/[slug]/page.tsx new file mode 100755 index 0000000..577b29e --- /dev/null +++ b/frontend/src/app/sted/[slug]/page.tsx @@ -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: () => ( +
+

Laster kartet...

+
+ ), +}); + +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 ( +
+
+
+

Steder

+

{place.title}

+

{place.intro}

+
+ + {facilitiesInPlace.length} anlegg + + + Kart og liste i samme visning + +
+
+ +
+ +
+
+ + +
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index e4afd13..3da5b73 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -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" }, ]; diff --git a/frontend/src/components/PlaceMap.tsx b/frontend/src/components/PlaceMap.tsx new file mode 100755 index 0000000..32ed6bd --- /dev/null +++ b/frontend/src/components/PlaceMap.tsx @@ -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 = {}; + +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 t; + } + + return ( + + ); +} + +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 ( +
+ +
+

Kart

+

Tegnforklaring

+
+ + Åpne + + + Lukk + +
+ +
+
+ {statusRows.map(([status, label]) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + + {label} +
+ ))} +
+ +
+ {actionRows.map(([icon, label]) => ( +
+
+ +
+ {label} +
+ ))} +
+
+
+ ); +} + +export default function PlaceMap({ facilities, placeLabel }: PlaceMapProps) { + const mapFacilities = useMemo( + () => + facilities.filter( + (facility) => typeof facility.lat === "number" && typeof facility.lng === "number" + ), + [facilities] + ); + + return ( +
+
+
+

Kartoversikt

+

Golfanlegg i {placeLabel}

+

+ Klikk på en markør for å åpne anlegget og bruke hurtiglenkene videre. +

+
+
+ +
+
+ +
+
+ + + + + {mapFacilities.map((facility) => { + const socialLinks = parseJson>(facility.social_links, []); + const facebook = socialLinks.find((entry) => entry.platform?.toLowerCase() === "facebook")?.url; + const instagram = socialLinks.find((entry) => entry.platform?.toLowerCase() === "instagram")?.url; + + return ( + + +
+
+ + {facility.name} + +

+ {facility.city} • {facility.county} +

+
+ +
+ {getStatusLabel(facility.primaryStatus)} +
+ + {facility.status_updated_at && ( +

Oppdatert {formatUpdatedDate(facility.status_updated_at)}

+ )} + +
+ + + + {facility.phone && ( + + + + )} + {facility.email && ( + + + + )} + {facility.website_url && ( + + + + )} + {facility.golfbox_tournament_url && ( + + + + )} + {facility.weather_url && ( + + + + )} + {facebook && ( + + + + )} + {instagram && ( + + + + )} + {buildMapUrl(facility.lat, facility.lng) && ( + + Åpne kart + + )} +
+
+
+
+ ); + })} +
+
+
+
+ ); +}