Etter å ha lagt til medlemskap
This commit is contained in:
parent
68f17b81ac
commit
f6aa50a0a7
5 changed files with 706 additions and 17 deletions
|
|
@ -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) =>
|
||||
|
|
|
|||
442
frontend/src/app/medlemskap/MembershipExplorer.tsx
Executable file
442
frontend/src/app/medlemskap/MembershipExplorer.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
61
frontend/src/app/medlemskap/page.tsx
Executable file
61
frontend/src/app/medlemskap/page.tsx
Executable 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å på 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 på 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='© <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) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue