diff --git a/frontend/public/icons/cropped-siteicon-1.png b/frontend/public/icons/cropped-siteicon-1.png new file mode 100755 index 0000000..c3babac Binary files /dev/null and b/frontend/public/icons/cropped-siteicon-1.png differ diff --git a/frontend/src/app/facilityData.ts b/frontend/src/app/facilityData.ts index fbad2eb..1ebd43f 100755 --- a/frontend/src/app/facilityData.ts +++ b/frontend/src/app/facilityData.ts @@ -31,6 +31,7 @@ export type FacilityRecord = { golfamore_data?: unknown; nsg_data?: unknown; vtg_datoer?: unknown; + vtg_updated_at?: string | null; social_links?: unknown; course_statuses?: unknown; footnote?: string | null; diff --git a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx index a6966a8..6db4bfa 100644 --- a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx +++ b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx @@ -526,10 +526,18 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
🏌️‍♂️
-

- Nybegynnerkurs (Veien til Golf) -

- +
+

+ Nybegynnerkurs (Veien til Golf) +

+ + Se alle VTG-kurs → + +
+ {facility.vtg_beskrivelse && (

{facility.vtg_beskrivelse} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 1dcca23..0b4c305 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -18,6 +18,11 @@ const displayFont = Oswald({ export const metadata: Metadata = { title: "TeeOff.no - Din guide til norske golfbaner", description: "Oppdatert banestatus, priser og informasjon om alle norske golfanlegg.", + icons: { + icon: "/icons/cropped-siteicon-1.png", + shortcut: "/icons/cropped-siteicon-1.png", + apple: "/icons/cropped-siteicon-1.png", + }, }; export default function RootLayout({ children }: { children: React.ReactNode }) { diff --git a/frontend/src/app/vtg/VtgExplorer.tsx b/frontend/src/app/vtg/VtgExplorer.tsx new file mode 100755 index 0000000..3872503 --- /dev/null +++ b/frontend/src/app/vtg/VtgExplorer.tsx @@ -0,0 +1,661 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { + enrichFacilities, + filterFacilitiesByArea, + HIERARCHICAL_AREA_OPTIONS, + parseJson, + type EnrichedFacility, + type FacilityRecord, +} from "@/app/facilityData"; + +type SortKey = "soonest" | "price" | "alpha"; +type TimeFilter = "all" | "upcoming" | "thisMonth" | "next30"; +type PriceFilter = "all" | "under1500" | "1500to2500" | "2500plus"; + +type CourseDateRecord = { + dato?: string; + status?: string; +}; + +type CourseDateSummary = { + raw: string; + status: string; + comparableDate: Date | null; +}; + +type VtgExplorerProps = { + facilities: FacilityRecord[]; +}; + +type VtgListing = { + id: number; + slug: string; + name: string; + city?: string | null; + county?: string | null; + vtgPris?: number | null; + vtgLenke?: string | null; + vtgBeskrivelse?: string | null; + vtgUpdatedAt?: string | null; + nextCourse: CourseDateSummary | null; + upcomingCourseCount: number; + hasPublishedDates: boolean; + allDates: CourseDateSummary[]; + enriched: EnrichedFacility; +}; + +const timeOptions: Array<{ value: TimeFilter; label: string }> = [ + { value: "all", label: "Alle" }, + { value: "upcoming", label: "Kommende" }, + { value: "thisMonth", label: "Denne måneden" }, + { value: "next30", label: "Neste 30 dager" }, +]; + +const priceOptions: Array<{ value: PriceFilter; label: string }> = [ + { value: "all", label: "Alle priser" }, + { value: "under1500", label: "Under 1500" }, + { value: "1500to2500", label: "1500–2500" }, + { value: "2500plus", label: "2500+" }, +]; + +const sortOptions: Array<{ value: SortKey; label: string }> = [ + { value: "soonest", label: "Snartest først" }, + { value: "price", label: "Billigst først" }, + { value: "alpha", label: "Alfabetisk" }, +]; + +const monthMap: Record = { + januar: 0, + jan: 0, + februar: 1, + feb: 1, + mars: 2, + mar: 2, + april: 3, + apr: 3, + mai: 4, + juni: 5, + jun: 5, + juli: 6, + jul: 6, + august: 7, + aug: 7, + september: 8, + sep: 8, + sept: 8, + oktober: 9, + okt: 9, + november: 10, + nov: 10, + desember: 11, + des: 11, +}; + +const currencyFormatter = new Intl.NumberFormat("nb-NO"); + +function formatCurrency(value?: number | null) { + if (typeof value !== "number") return "Ikke publisert"; + 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 normalizeWhitespace(value: string) { + return value.replace(/\s+/g, " ").trim(); +} + +function getStartOfToday() { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return today; +} + +function parseComparableDate(raw: string) { + const trimmed = normalizeWhitespace(raw); + if (!trimmed) return null; + + const isoCandidate = new Date(trimmed); + if (!Number.isNaN(isoCandidate.getTime())) { + isoCandidate.setHours(0, 0, 0, 0); + return isoCandidate; + } + + const numericDateMatch = trimmed.match(/(\d{1,2})[./](\d{1,2})[./](\d{2,4})/); + if (numericDateMatch) { + const day = Number(numericDateMatch[1]); + const month = Number(numericDateMatch[2]) - 1; + const yearValue = Number(numericDateMatch[3]); + const year = yearValue < 100 ? 2000 + yearValue : yearValue; + const parsed = new Date(year, month, day); + parsed.setHours(0, 0, 0, 0); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + + const normalized = trimmed + .toLowerCase() + .replace(/[.,]/g, " ") + .replace(/\s+/g, " "); + + const monthToken = Object.keys(monthMap).find((monthName) => + normalized.includes(monthName), + ); + + if (!monthToken) return null; + + const monthIndex = monthMap[monthToken]; + const dayMatch = normalized.match(/(\d{1,2})/); + if (!dayMatch) return null; + + const explicitYearMatch = normalized.match(/\b(20\d{2})\b/); + const today = getStartOfToday(); + let year = explicitYearMatch ? Number(explicitYearMatch[1]) : today.getFullYear(); + const day = Number(dayMatch[1]); + + let parsed = new Date(year, monthIndex, day); + parsed.setHours(0, 0, 0, 0); + + if (!explicitYearMatch && parsed.getTime() < today.getTime() - 7 * 24 * 60 * 60 * 1000) { + year += 1; + parsed = new Date(year, monthIndex, day); + parsed.setHours(0, 0, 0, 0); + } + + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function summarizeCourseDates(rawDates: unknown) { + const parsedDates = parseJson(rawDates, []); + const courseDates = (Array.isArray(parsedDates) ? parsedDates : []) + .map((entry) => ({ + raw: normalizeWhitespace(String(entry?.dato || "")), + status: normalizeWhitespace(String(entry?.status || "Ukjent")), + comparableDate: parseComparableDate(String(entry?.dato || "")), + })) + .filter((entry) => entry.raw); + + const today = getStartOfToday(); + const datedEntries = courseDates + .filter((entry) => entry.comparableDate) + .sort((a, b) => a.comparableDate!.getTime() - b.comparableDate!.getTime()); + + const upcomingEntries = datedEntries.filter( + (entry) => entry.comparableDate!.getTime() >= today.getTime(), + ); + + return { + allDates: courseDates, + nextCourse: upcomingEntries[0] || datedEntries[0] || null, + upcomingCourseCount: upcomingEntries.length, + hasPublishedDates: courseDates.length > 0, + }; +} + +function matchesTimeFilter(nextCourse: CourseDateSummary | null, filter: TimeFilter) { + if (filter === "all") return true; + if (!nextCourse?.comparableDate) return false; + + const today = getStartOfToday(); + const target = nextCourse.comparableDate; + + if (filter === "upcoming") { + return target.getTime() >= today.getTime(); + } + + if (filter === "thisMonth") { + return ( + target.getFullYear() === today.getFullYear() && + target.getMonth() === today.getMonth() && + target.getTime() >= today.getTime() + ); + } + + const nextThirty = new Date(today); + nextThirty.setDate(today.getDate() + 30); + return target.getTime() >= today.getTime() && target.getTime() <= nextThirty.getTime(); +} + +function matchesPriceFilter(price: number | null | undefined, filter: PriceFilter) { + if (filter === "all") return true; + if (typeof price !== "number") return false; + if (filter === "under1500") return price < 1500; + if (filter === "1500to2500") return price >= 1500 && price <= 2500; + return price > 2500; +} + +function statusTone(status: string) { + const normalized = status.toLowerCase(); + if (normalized.includes("full")) return "bg-rose-100 text-rose-700"; + if (normalized.includes("vente") || normalized.includes("få")) return "bg-amber-100 text-amber-800"; + if (normalized.includes("ledig")) return "bg-emerald-100 text-emerald-700"; + return "bg-[#EFF4E8] text-[#556555]"; +} + +export default function VtgExplorer({ facilities }: VtgExplorerProps) { + const [areaFilter, setAreaFilter] = useState(""); + const [clubQuery, setClubQuery] = useState(""); + const [timeFilter, setTimeFilter] = useState("upcoming"); + const [priceFilter, setPriceFilter] = useState("all"); + const [sortKey, setSortKey] = useState("soonest"); + const [onlyWithDates, setOnlyWithDates] = useState(false); + + const enrichedFacilities = useMemo(() => enrichFacilities(facilities), [facilities]); + + const listings = useMemo( + () => + enrichedFacilities + .filter((facility) => facility.hasVtg) + .map((facility) => { + const summary = summarizeCourseDates(facility.vtg_datoer); + return { + id: facility.id, + slug: facility.slug, + name: facility.name, + city: facility.city, + county: facility.county, + vtgPris: facility.vtg_pris, + vtgLenke: facility.vtg_lenke, + vtgBeskrivelse: facility.vtg_beskrivelse, + vtgUpdatedAt: facility.vtg_updated_at || facility.status_updated_at, + nextCourse: summary.nextCourse, + upcomingCourseCount: summary.upcomingCourseCount, + hasPublishedDates: summary.hasPublishedDates, + allDates: summary.allDates, + enriched: facility, + }; + }), + [enrichedFacilities], + ); + + const areaFilteredListings = useMemo( + () => + areaFilter + ? filterFacilitiesByArea( + listings.map((listing) => listing.enriched), + areaFilter, + ).map((facility) => listings.find((listing) => listing.id === facility.id)!).filter(Boolean) + : listings, + [areaFilter, listings], + ); + + const filteredListings = useMemo(() => { + const normalizedClubQuery = clubQuery.trim().toLowerCase(); + + const filtered = areaFilteredListings.filter((listing) => { + const matchesClub = + !normalizedClubQuery || + listing.name.toLowerCase().includes(normalizedClubQuery) || + String(listing.city || "") + .toLowerCase() + .includes(normalizedClubQuery); + + if (!matchesClub) return false; + if (!matchesTimeFilter(listing.nextCourse, timeFilter)) return false; + if (!matchesPriceFilter(listing.vtgPris, priceFilter)) return false; + if (onlyWithDates && !listing.hasPublishedDates) return false; + return true; + }); + + const sorted = [...filtered].sort((a, b) => { + if (sortKey === "alpha") { + return a.name.localeCompare(b.name, "nb-NO"); + } + + if (sortKey === "price") { + const priceA = typeof a.vtgPris === "number" ? a.vtgPris : Number.POSITIVE_INFINITY; + const priceB = typeof b.vtgPris === "number" ? b.vtgPris : Number.POSITIVE_INFINITY; + if (priceA !== priceB) return priceA - priceB; + return a.name.localeCompare(b.name, "nb-NO"); + } + + const dateA = a.nextCourse?.comparableDate?.getTime() ?? Number.POSITIVE_INFINITY; + const dateB = b.nextCourse?.comparableDate?.getTime() ?? Number.POSITIVE_INFINITY; + if (dateA !== dateB) return dateA - dateB; + return a.name.localeCompare(b.name, "nb-NO"); + }); + + return sorted; + }, [areaFilteredListings, clubQuery, onlyWithDates, priceFilter, sortKey, timeFilter]); + + const stats = useMemo(() => { + const withDates = listings.filter((listing) => listing.hasPublishedDates).length; + const cheapest = listings + .map((listing) => listing.vtgPris) + .filter((value): value is number => typeof value === "number") + .sort((a, b) => a - b)[0]; + + return { + clubs: listings.length, + withDates, + cheapest, + }; + }, [listings]); + + return ( +

+
+
+
+

+ Veien til Golf +

+

+ Finn nybegynnerkurs i golf +

+

+ Sammenlign VTG-tilbud etter område, dato, pris og klubb. Dette er laget for folk + som vil finne et konkret sted å starte, ikke grave seg gjennom hver enkelt klubbside. +

+
+ +
+
+

+ Klubber +

+

{stats.clubs}

+

tilbyr VTG på TeeOff akkurat nå

+
+
+

+ Kursdatoer +

+

{stats.withDates}

+

har publiserte kursdatoer

+
+
+

+ Billigste pris +

+

+ {stats.cheapest ? formatCurrency(stats.cheapest) : "Ukjent"} +

+

laveste registrerte voksenpris

+
+
+
+
+ +
+
+
+
+ + +
+ +
+ + setClubQuery(event.target.value)} + className="filter-field w-full px-4" + placeholder="Søk etter klubb eller sted" + /> +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ {sortOptions.map((option) => { + const isActive = option.value === sortKey; + return ( + + ); + })} +
+ + +
+
+
+
+ +
+
+
+

+ Resultater +

+

+ {filteredListings.length} VTG-tilbud +

+
+

+ Vi viser klubbene som har pris, beskrivelse, lenke eller kursdatoer registrert. + Resultater uten dato kan fortsatt være nyttige hvis du bare vil finne riktig klubb. +

+
+ +
+ {filteredListings.length ? ( + filteredListings.map((listing) => ( +
+
+
+
+

{listing.name}

+ + {listing.city ? `${listing.city} · ${listing.county}` : listing.county || "Norge"} + + {listing.hasPublishedDates ? ( + + Har kursdato + + ) : ( + + Ingen dato publisert + + )} +
+ +
+
+

+ Pris +

+

+ {formatCurrency(listing.vtgPris)} +

+
+ +
+

+ Neste kurs +

+

+ {listing.nextCourse?.raw || "Ingen dato publisert"} +

+ {listing.nextCourse?.status ? ( + + {listing.nextCourse.status} + + ) : null} +
+ +
+

+ Kursdatoer +

+

+ {listing.hasPublishedDates ? listing.allDates.length : 0} +

+

+ {listing.upcomingCourseCount > 0 + ? `${listing.upcomingCourseCount} kommende` + : listing.hasPublishedDates + ? "Kun historiske / utydelige datoer" + : "Ingen kursdatoer registrert"} +

+
+
+ + {listing.vtgBeskrivelse ? ( +

+ {listing.vtgBeskrivelse} +

+ ) : ( +

+ Ingen kort kursbeskrivelse er publisert ennå, men du kan fortsatt gå videre + til klubbside eller innmelding dersom lenke finnes. +

+ )} + + {listing.allDates.length > 1 ? ( +
+ {listing.allDates.slice(0, 6).map((courseDate, index) => ( + + {courseDate.raw} + + ))} +
+ ) : null} +
+ +
+ + Se klubbside + + {listing.vtgLenke ? ( + + Ta VTG her + + ) : null} +

+ Sist oppdatert: {formatDate(listing.vtgUpdatedAt)} +

+
+
+
+ )) + ) : ( +
+

+ Ingen treff +

+

+ Ingen VTG-tilbud matcher filtrene dine +

+

+ Prøv et større område, fjern kravet om publiserte datoer eller bytt til en bredere + prisgruppe. Noen klubber publiserer bare pris og lenke. +

+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/vtg/page.tsx b/frontend/src/app/vtg/page.tsx new file mode 100755 index 0000000..a29dac3 --- /dev/null +++ b/frontend/src/app/vtg/page.tsx @@ -0,0 +1,32 @@ +import { API_URL } from "@/config/constants"; +import VtgExplorer from "./VtgExplorer"; +import type { FacilityRecord } from "@/app/facilityData"; + +export const dynamic = "force-dynamic"; + +export default async function VtgPage() { + 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}`); + } + + const data = await res.json(); + facilities = Array.isArray(data) ? data : []; + } catch (error) { + console.error("Kunne ikke hente VTG-data:", error); + facilities = []; + } + + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index f30b4a0..5bae6c9 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -72,6 +72,7 @@ export default function Header() { { href: "/", label: "Hjem" }, { href: "/golfbaner", label: "Golfbaner" }, { href: "/medlemskap", label: "Medlemskap" }, + { href: "/vtg", label: "VTG" }, ]; return (