Etter første endring av steder og kart
This commit is contained in:
parent
d29b4a8976
commit
a9ba2660ca
7 changed files with 771 additions and 11 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
300
frontend/src/app/facilityData.ts
Executable 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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
90
frontend/src/app/sted/[slug]/page.tsx
Executable file
90
frontend/src/app/sted/[slug]/page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" },
|
||||
];
|
||||
|
||||
|
|
|
|||
311
frontend/src/components/PlaceMap.tsx
Executable file
311
frontend/src/components/PlaceMap.tsx
Executable 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 på 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='© <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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue