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;
|
||||
nsg_data?: unknown;
|
||||
vtg_datoer?: unknown;
|
||||
vtg_updated_at?: string | null;
|
||||
social_links?: unknown;
|
||||
course_statuses?: unknown;
|
||||
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="relative z-10">
|
||||
<h3 className="text-2xl font-black uppercase tracking-tighter mb-4 flex items-center gap-3">
|
||||
Nybegynnerkurs (Veien til Golf)
|
||||
</h3>
|
||||
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<h3 className="text-2xl font-black uppercase tracking-tighter flex items-center gap-3">
|
||||
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 && (
|
||||
<p className="text-sm md:text-base text-white/90 mb-8 leading-relaxed font-medium max-w-4xl">
|
||||
{facility.vtg_beskrivelse}
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
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: "/golfbaner", label: "Golfbaner" },
|
||||
{ href: "/medlemskap", label: "Medlemskap" },
|
||||
{ href: "/vtg", label: "VTG" },
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
|||
Loading…
Reference in a new issue