344 lines
14 KiB
TypeScript
Executable file
344 lines
14 KiB
TypeScript
Executable file
import type { Metadata } from "next";
|
|
import { notFound } from "next/navigation";
|
|
import { cache } from "react";
|
|
import PlaceExplorer from "@/app/sted/[slug]/PlaceExplorer";
|
|
import {
|
|
buildPlaceAverageComparison,
|
|
buildPlaceStats,
|
|
buildPlaceStatsIntro,
|
|
formatPlaceCount,
|
|
formatPlaceCurrency,
|
|
type FacilityRecord,
|
|
enrichFacilities,
|
|
filterFacilitiesByArea,
|
|
getPlaceConfigFromSlug,
|
|
getPlacePreposition,
|
|
} from "@/app/facilityData";
|
|
import { API_URL } from "@/config/constants";
|
|
import {
|
|
createBreadcrumbJsonLd,
|
|
createCollectionPageJsonLd,
|
|
createItemListJsonLd,
|
|
createPageMetadata,
|
|
resolveSeoDescription,
|
|
resolveSeoTitle,
|
|
} from "@/app/seo";
|
|
import {
|
|
buildDefaultPlaceMetaDescription,
|
|
buildDefaultPlaceMetaTitle,
|
|
} from "@/app/placeSeo";
|
|
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
|
|
|
type PlacePageData = {
|
|
slug?: string;
|
|
factbox_intro_html?: string | null;
|
|
meta_title?: string | null;
|
|
meta_description?: string | null;
|
|
updated_at?: string | null;
|
|
};
|
|
|
|
const escapeHtml = (value: string) =>
|
|
value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
|
|
const sanitizeHref = (value: string) => {
|
|
const href = value.trim();
|
|
return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#";
|
|
};
|
|
|
|
const isInternalTeeoffHref = (href: string) =>
|
|
/^(\/|#|mailto:|tel:)/i.test(href) || /^https?:\/\/([^/]+\.)?teeoff\.no(\/|$)/i.test(href);
|
|
|
|
const sanitizePlaceRichText = (value: string | null | undefined) => {
|
|
const source = String(value || "").replace(/\r\n?/g, "\n");
|
|
if (!source.trim()) return "";
|
|
|
|
const placeholders = new Map<string, string>();
|
|
let index = 0;
|
|
const keep = (html: string) => {
|
|
const key = `__HTML_TOKEN_${index++}__`;
|
|
placeholders.set(key, html);
|
|
return key;
|
|
};
|
|
|
|
let safe = source
|
|
.replace(/<\s*br\s*\/?\s*>/gi, () => keep("<br />"))
|
|
.replace(/<\s*(strong|b)\s*>/gi, () => keep("<strong>"))
|
|
.replace(/<\s*\/\s*(strong|b)\s*>/gi, () => keep("</strong>"))
|
|
.replace(/<\s*(em|i)\s*>/gi, () => keep("<em>"))
|
|
.replace(/<\s*\/\s*(em|i)\s*>/gi, () => keep("</em>"))
|
|
.replace(/<\s*u\s*>/gi, () => keep("<u>"))
|
|
.replace(/<\s*\/\s*u\s*>/gi, () => keep("</u>"))
|
|
.replace(/<\s*(p|blockquote)\s*>/gi, (_, tag: string) => keep(`<${tag.toLowerCase()}>`))
|
|
.replace(/<\s*\/\s*(p|blockquote)\s*>/gi, (_, tag: string) => keep(`</${tag.toLowerCase()}>`))
|
|
.replace(/<\s*(ul|ol)\s*>/gi, (_, tag: string) => keep(`<${tag.toLowerCase()}>`))
|
|
.replace(/<\s*\/\s*(ul|ol)\s*>/gi, (_, tag: string) => keep(`</${tag.toLowerCase()}>`))
|
|
.replace(/<\s*li\s*>/gi, () => keep("<li>"))
|
|
.replace(/<\s*\/\s*li\s*>/gi, () => keep("</li>"))
|
|
.replace(/<\s*(h2|h3)\s*>/gi, (_, tag: string) => keep(`<${tag.toLowerCase()}>`))
|
|
.replace(/<\s*\/\s*(h2|h3)\s*>/gi, (_, tag: string) => keep(`</${tag.toLowerCase()}>`))
|
|
.replace(/<\s*a\b([^>]*)>/gi, (_, attrs: string) => {
|
|
const hrefMatch = attrs.match(/href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
|
const href = sanitizeHref(hrefMatch?.[1] || hrefMatch?.[2] || hrefMatch?.[3] || "#");
|
|
if (isInternalTeeoffHref(href)) {
|
|
return keep(`<a href="${escapeHtml(href)}">`);
|
|
}
|
|
return keep(`<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer noopener">`);
|
|
})
|
|
.replace(/<\s*\/\s*a\s*>/gi, () => keep("</a>"));
|
|
|
|
safe = escapeHtml(safe).replace(/\n/g, "<br />");
|
|
|
|
for (const [token, html] of placeholders) {
|
|
safe = safe.replaceAll(token, html);
|
|
}
|
|
|
|
return safe;
|
|
};
|
|
|
|
export const dynamicParams = true;
|
|
export const revalidate = 3600;
|
|
|
|
const fetchPlacePageData = cache(async (slug: string): Promise<PlacePageData | null> => {
|
|
try {
|
|
const res = await fetch(`${API_URL}/place-pages/${slug}`, {
|
|
cache: "no-store",
|
|
});
|
|
|
|
if (!res.ok) {
|
|
return null;
|
|
}
|
|
|
|
return (await res.json()) as PlacePageData;
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
|
|
const fetchPlaceFacilities = cache(async () => fetchPublicFacilities<FacilityRecord>("place", revalidate));
|
|
|
|
export async function generateMetadata({
|
|
params,
|
|
}: {
|
|
params: Promise<{ slug: string }>;
|
|
}): Promise<Metadata> {
|
|
const { slug } = await params;
|
|
const place = getPlaceConfigFromSlug(slug);
|
|
|
|
if (!place) {
|
|
return createPageMetadata({
|
|
title: "Sted ikke funnet",
|
|
description: "Denne stedssiden finnes ikke på TeeOff.",
|
|
path: `/sted/${slug}`,
|
|
});
|
|
}
|
|
|
|
const facilities = await fetchPlaceFacilities();
|
|
const placePage = await fetchPlacePageData(slug);
|
|
const safeData = Array.isArray(facilities) ? facilities : [];
|
|
const enrichedFacilities = enrichFacilities(safeData);
|
|
const facilitiesInPlace = filterFacilitiesByArea(enrichedFacilities, place.areaFilter);
|
|
const placePreposition = place.slug === "norge" ? "i" : getPlacePreposition(place.label);
|
|
const fallbackTitle = buildDefaultPlaceMetaTitle(
|
|
facilitiesInPlace.length,
|
|
place.label,
|
|
placePreposition,
|
|
);
|
|
const fallbackDescription = buildDefaultPlaceMetaDescription(place.label, placePreposition);
|
|
|
|
return createPageMetadata({
|
|
title: resolveSeoTitle(placePage?.meta_title, fallbackTitle),
|
|
description: resolveSeoDescription(placePage?.meta_description, fallbackDescription),
|
|
path: `/sted/${slug}`,
|
|
});
|
|
}
|
|
|
|
export default async function PlacePage({ params }: { params: Promise<{ slug: string }> }) {
|
|
const { slug } = await params;
|
|
const place = getPlaceConfigFromSlug(slug);
|
|
|
|
if (!place) {
|
|
notFound();
|
|
}
|
|
|
|
let placePage: PlacePageData | null = null;
|
|
|
|
const facilities = await fetchPlaceFacilities();
|
|
|
|
try {
|
|
placePage = await fetchPlacePageData(slug);
|
|
if (!placePage) {
|
|
throw new Error("API returnerte ingen sted-side.");
|
|
}
|
|
} catch (error) {
|
|
console.error("Kritisk feil ved henting av sted-sideinnhold:", error);
|
|
placePage = null;
|
|
}
|
|
|
|
const safeData = Array.isArray(facilities) ? facilities : [];
|
|
const enrichedFacilities = enrichFacilities(safeData);
|
|
const facilitiesInPlace = filterFacilitiesByArea(enrichedFacilities, place.areaFilter);
|
|
const placeStats = buildPlaceStats(facilitiesInPlace);
|
|
const nationalStats = buildPlaceStats(enrichedFacilities);
|
|
const placeStatsIntro = buildPlaceStatsIntro(place.label, placeStats);
|
|
const isNationalPlace = place.slug === "norge";
|
|
const placePreposition = isNationalPlace ? "i" : getPlacePreposition(place.label);
|
|
const greenfeeComparison = isNationalPlace
|
|
? null
|
|
: buildPlaceAverageComparison(placeStats.avgPrimetimeGreenfee, nationalStats.avgPrimetimeGreenfee);
|
|
const membershipComparison = isNationalPlace
|
|
? null
|
|
: buildPlaceAverageComparison(placeStats.avgStandardMembership, nationalStats.avgStandardMembership);
|
|
const holeDistributionParts = [
|
|
`${formatPlaceCount(placeStats.par3HoleCount)} par 3-hull`,
|
|
`${formatPlaceCount(placeStats.par4HoleCount)} par 4-hull`,
|
|
`${formatPlaceCount(placeStats.par5HoleCount)} par 5-hull`,
|
|
placeStats.par6HoleCount > 0 ? `${formatPlaceCount(placeStats.par6HoleCount)} par 6-hull` : null,
|
|
].filter((part): part is string => Boolean(part));
|
|
const holeDistributionText =
|
|
holeDistributionParts.length > 1
|
|
? `${holeDistributionParts.slice(0, -1).join(", ")} og ${holeDistributionParts.at(-1)}`
|
|
: holeDistributionParts[0] ?? null;
|
|
const shortestLongestText =
|
|
placeStats.shortestHoleMeters !== null && placeStats.longestHoleMeters !== null
|
|
? `Det korteste golfhullet ${placePreposition} ${place.label} er ${placeStats.shortestHoleMeters} meter, mens det lengste er ${placeStats.longestHoleMeters} meter.`
|
|
: null;
|
|
const collectionJsonLd = createCollectionPageJsonLd({
|
|
name: resolveSeoTitle(
|
|
placePage?.meta_title,
|
|
buildDefaultPlaceMetaTitle(placeStats.facilityCount, place.label, placePreposition),
|
|
),
|
|
description: resolveSeoDescription(
|
|
placePage?.meta_description,
|
|
buildDefaultPlaceMetaDescription(place.label, placePreposition),
|
|
),
|
|
path: `/sted/${slug}`,
|
|
});
|
|
const itemListJsonLd = createItemListJsonLd({
|
|
name: resolveSeoTitle(
|
|
placePage?.meta_title,
|
|
buildDefaultPlaceMetaTitle(placeStats.facilityCount, place.label, placePreposition),
|
|
),
|
|
path: `/sted/${slug}`,
|
|
items: facilitiesInPlace
|
|
.filter((facility) => facility?.slug && facility?.name)
|
|
.map((facility) => ({
|
|
name: facility.name,
|
|
path: `/golfbaner/${facility.slug}`,
|
|
description: facility.description,
|
|
})),
|
|
});
|
|
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
|
{ name: "Hjem", path: "/" },
|
|
{ name: "Steder", path: "/sted/norge" },
|
|
{ name: place.label, path: `/sted/${slug}` },
|
|
]);
|
|
|
|
return (
|
|
<>
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }}
|
|
/>
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListJsonLd) }}
|
|
/>
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
|
/>
|
|
<main className="site-shell min-h-screen">
|
|
<section className="mx-auto max-w-[1400px] px-4 py-8 sm:px-6 sm:py-10 lg:px-8 lg:py-12">
|
|
<div className="max-w-4xl">
|
|
<p className="mb-3 text-[11px] font-extrabold uppercase tracking-[0.3em] text-[#6FA786]">Steder</p>
|
|
<h1 className="section-title text-4xl text-[#112015] sm:text-5xl lg:text-6xl">{place.title}</h1>
|
|
<p className="mt-4 max-w-3xl text-base leading-8 text-[#617063]">{place.intro}</p>
|
|
<div className="mt-5 flex flex-wrap gap-3">
|
|
<span className="rounded-full bg-white px-4 py-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#112015] shadow-sm">
|
|
{placeStats.facilityCount} golfanlegg
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{placePage?.factbox_intro_html ? (
|
|
<section className="mt-8 overflow-hidden rounded-[2rem] border border-[#E7D8CE] bg-[#fff8f4] shadow-sm">
|
|
<div className="px-5 py-6 sm:px-6 lg:px-8 lg:py-8">
|
|
<div
|
|
className="max-w-4xl text-base leading-8 text-[#4e3d34] [&_a]:font-bold [&_a]:text-[#d53300] [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-[#a82800] [&_blockquote]:border-l-4 [&_blockquote]:border-[#e4b9a8] [&_blockquote]:pl-4 [&_em]:italic [&_h2]:mt-8 [&_h2]:text-2xl [&_h2]:font-black [&_h2]:text-[#112015] [&_h2:first-child]:mt-0 [&_h3]:mt-6 [&_h3]:text-xl [&_h3]:font-black [&_h3]:text-[#112015] [&_ol]:my-5 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-5 [&_p:last-child]:mb-0 [&_strong]:font-bold [&_u]:underline [&_ul]:my-5 [&_ul]:list-disc [&_ul]:pl-6"
|
|
dangerouslySetInnerHTML={{ __html: sanitizePlaceRichText(placePage.factbox_intro_html) }}
|
|
/>
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
<section className="mt-8 overflow-hidden rounded-[2rem] border border-[#D7DED0] bg-white shadow-sm">
|
|
<div className="px-5 py-6 sm:px-6 lg:px-8 lg:py-8">
|
|
<div className="max-w-5xl">
|
|
<p className="text-[11px] font-extrabold uppercase tracking-[0.28em] text-[#6FA786]">Nøkkeltall</p>
|
|
<h2 className="mt-2 text-3xl text-[#112015] sm:text-4xl">
|
|
{isNationalPlace ? "Fakta om golfanleggene i Norge" : `Fakta om golfanleggene ${placePreposition} ${place.label}`}
|
|
</h2>
|
|
<p className="mt-4 text-base leading-8 text-[#213127]">{placeStatsIntro}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-px border-t border-[#D7DED0] bg-[#D7DED0] lg:grid-cols-3">
|
|
<div className="bg-white/88 px-5 py-5 sm:px-6 lg:px-8">
|
|
<p className="text-[11px] font-extrabold uppercase tracking-[0.2em] text-[#6FA786]">Greenfee</p>
|
|
<p className="mt-2 text-2xl font-black text-[#112015]">
|
|
{placeStats.avgPrimetimeGreenfee !== null ? formatPlaceCurrency(placeStats.avgPrimetimeGreenfee) : "Mangler grunnlag"}
|
|
</p>
|
|
<p className="mt-2 text-sm leading-6 text-[#4F6052]">
|
|
{placeStats.avgPrimetimeGreenfee !== null
|
|
? "Gjennomsnittlig primetime-pris i høysesong."
|
|
: "Ingen tilstrekkelige greenfee-data tilgjengelig."}
|
|
</p>
|
|
{greenfeeComparison ? <p className="mt-2 text-sm font-bold text-[#112015]">{greenfeeComparison}</p> : null}
|
|
</div>
|
|
|
|
<div className="bg-white/88 px-5 py-5 sm:px-6 lg:px-8">
|
|
<p className="text-[11px] font-extrabold uppercase tracking-[0.2em] text-[#6FA786]">Medlemskap</p>
|
|
<p className="mt-2 text-2xl font-black text-[#112015]">
|
|
{placeStats.avgStandardMembership !== null ? formatPlaceCurrency(placeStats.avgStandardMembership) : "Mangler grunnlag"}
|
|
</p>
|
|
<p className="mt-2 text-sm leading-6 text-[#4F6052]">
|
|
{placeStats.avgStandardMembership !== null
|
|
? "Gjennomsnittlig standard medlemskap med spillerett."
|
|
: "Ingen tilstrekkelige medlemskapsdata tilgjengelig."}
|
|
</p>
|
|
{membershipComparison ? <p className="mt-2 text-sm font-bold text-[#112015]">{membershipComparison}</p> : null}
|
|
</div>
|
|
|
|
<div className="bg-white/88 px-5 py-5 sm:px-6 lg:px-8">
|
|
<p className="text-[11px] font-extrabold uppercase tracking-[0.2em] text-[#6FA786]">Hullstatistikk</p>
|
|
<p className="mt-2 text-sm leading-7 text-[#112015]">
|
|
{formatPlaceCount(placeStats.hole18Count, "neuter")} 18-hullsanlegg
|
|
<br />
|
|
{formatPlaceCount(placeStats.hole9Count, "neuter")} 9-hullsanlegg
|
|
<br />
|
|
{formatPlaceCount(placeStats.hole6Count, "neuter")} 6-hullsanlegg
|
|
<br />
|
|
{formatPlaceCount(placeStats.hole27PlusCount, "neuter")} anlegg med 27 hull eller mer
|
|
</p>
|
|
{holeDistributionText ? <p className="mt-3 text-sm leading-6 text-[#4F6052]">{holeDistributionText}.</p> : null}
|
|
{shortestLongestText ? <p className="mt-2 text-sm leading-6 text-[#4F6052]">{shortestLongestText}</p> : null}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<PlaceExplorer
|
|
facilities={facilitiesInPlace}
|
|
placeLabel={place.label}
|
|
placeAreaFilter={place.areaFilter}
|
|
placeTitle={place.title}
|
|
/>
|
|
</section>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|