diff --git a/frontend/src/app/facilityData.ts b/frontend/src/app/facilityData.ts index a117563..fbad2eb 100755 --- a/frontend/src/app/facilityData.ts +++ b/frontend/src/app/facilityData.ts @@ -105,14 +105,14 @@ export const HIERARCHICAL_AREA_OPTIONS = [ ]; 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", + 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) => diff --git a/frontend/src/app/medlemskap/MembershipExplorer.tsx b/frontend/src/app/medlemskap/MembershipExplorer.tsx new file mode 100755 index 0000000..d82854f --- /dev/null +++ b/frontend/src/app/medlemskap/MembershipExplorer.tsx @@ -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; + onToggleRow: (key: string) => void; +}) { + const meta = sectionMeta[sectionKey]; + + return ( +
+
+
+
+

+ {meta.kicker} · {entries.length} klubber +

+

{meta.title}

+

{meta.intro}

+
+ +
+ {sortOptions.map((option) => { + const isActive = option.value === sortKey; + return ( + + ); + })} +
+
+
+ +
+
+
+ Tabellen er beholdt pa mobil. Trykk pa en rad for detaljer. +
+ +
+ + + + + + + + + + + + {entries.length ? ( + entries.map((entry) => { + const isOpen = expandedRows[entry.key] ?? false; + + return ( + + + + + + + + + {isOpen ? ( + + + + ) : null} + + ); + }) + ) : ( + + + + )} + +
KlubbMedlemskapPrisOppdatertDetaljer
+
+
+ {entry.clubName} +
+ {entry.location ? ( +
+ {entry.location} +
+ ) : null} +
+ {entry.membershipName} +
+
+
+ {entry.membershipName} + +
+ {formatCurrency(entry.price)} +
+
+ {formatDate(entry.updatedAt)} + + +
+
+
+
+
+ Medlemskapstype +
+

+ {entry.membershipName} +

+
+ +
+
+ Sist oppdatert +
+

+ {formatDate(entry.updatedAt)} +

+
+ + {entry.comment ? ( +
+
+ Kommentar +
+

+ {entry.comment} +

+
+ ) : null} +
+ +
+ + Se anlegg + + {hasUsableLink(entry.membershipUrl) ? ( + + Innmelding + + ) : null} +
+
+
+ Ingen priser er registrert i denne tabellen enna. +
+
+
+
+
+ ); +} + +export default function MembershipExplorer({ facilities }: MembershipExplorerProps) { + const [activeSection, setActiveSection] = useState("standard"); + const [standardSort, setStandardSort] = useState("alpha"); + const [budgetSort, setBudgetSort] = useState("priceAsc"); + const [expandedRows, setExpandedRows] = useState>({}); + + 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 ( +
+
+
+
+ + + +
+
+
+ + +
+ ); +} diff --git a/frontend/src/app/medlemskap/page.tsx b/frontend/src/app/medlemskap/page.tsx new file mode 100755 index 0000000..7cb2aed --- /dev/null +++ b/frontend/src/app/medlemskap/page.tsx @@ -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 ( +
+
+
+
+

+ Medlemskap +

+

+ Dette koster medlemskap i norske golfklubber +

+

+ 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. +

+
+
+ Velg hvilken type medlemskap du vil sammenligne under. Hver rad kan åpnes for flere + detaljer, sist oppdatert-dato og lenke til klubbens egen innmelding. +
+
+
+ +
+ +
+
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 3da5b73..f30b4a0 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -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 ( -
+
))} +
setIsPlacesOpen(true)} + onMouseLeave={() => setIsPlacesOpen(false)} + > + + + {isPlacesOpen && ( +
+
+
+ {placeGroups.map((group) => ( +
+

+ {group.label} +

+
+ {group.items.map((item) => ( + setIsPlacesOpen(false)} + > + {item.label} + + ))} +
+
+ ))} +
+
+
+ )} +
{isOpen && ( -
+
+
{navItems.map((item) => ( ))} +
+ setIsOpen(false)} + href="/sted/norge" + className="block text-lg font-extrabold uppercase tracking-[0.08em] text-white" + > + Steder + +
+ {placeGroups.map((group) => ( +
+

+ {group.label} +

+
+ {group.items.map((item) => ( + setIsOpen(false)} + href={item.href} + className="block text-sm font-bold text-white/88" + > + {item.label} + + ))} +
+
+ ))} +
+
+
)}
diff --git a/frontend/src/components/PlaceMap.tsx b/frontend/src/components/PlaceMap.tsx index 32ed6bd..9df66d5 100755 --- a/frontend/src/components/PlaceMap.tsx +++ b/frontend/src/components/PlaceMap.tsx @@ -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]) => (
{/* eslint-disable-next-line @next/next/no-img-element */} - + {label}
))} @@ -197,6 +242,9 @@ export default function PlaceMap({ facilities, placeLabel }: PlaceMapProps) {

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

+

+ Hold Shift inne for å zoome med musehjulet. +

@@ -208,14 +256,15 @@ export default function PlaceMap({ facilities, placeLabel }: PlaceMapProps) { + {mapFacilities.map((facility) => {