Nye-TeeOff/frontend/src/app/sted/[slug]/page.tsx

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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
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>
</>
);
}