Etter å ha lagt til medlemskap

This commit is contained in:
Erol 2026-04-12 21:13:25 +02:00
parent 68f17b81ac
commit f6aa50a0a7
5 changed files with 706 additions and 17 deletions

View file

@ -105,14 +105,14 @@ export const HIERARCHICAL_AREA_OPTIONS = [
];
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",
aapen: "/icons/open.png",
aapen_med_vintergreener: "/icons/open-winter.png",
stengt: "/icons/closed.png",
aapner_snart: "/icons/open-soon.png",
stenger_snart: "/icons/close-soon.png",
under_utvikling: "/icons/under-development.png",
nedlagt: "/icons/discontinued.png",
ukjent: "/icons/unknown.png",
};
export const normalizeText = (value: unknown) =>

View file

@ -0,0 +1,442 @@
"use client";
import Link from "next/link";
import { Fragment, useMemo, useState } from "react";
type SortKey = "alpha" | "priceAsc" | "priceDesc";
type SectionKey = "standard" | "budget";
export type MembershipFacility = {
id: number;
slug: string;
name: string;
city?: string | null;
county?: string | null;
medlemskap_url?: string | null;
membership_updated_at?: string | null;
standard_medlemskap_kommentarer?: string | null;
navn_standard_medlemskap?: string | null;
standard_medlemskap?: number | null;
navn_rimeligste_alternativ?: string | null;
rimeligste_alternativ?: number | null;
};
type MembershipExplorerProps = {
facilities: MembershipFacility[];
};
type MembershipEntry = {
key: string;
slug: string;
clubName: string;
location: string;
membershipName: string;
price: number;
updatedAt?: string | null;
comment?: string | null;
membershipUrl?: string | null;
};
const sortOptions: Array<{ value: SortKey; label: string }> = [
{ value: "alpha", label: "Alfabetisk" },
{ value: "priceAsc", label: "Lavest pris" },
{ value: "priceDesc", label: "Hoyest pris" },
];
const sectionMeta: Record<
SectionKey,
{
kicker: string;
title: string;
intro: string;
}
> = {
standard: {
kicker: "Seksjon 1",
title: "Full spillerett 35+",
intro:
"Dette er tabellen for klubber der du vil se hva et vanlig voksenmedlemskap med friest mulig tilgang til banen koster.",
},
budget: {
kicker: "Seksjon 2",
title: "Rimeligste medlemskap med nasjonal spillerett",
intro:
"Her ser du billigste registrerte alternativ per klubb, typisk et medlemskap der greenfee betales separat nar du spiller.",
},
};
const currencyFormatter = new Intl.NumberFormat("nb-NO");
function formatCurrency(value: number) {
return `${currencyFormatter.format(value)},-`;
}
function formatDate(value?: string | null) {
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",
});
}
function hasUsableLink(value?: string | null) {
return Boolean(String(value || "").trim());
}
function sortEntries(entries: MembershipEntry[], sortKey: SortKey) {
const sorted = [...entries];
sorted.sort((a, b) => {
if (sortKey === "alpha") {
return a.clubName.localeCompare(b.clubName, "nb-NO");
}
if (sortKey === "priceAsc") {
if (a.price !== b.price) return a.price - b.price;
return a.clubName.localeCompare(b.clubName, "nb-NO");
}
if (a.price !== b.price) return b.price - a.price;
return a.clubName.localeCompare(b.clubName, "nb-NO");
});
return sorted;
}
function buildLocation(city?: string | null, county?: string | null) {
return [city, county].filter(Boolean).join(" · ");
}
function buildEntry(
facility: MembershipFacility,
section: SectionKey,
): MembershipEntry | null {
if (section === "standard") {
if (typeof facility.standard_medlemskap !== "number") return null;
return {
key: `standard-${facility.id}`,
slug: facility.slug,
clubName: facility.name,
location: buildLocation(facility.city, facility.county),
membershipName:
facility.navn_standard_medlemskap?.trim() || "Standardmedlemskap",
price: facility.standard_medlemskap,
updatedAt: facility.membership_updated_at,
comment: facility.standard_medlemskap_kommentarer,
membershipUrl: facility.medlemskap_url,
};
}
if (typeof facility.rimeligste_alternativ !== "number") return null;
return {
key: `budget-${facility.id}`,
slug: facility.slug,
clubName: facility.name,
location: buildLocation(facility.city, facility.county),
membershipName:
facility.navn_rimeligste_alternativ?.trim() || "Rimeligste alternativ",
price: facility.rimeligste_alternativ,
updatedAt: facility.membership_updated_at,
comment: facility.standard_medlemskap_kommentarer,
membershipUrl: facility.medlemskap_url,
};
}
function SectionTable({
sectionKey,
sortKey,
onSortChange,
entries,
expandedRows,
onToggleRow,
}: {
sectionKey: SectionKey;
sortKey: SortKey;
onSortChange: (sortKey: SortKey) => void;
entries: MembershipEntry[];
expandedRows: Record<string, boolean>;
onToggleRow: (key: string) => void;
}) {
const meta = sectionMeta[sectionKey];
return (
<section className="surface-card overflow-hidden rounded-[2rem]">
<div className="border-b border-[#112015]/8 bg-white/80 px-5 py-5 sm:px-7">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<p className="mb-3 text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">
{meta.kicker} · {entries.length} klubber
</p>
<h2 className="text-3xl font-black text-[#112015] sm:text-[2.25rem]">{meta.title}</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-[#5B675C]">{meta.intro}</p>
</div>
<div className="flex flex-wrap gap-2">
{sortOptions.map((option) => {
const isActive = option.value === sortKey;
return (
<button
key={option.value}
type="button"
onClick={() => onSortChange(option.value)}
className={`rounded-full px-4 py-2 text-xs font-black uppercase tracking-[0.16em] transition ${
isActive
? "bg-[#112015] text-white"
: "border border-[#112015]/10 bg-white text-[#112015] hover:border-[#FF5722] hover:text-[#FF5722]"
}`}
>
{option.label}
</button>
);
})}
</div>
</div>
</div>
<div className="px-3 pb-3 pt-2 sm:px-5 sm:pb-5">
<div className="overflow-hidden rounded-[1.5rem] border border-[#112015]/8 bg-white">
<div className="border-b border-[#112015]/8 bg-[#EFF4E8] px-4 py-3 text-xs font-bold text-[#5B675C] md:hidden">
Tabellen er beholdt pa mobil. Trykk pa en rad for detaljer.
</div>
<div className="overflow-x-auto">
<table className="min-w-full border-collapse">
<thead className="hidden bg-[#EFF4E8] text-left md:table-header-group">
<tr className="text-[11px] font-black uppercase tracking-[0.18em] text-[#516052]">
<th className="px-5 py-4">Klubb</th>
<th className="px-5 py-4">Medlemskap</th>
<th className="px-5 py-4">Pris</th>
<th className="px-5 py-4">Oppdatert</th>
<th className="px-5 py-4 text-right">Detaljer</th>
</tr>
</thead>
<tbody>
{entries.length ? (
entries.map((entry) => {
const isOpen = expandedRows[entry.key] ?? false;
return (
<Fragment key={entry.key}>
<tr className="border-b border-[#112015]/8 last:border-b-0">
<td className="px-4 py-4 align-top sm:px-5">
<div className="min-w-[11rem]">
<div className="text-sm font-black text-[#112015] sm:text-[15px]">
{entry.clubName}
</div>
{entry.location ? (
<div className="mt-1 text-xs font-medium text-[#6B776C]">
{entry.location}
</div>
) : null}
<div className="mt-2 text-xs font-bold text-[#6B776C] md:hidden">
{entry.membershipName}
</div>
</div>
</td>
<td className="hidden px-5 py-4 align-top text-sm font-semibold text-[#112015] md:table-cell">
{entry.membershipName}
</td>
<td className="px-4 py-4 align-top sm:px-5">
<div className="text-sm font-black text-[#112015] sm:text-[15px]">
{formatCurrency(entry.price)}
</div>
</td>
<td className="hidden px-5 py-4 align-top text-sm text-[#5B675C] md:table-cell">
{formatDate(entry.updatedAt)}
</td>
<td className="px-4 py-4 text-right align-top sm:px-5">
<button
type="button"
onClick={() => onToggleRow(entry.key)}
className="inline-flex items-center gap-2 rounded-full border border-[#112015]/10 bg-[#F8FAF5] px-3 py-2 text-[11px] font-black uppercase tracking-[0.14em] text-[#112015] transition hover:border-[#FF5722] hover:text-[#FF5722]"
aria-expanded={isOpen}
>
<span>{isOpen ? "Lukk" : "Detaljer"}</span>
<span className={`transition ${isOpen ? "rotate-180" : ""}`}></span>
</button>
</td>
</tr>
{isOpen ? (
<tr className="border-b border-[#112015]/8 bg-[#FAFCF7] last:border-b-0">
<td colSpan={5} className="px-4 py-4 sm:px-5">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<div className="text-[11px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
Medlemskapstype
</div>
<p className="mt-2 text-sm font-semibold text-[#112015]">
{entry.membershipName}
</p>
</div>
<div>
<div className="text-[11px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
Sist oppdatert
</div>
<p className="mt-2 text-sm font-semibold text-[#112015]">
{formatDate(entry.updatedAt)}
</p>
</div>
{entry.comment ? (
<div className="sm:col-span-2">
<div className="text-[11px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
Kommentar
</div>
<p className="mt-2 text-sm leading-6 text-[#4E5E4F]">
{entry.comment}
</p>
</div>
) : null}
</div>
<div className="flex flex-col gap-3 lg:items-end">
<Link
href={`/golfbaner/${entry.slug}`}
className="inline-flex w-full items-center justify-center rounded-full bg-[#112015] px-5 py-3 text-sm font-black text-white transition hover:bg-[#25312A] lg:w-auto"
>
Se anlegg
</Link>
{hasUsableLink(entry.membershipUrl) ? (
<a
href={entry.membershipUrl || "#"}
target="_blank"
rel="noreferrer"
className="inline-flex w-full items-center justify-center rounded-full border border-[#112015]/10 bg-white px-5 py-3 text-sm font-black text-[#112015] transition hover:border-[#FF5722] hover:text-[#FF5722] lg:w-auto"
>
Innmelding
</a>
) : null}
</div>
</div>
</td>
</tr>
) : null}
</Fragment>
);
})
) : (
<tr>
<td colSpan={5} className="px-5 py-10 text-center text-sm text-[#5B675C]">
Ingen priser er registrert i denne tabellen enna.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</section>
);
}
export default function MembershipExplorer({ facilities }: MembershipExplorerProps) {
const [activeSection, setActiveSection] = useState<SectionKey>("standard");
const [standardSort, setStandardSort] = useState<SortKey>("alpha");
const [budgetSort, setBudgetSort] = useState<SortKey>("priceAsc");
const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({});
const standardEntries = useMemo(
() =>
facilities
.map((facility) => buildEntry(facility, "standard"))
.filter((entry): entry is MembershipEntry => Boolean(entry)),
[facilities],
);
const budgetEntries = useMemo(
() =>
facilities
.map((facility) => buildEntry(facility, "budget"))
.filter((entry): entry is MembershipEntry => Boolean(entry)),
[facilities],
);
const sortedStandardEntries = useMemo(
() => sortEntries(standardEntries, standardSort),
[standardEntries, standardSort],
);
const sortedBudgetEntries = useMemo(
() => sortEntries(budgetEntries, budgetSort),
[budgetEntries, budgetSort],
);
const currentEntries =
activeSection === "standard" ? sortedStandardEntries : sortedBudgetEntries;
const currentSort = activeSection === "standard" ? standardSort : budgetSort;
const handleSortChange = (sortKey: SortKey) => {
if (activeSection === "standard") {
setStandardSort(sortKey);
return;
}
setBudgetSort(sortKey);
};
const toggleRow = (key: string) => {
setExpandedRows((current) => ({
...current,
[key]: !current[key],
}));
};
return (
<div className="space-y-5">
<div className="sticky top-24 z-20">
<div className="surface-card rounded-[1.5rem] p-2">
<div className="grid gap-2 md:grid-cols-2">
<button
type="button"
onClick={() => setActiveSection("standard")}
className={`rounded-[1.2rem] px-4 py-4 text-left transition ${
activeSection === "standard"
? "bg-[#112015] text-white"
: "bg-white text-[#112015] hover:bg-[#F5F8EF]"
}`}
>
<div className="text-[11px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
Seksjon 1
</div>
<div className="mt-2 text-lg font-black">Full spillerett 35+</div>
<div className="mt-1 text-sm opacity-80">{standardEntries.length} klubber</div>
</button>
<button
type="button"
onClick={() => setActiveSection("budget")}
className={`rounded-[1.2rem] px-4 py-4 text-left transition ${
activeSection === "budget"
? "bg-[#112015] text-white"
: "bg-white text-[#112015] hover:bg-[#F5F8EF]"
}`}
>
<div className="text-[11px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
Seksjon 2
</div>
<div className="mt-2 text-lg font-black">Rimeligste alternativ</div>
<div className="mt-1 text-sm opacity-80">{budgetEntries.length} klubber</div>
</button>
</div>
</div>
</div>
<SectionTable
sectionKey={activeSection}
sortKey={currentSort}
onSortChange={handleSortChange}
entries={currentEntries}
expandedRows={expandedRows}
onToggleRow={toggleRow}
/>
</div>
);
}

View file

@ -0,0 +1,61 @@
import { API_URL } from "@/config/constants";
import MembershipExplorer, { type MembershipFacility } from "./MembershipExplorer";
export const dynamic = "force-dynamic";
export default async function MembershipPage() {
let facilities: MembershipFacility[] = [];
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}`);
}
const data = await res.json();
facilities = Array.isArray(data) ? data : [];
} catch (error) {
console.error("Kunne ikke hente medlemsdata:", error);
facilities = [];
}
const visibleFacilities = facilities.filter(
(facility) =>
typeof facility.standard_medlemskap === "number" ||
typeof facility.rimeligste_alternativ === "number",
);
return (
<main className="site-shell min-h-screen">
<section className="border-b border-[#112015]/8 bg-[linear-gradient(135deg,rgba(139,195,74,0.16),rgba(255,255,255,0.92))]">
<div className="mx-auto max-w-[1400px] px-4 py-14 sm:px-6 lg:px-8 lg:py-20">
<div className="max-w-4xl">
<p className="mb-4 text-[11px] font-black uppercase tracking-[0.28em] text-[#8BC34A]">
Medlemskap
</p>
<h1 className="max-w-3xl text-5xl font-black text-[#112015] sm:text-6xl">
Dette koster medlemskap i norske golfklubber
</h1>
<p className="mt-6 max-w-3xl text-base leading-7 text-[#4F5F50] sm:text-lg">
Beløpene oppdateres fortløpende etter hvert som vi får verifisert nye priser.
Siden er laget for å sammenligne, ikke bare lese. Derfor er tabellformatet
beholdt også mobil.
</p>
</div>
<div className="mt-8 max-w-4xl text-sm leading-6 text-[#5B675C]">
Velg hvilken type medlemskap du vil sammenligne under. Hver rad kan åpnes for flere
detaljer, sist oppdatert-dato og lenke til klubbens egen innmelding.
</div>
</div>
</section>
<section className="mx-auto max-w-[1400px] px-4 py-8 sm:px-6 lg:px-8 lg:py-10">
<MembershipExplorer facilities={visibleFacilities} />
</section>
</main>
);
}

View file

@ -3,16 +3,79 @@ import Image from "next/image";
import { useState } from "react";
import Link from "next/link";
const placeGroups = [
{
label: "Hele Norge",
items: [{ href: "/sted/norge", label: "Hele Norge" }],
},
{
label: "Nord-Norge",
items: [
{ href: "/sted/nord-norge", label: "Nord-Norge" },
{ href: "/sted/finnmark", label: "Finnmark" },
{ href: "/sted/troms", label: "Troms" },
{ href: "/sted/nordland", label: "Nordland" },
],
},
{
label: "Midt-Norge",
items: [
{ href: "/sted/midt-norge", label: "Midt-Norge" },
{ href: "/sted/nord-trondelag", label: "Nord-Trøndelag" },
{ href: "/sted/sor-trondelag", label: "Sør-Trøndelag" },
{ href: "/sted/trondelag", label: "Trøndelag" },
],
},
{
label: "Vestlandet",
items: [
{ href: "/sted/vestlandet", label: "Vestlandet" },
{ href: "/sted/more-og-romsdal", label: "Møre og Romsdal" },
{ href: "/sted/sogn-og-fjordane", label: "Sogn og Fjordane" },
{ href: "/sted/hordaland", label: "Hordaland" },
{ href: "/sted/rogaland", label: "Rogaland" },
{ href: "/sted/vestland", label: "Vestland" },
],
},
{
label: "Sørlandet",
items: [
{ href: "/sted/sorlandet", label: "Sørlandet" },
{ href: "/sted/vest-agder", label: "Vest-Agder" },
{ href: "/sted/aust-agder", label: "Aust-Agder" },
{ href: "/sted/agder", label: "Agder" },
],
},
{
label: "Østlandet",
items: [
{ href: "/sted/ostlandet", label: "Østlandet" },
{ href: "/sted/telemark", label: "Telemark" },
{ href: "/sted/vestfold", label: "Vestfold" },
{ href: "/sted/ostfold", label: "Østfold" },
{ href: "/sted/buskerud", label: "Buskerud" },
{ href: "/sted/hedmark", label: "Hedmark" },
{ href: "/sted/oppland", label: "Oppland" },
{ href: "/sted/oslo-og-akershus", label: "Oslo og Akershus" },
{ href: "/sted/akershus", label: "Akershus" },
{ href: "/sted/oslo", label: "Oslo" },
{ href: "/sted/innlandet", label: "Innlandet" },
{ href: "/sted/viken", label: "Viken" },
],
},
];
export default function Header() {
const [isOpen, setIsOpen] = useState(false);
const [isPlacesOpen, setIsPlacesOpen] = useState(false);
const navItems = [
{ href: "/", label: "Hjem" },
{ href: "/sted/norge", label: "Steder" },
{ href: "/golfbaner", label: "Golfbaner" },
{ href: "/medlemskap", label: "Medlemskap" },
];
return (
<header className="sticky top-0 z-[100] border-b border-white/10 bg-[#25312A]/95 text-white shadow-sm backdrop-blur-md">
<header className="sticky top-0 z-[2000] border-b border-white/10 bg-[#25312A]/95 text-white shadow-sm backdrop-blur-md">
<div className="mx-auto flex h-20 max-w-[1400px] items-center justify-between px-4 sm:px-6 lg:px-8">
<Link href="/" className="h-10 transition-transform hover:scale-[1.02] active:scale-95 md:h-12">
<Image
@ -31,6 +94,48 @@ export default function Header() {
{item.label}
</Link>
))}
<div
className="group relative"
onMouseEnter={() => setIsPlacesOpen(true)}
onMouseLeave={() => setIsPlacesOpen(false)}
>
<button
type="button"
className="flex items-center gap-2 text-[12px] font-extrabold uppercase tracking-[0.14em] transition hover:text-[#8BC34A]"
onClick={() => setIsPlacesOpen((current) => !current)}
>
<span>Steder</span>
<span className={`text-[10px] transition ${isPlacesOpen ? "rotate-180" : ""}`}></span>
</button>
{isPlacesOpen && (
<div className="absolute right-0 top-full z-[2100] w-[44rem] pt-4">
<div className="max-h-[min(70vh,42rem)] overflow-y-auto rounded-[1.75rem] border border-white/10 bg-[#25312A] p-5 shadow-2xl">
<div className="grid grid-cols-2 gap-5 xl:grid-cols-3">
{placeGroups.map((group) => (
<div key={group.label}>
<p className="mb-3 text-[10px] font-extrabold uppercase tracking-[0.2em] text-[#8BC34A]">
{group.label}
</p>
<div className="space-y-2">
{group.items.map((item) => (
<Link
key={item.href}
href={item.href}
className="block text-[13px] font-bold normal-case tracking-normal text-white/88 transition hover:text-[#FF5722]"
onClick={() => setIsPlacesOpen(false)}
>
{item.label}
</Link>
))}
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
</nav>
<button onClick={() => setIsOpen(!isOpen)} className="p-2 text-white md:hidden" aria-label="Meny">
@ -41,7 +146,8 @@ export default function Header() {
</div>
{isOpen && (
<div className="absolute left-0 top-20 flex w-full flex-col gap-5 border-b border-white/10 bg-[#25312A] px-6 py-6 shadow-2xl md:hidden">
<div className="absolute left-0 top-20 max-h-[calc(100vh-5rem)] w-full overflow-y-auto border-b border-white/10 bg-[#25312A] px-6 py-6 shadow-2xl md:hidden">
<div className="flex flex-col gap-5 pb-6">
{navItems.map((item) => (
<Link
key={item.href}
@ -52,6 +158,37 @@ export default function Header() {
{item.label}
</Link>
))}
<div className="border-t border-white/10 pt-5">
<Link
onClick={() => setIsOpen(false)}
href="/sted/norge"
className="block text-lg font-extrabold uppercase tracking-[0.08em] text-white"
>
Steder
</Link>
<div className="mt-4 grid gap-4">
{placeGroups.map((group) => (
<div key={group.label}>
<p className="mb-2 text-[10px] font-extrabold uppercase tracking-[0.2em] text-[#8BC34A]">
{group.label}
</p>
<div className="space-y-2">
{group.items.map((item) => (
<Link
key={item.href}
onClick={() => setIsOpen(false)}
href={item.href}
className="block text-sm font-bold text-white/88"
>
{item.label}
</Link>
))}
</div>
</div>
))}
</div>
</div>
</div>
</div>
)}
</header>

View file

@ -25,14 +25,59 @@ const getMarkerIcon = (status: string) => {
if (!markerIconCache[key]) {
markerIconCache[key] = new Icon({
iconUrl: STATUS_ICON_PATHS[key],
iconSize: [17, 24],
iconAnchor: [8, 24],
popupAnchor: [0, -22],
iconSize: [34, 48],
iconAnchor: [17, 48],
popupAnchor: [0, -42],
});
}
return markerIconCache[key];
};
function ShiftScrollZoomGuard() {
const map = useMap();
useEffect(() => {
const updateWheelMode = (shiftPressed: boolean) => {
if (window.innerWidth < 1024) {
map.scrollWheelZoom.enable();
return;
}
if (shiftPressed) {
map.scrollWheelZoom.enable();
} else {
map.scrollWheelZoom.disable();
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Shift") updateWheelMode(true);
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === "Shift") updateWheelMode(false);
};
const onBlur = () => updateWheelMode(false);
const onResize = () => updateWheelMode(false);
updateWheelMode(false);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", onBlur);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onBlur);
window.removeEventListener("resize", onResize);
};
}, [map]);
return null;
}
function FitMapBounds({ facilities }: { facilities: EnrichedFacility[] }) {
const map = useMap();
@ -158,7 +203,7 @@ function MapLegend() {
{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" />
<img src={STATUS_ICON_PATHS[status]} alt="" className="h-9 w-[26px] shrink-0" />
<span>{label}</span>
</div>
))}
@ -197,6 +242,9 @@ export default function PlaceMap({ facilities, placeLabel }: PlaceMapProps) {
<p className="mt-3 text-base leading-7 text-[#617063]">
Klikk en markør for å åpne anlegget og bruke hurtiglenkene videre.
</p>
<p className="mt-2 hidden text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#839184] lg:block">
Hold Shift inne for å zoome med musehjulet.
</p>
</div>
<div className="lg:max-w-[34rem]">
<MapLegend />
@ -208,14 +256,15 @@ export default function PlaceMap({ facilities, placeLabel }: PlaceMapProps) {
<MapContainer
center={[64.5, 15.5]}
zoom={5}
scrollWheelZoom={false}
zoomControl={false}
scrollWheelZoom
zoomControl
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"
/>
<ShiftScrollZoomGuard />
<FitMapBounds facilities={mapFacilities} />
{mapFacilities.map((facility) => {