Før vi begynner på SEO
This commit is contained in:
parent
f6aa50a0a7
commit
b5f9f52ffe
7 changed files with 712 additions and 4 deletions
BIN
frontend/public/icons/cropped-siteicon-1.png
Executable file
BIN
frontend/public/icons/cropped-siteicon-1.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
|
|
@ -31,6 +31,7 @@ export type FacilityRecord = {
|
||||||
golfamore_data?: unknown;
|
golfamore_data?: unknown;
|
||||||
nsg_data?: unknown;
|
nsg_data?: unknown;
|
||||||
vtg_datoer?: unknown;
|
vtg_datoer?: unknown;
|
||||||
|
vtg_updated_at?: string | null;
|
||||||
social_links?: unknown;
|
social_links?: unknown;
|
||||||
course_statuses?: unknown;
|
course_statuses?: unknown;
|
||||||
footnote?: string | null;
|
footnote?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -526,10 +526,18 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
<div className="absolute -right-20 -top-20 opacity-10 text-[200px] pointer-events-none transform group-hover:scale-110 transition-transform duration-700">🏌️♂️</div>
|
<div className="absolute -right-20 -top-20 opacity-10 text-[200px] pointer-events-none transform group-hover:scale-110 transition-transform duration-700">🏌️♂️</div>
|
||||||
|
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<h3 className="text-2xl font-black uppercase tracking-tighter mb-4 flex items-center gap-3">
|
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
Nybegynnerkurs (Veien til Golf)
|
<h3 className="text-2xl font-black uppercase tracking-tighter flex items-center gap-3">
|
||||||
</h3>
|
Nybegynnerkurs (Veien til Golf)
|
||||||
|
</h3>
|
||||||
|
<Link
|
||||||
|
href="/vtg"
|
||||||
|
className="inline-flex items-center text-[10px] font-black uppercase tracking-[0.18em] text-white/80 transition hover:text-white"
|
||||||
|
>
|
||||||
|
Se alle VTG-kurs →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
{facility.vtg_beskrivelse && (
|
{facility.vtg_beskrivelse && (
|
||||||
<p className="text-sm md:text-base text-white/90 mb-8 leading-relaxed font-medium max-w-4xl">
|
<p className="text-sm md:text-base text-white/90 mb-8 leading-relaxed font-medium max-w-4xl">
|
||||||
{facility.vtg_beskrivelse}
|
{facility.vtg_beskrivelse}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ const displayFont = Oswald({
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "TeeOff.no - Din guide til norske golfbaner",
|
title: "TeeOff.no - Din guide til norske golfbaner",
|
||||||
description: "Oppdatert banestatus, priser og informasjon om alle norske golfanlegg.",
|
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 }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|
|
||||||
661
frontend/src/app/vtg/VtgExplorer.tsx
Executable file
661
frontend/src/app/vtg/VtgExplorer.tsx
Executable file
|
|
@ -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<string, number> = {
|
||||||
|
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<CourseDateRecord[]>(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<TimeFilter>("upcoming");
|
||||||
|
const [priceFilter, setPriceFilter] = useState<PriceFilter>("all");
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>("soonest");
|
||||||
|
const [onlyWithDates, setOnlyWithDates] = useState(false);
|
||||||
|
|
||||||
|
const enrichedFacilities = useMemo(() => enrichFacilities(facilities), [facilities]);
|
||||||
|
|
||||||
|
const listings = useMemo<VtgListing[]>(
|
||||||
|
() =>
|
||||||
|
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 (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<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]">
|
||||||
|
Veien til Golf
|
||||||
|
</p>
|
||||||
|
<h1 className="max-w-3xl text-5xl font-black text-[#112015] sm:text-6xl">
|
||||||
|
Finn nybegynnerkurs i golf
|
||||||
|
</h1>
|
||||||
|
<p className="mt-6 max-w-3xl text-base leading-7 text-[#4F5F50] sm:text-lg">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="surface-card rounded-[1.75rem] p-5">
|
||||||
|
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
|
||||||
|
Klubber
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-4xl font-black text-[#112015]">{stats.clubs}</p>
|
||||||
|
<p className="mt-2 text-sm text-[#5B675C]">tilbyr VTG på TeeOff akkurat nå</p>
|
||||||
|
</div>
|
||||||
|
<div className="surface-card rounded-[1.75rem] p-5">
|
||||||
|
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
|
||||||
|
Kursdatoer
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-4xl font-black text-[#112015]">{stats.withDates}</p>
|
||||||
|
<p className="mt-2 text-sm text-[#5B675C]">har publiserte kursdatoer</p>
|
||||||
|
</div>
|
||||||
|
<div className="surface-card rounded-[1.75rem] p-5">
|
||||||
|
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
|
||||||
|
Billigste pris
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-4xl font-black text-[#112015]">
|
||||||
|
{stats.cheapest ? formatCurrency(stats.cheapest) : "Ukjent"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-[#5B675C]">laveste registrerte voksenpris</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mx-auto max-w-[1400px] px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="surface-card rounded-[2rem] p-5 sm:p-6">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1.2fr_1.2fr_0.9fr_0.9fr]">
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[11px] font-black uppercase tracking-[0.18em] text-[#516052]">
|
||||||
|
Område
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={areaFilter}
|
||||||
|
onChange={(event) => setAreaFilter(event.target.value)}
|
||||||
|
className="filter-field w-full px-4"
|
||||||
|
>
|
||||||
|
{HIERARCHICAL_AREA_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value || option.slug} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[11px] font-black uppercase tracking-[0.18em] text-[#516052]">
|
||||||
|
Klubb
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={clubQuery}
|
||||||
|
onChange={(event) => setClubQuery(event.target.value)}
|
||||||
|
className="filter-field w-full px-4"
|
||||||
|
placeholder="Søk etter klubb eller sted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[11px] font-black uppercase tracking-[0.18em] text-[#516052]">
|
||||||
|
Når
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={timeFilter}
|
||||||
|
onChange={(event) => setTimeFilter(event.target.value as TimeFilter)}
|
||||||
|
className="filter-field w-full px-4"
|
||||||
|
>
|
||||||
|
{timeOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-[11px] font-black uppercase tracking-[0.18em] text-[#516052]">
|
||||||
|
Pris
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={priceFilter}
|
||||||
|
onChange={(event) => setPriceFilter(event.target.value as PriceFilter)}
|
||||||
|
className="filter-field w-full px-4"
|
||||||
|
>
|
||||||
|
{priceOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col gap-4 border-t border-[#112015]/8 pt-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<label className="inline-flex items-center gap-3 text-sm font-semibold text-[#112015]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={onlyWithDates}
|
||||||
|
onChange={(event) => setOnlyWithDates(event.target.checked)}
|
||||||
|
className="h-4 w-4 accent-[#8BC34A]"
|
||||||
|
/>
|
||||||
|
Vis bare kurs med publiserte datoer
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{sortOptions.map((option) => {
|
||||||
|
const isActive = option.value === sortKey;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mx-auto max-w-[1400px] px-4 pb-12 sm:px-6 lg:px-8 lg:pb-16">
|
||||||
|
<div className="mb-5 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
|
||||||
|
Resultater
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-3xl font-black text-[#112015]">
|
||||||
|
{filteredListings.length} VTG-tilbud
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-2xl text-sm text-[#5B675C]">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredListings.length ? (
|
||||||
|
filteredListings.map((listing) => (
|
||||||
|
<article key={listing.id} className="surface-card rounded-[2rem] p-5 sm:p-6">
|
||||||
|
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h3 className="text-2xl font-black text-[#112015]">{listing.name}</h3>
|
||||||
|
<span className="rounded-full bg-[#EFF4E8] px-3 py-1 text-[11px] font-black uppercase tracking-[0.16em] text-[#556555]">
|
||||||
|
{listing.city ? `${listing.city} · ${listing.county}` : listing.county || "Norge"}
|
||||||
|
</span>
|
||||||
|
{listing.hasPublishedDates ? (
|
||||||
|
<span className="rounded-full bg-emerald-100 px-3 py-1 text-[11px] font-black uppercase tracking-[0.16em] text-emerald-700">
|
||||||
|
Har kursdato
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-[#EFF4E8] px-3 py-1 text-[11px] font-black uppercase tracking-[0.16em] text-[#556555]">
|
||||||
|
Ingen dato publisert
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="rounded-[1.4rem] bg-[#F7F9F2] p-4">
|
||||||
|
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
|
||||||
|
Pris
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-2xl font-black text-[#112015]">
|
||||||
|
{formatCurrency(listing.vtgPris)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[1.4rem] bg-[#F7F9F2] p-4">
|
||||||
|
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
|
||||||
|
Neste kurs
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-lg font-black text-[#112015]">
|
||||||
|
{listing.nextCourse?.raw || "Ingen dato publisert"}
|
||||||
|
</p>
|
||||||
|
{listing.nextCourse?.status ? (
|
||||||
|
<span
|
||||||
|
className={`mt-2 inline-flex rounded-full px-2.5 py-1 text-[10px] font-black uppercase tracking-[0.14em] ${statusTone(
|
||||||
|
listing.nextCourse.status,
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{listing.nextCourse.status}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[1.4rem] bg-[#F7F9F2] p-4">
|
||||||
|
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
|
||||||
|
Kursdatoer
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-2xl font-black text-[#112015]">
|
||||||
|
{listing.hasPublishedDates ? listing.allDates.length : 0}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-[#5B675C]">
|
||||||
|
{listing.upcomingCourseCount > 0
|
||||||
|
? `${listing.upcomingCourseCount} kommende`
|
||||||
|
: listing.hasPublishedDates
|
||||||
|
? "Kun historiske / utydelige datoer"
|
||||||
|
: "Ingen kursdatoer registrert"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{listing.vtgBeskrivelse ? (
|
||||||
|
<p className="mt-4 max-w-4xl text-sm leading-6 text-[#4E5E4F]">
|
||||||
|
{listing.vtgBeskrivelse}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-4 max-w-4xl text-sm leading-6 text-[#697669]">
|
||||||
|
Ingen kort kursbeskrivelse er publisert ennå, men du kan fortsatt gå videre
|
||||||
|
til klubbside eller innmelding dersom lenke finnes.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{listing.allDates.length > 1 ? (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{listing.allDates.slice(0, 6).map((courseDate, index) => (
|
||||||
|
<span
|
||||||
|
key={`${listing.id}-${courseDate.raw}-${index}`}
|
||||||
|
className={`inline-flex rounded-full px-3 py-1.5 text-[11px] font-black tracking-[0.08em] ${statusTone(
|
||||||
|
courseDate.status,
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{courseDate.raw}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col gap-3 xl:w-auto xl:min-w-[13rem]">
|
||||||
|
<Link
|
||||||
|
href={`/golfbaner/${listing.slug}`}
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-[#112015] px-5 py-3 text-sm font-black text-white transition hover:bg-[#25312A]"
|
||||||
|
>
|
||||||
|
Se klubbside
|
||||||
|
</Link>
|
||||||
|
{listing.vtgLenke ? (
|
||||||
|
<a
|
||||||
|
href={listing.vtgLenke.split(",")[0].trim()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex 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]"
|
||||||
|
>
|
||||||
|
Ta VTG her
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
<p className="text-center text-xs text-[#697669] xl:text-right">
|
||||||
|
Sist oppdatert: {formatDate(listing.vtgUpdatedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="surface-card rounded-[2rem] p-10 text-center">
|
||||||
|
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
|
||||||
|
Ingen treff
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-3 text-2xl font-black text-[#112015]">
|
||||||
|
Ingen VTG-tilbud matcher filtrene dine
|
||||||
|
</h3>
|
||||||
|
<p className="mx-auto mt-3 max-w-2xl text-sm leading-6 text-[#5B675C]">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/src/app/vtg/page.tsx
Executable file
32
frontend/src/app/vtg/page.tsx
Executable file
|
|
@ -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 (
|
||||||
|
<main className="site-shell min-h-screen">
|
||||||
|
<VtgExplorer facilities={facilities} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -72,6 +72,7 @@ export default function Header() {
|
||||||
{ href: "/", label: "Hjem" },
|
{ href: "/", label: "Hjem" },
|
||||||
{ href: "/golfbaner", label: "Golfbaner" },
|
{ href: "/golfbaner", label: "Golfbaner" },
|
||||||
{ href: "/medlemskap", label: "Medlemskap" },
|
{ href: "/medlemskap", label: "Medlemskap" },
|
||||||
|
{ href: "/vtg", label: "VTG" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue