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
+
+
+
+
+
+
+
+
+
+
+ Område
+
+ setAreaFilter(event.target.value)}
+ className="filter-field w-full px-4"
+ >
+ {HIERARCHICAL_AREA_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+ Klubb
+
+ setClubQuery(event.target.value)}
+ className="filter-field w-full px-4"
+ placeholder="Søk etter klubb eller sted"
+ />
+
+
+
+
+ Når
+
+ setTimeFilter(event.target.value as TimeFilter)}
+ className="filter-field w-full px-4"
+ >
+ {timeOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+ Pris
+
+ setPriceFilter(event.target.value as PriceFilter)}
+ className="filter-field w-full px-4"
+ >
+ {priceOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+
+ setOnlyWithDates(event.target.checked)}
+ className="h-4 w-4 accent-[#8BC34A]"
+ />
+ Vis bare kurs med publiserte datoer
+
+
+
+
+ {sortOptions.map((option) => {
+ const isActive = option.value === sortKey;
+ return (
+ setSortKey(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}
+
+ );
+ })}
+
+
+
{
+ setAreaFilter("");
+ setClubQuery("");
+ setTimeFilter("upcoming");
+ setPriceFilter("all");
+ setSortKey("soonest");
+ setOnlyWithDates(false);
+ }}
+ className="rounded-full border border-[#112015]/10 bg-white px-4 py-2 text-xs font-black uppercase tracking-[0.16em] text-[#112015] transition hover:border-[#FF5722] hover:text-[#FF5722]"
+ >
+ Nullstill filtre
+
+
+
+
+
+
+
+
+
+
+ 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 (