Før skjema nederst på hver baneside

This commit is contained in:
Erol Haagenrud 2026-04-19 12:33:05 +02:00
parent d53383b6fd
commit d3a967c664
5 changed files with 164 additions and 23 deletions

View file

@ -4,6 +4,7 @@ import { STATUS_MAP } from "@/config/constants";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { type EnrichedFacility } from "@/app/facilityData";
type SortMethod = "updated" | "dist" | "alpha";
type Variant = "home" | "catalog";
@ -65,6 +66,8 @@ type FacilitySearchProps = {
intro?: string;
fixedAreaFilter?: string;
hideTitleBlock?: boolean;
onFilteredFacilitiesChange?: (facilities: EnrichedFacility[]) => void;
filterHeading?: string;
};
type SpecialFlags = {
@ -393,6 +396,8 @@ export default function FacilitySearch({
intro = "Bruk område, banestatus og fasiliteter for å snevre inn oversikten. Her får katalogen være arbeidsflate, ikke hero.",
fixedAreaFilter = "",
hideTitleBlock = false,
onFilteredFacilitiesChange,
filterHeading = "Søk golfbaner",
}: FacilitySearchProps) {
const [searchQuery, setSearchQuery] = useState("");
const [areaFilter, setAreaFilter] = useState(fixedAreaFilter);
@ -509,6 +514,13 @@ export default function FacilitySearch({
const hasGolfamore = facility.golfamore === true || Object.keys(golfamoreData).length > 0;
const hasNSG = Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0;
const hasSimulator = hasTruthyAmenity(amenities.simulator);
const hasDrivingRange = hasTruthyAmenity(amenities.drivingrange);
const vtgDates = parseJson<unknown[]>(facility.vtg_datoer, []);
const hasVtg =
Boolean(facility.vtg_pris) ||
Boolean(facility.vtg_lenke) ||
Boolean(facility.vtg_beskrivelse) ||
(Array.isArray(vtgDates) && vtgDates.length > 0);
const architectKey = normalizeText(facility.architect || "");
const updatedTsRaw = facility.status_updated_at ? new Date(facility.status_updated_at).getTime() : 0;
@ -570,9 +582,15 @@ export default function FacilitySearch({
return {
...facility,
holeValue,
countySlug,
regions,
statuses,
primaryStatus,
hasGolfamore,
hasNSG,
hasSimulator,
hasDrivingRange,
hasVtg,
distance,
lastUpdatedTs,
matchesSearch,
@ -644,6 +662,10 @@ export default function FacilitySearch({
}
}, [filtersCount, isCollapsibleHomeSearch]);
useEffect(() => {
onFilteredFacilitiesChange?.(processedFacilities);
}, [onFilteredFacilitiesChange, processedFacilities]);
return (
<section className="mx-auto max-w-[1400px] px-4 py-6 sm:px-6 sm:py-8 lg:px-8 lg:py-10">
{variant === "catalog" && !hideTitleBlock && (
@ -664,7 +686,7 @@ export default function FacilitySearch({
aria-expanded={searchPanelOpen}
className="flex w-full items-center justify-between gap-4 rounded-[1.4rem] border border-white/12 bg-white/6 px-4 py-4 text-left transition hover:bg-white/10 md:hidden"
>
<span className="section-title text-[1.7rem] text-white">Søk golfbaner</span>
<span className="section-title text-[1.7rem] text-white">{filterHeading}</span>
<span
className={`inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-white/15 bg-white/10 text-white transition-transform duration-300 ${
searchPanelOpen ? "rotate-180" : ""
@ -677,7 +699,7 @@ export default function FacilitySearch({
</span>
</button>
<div className="hidden md:block">
<h2 className="section-title text-3xl sm:text-4xl">Søk golfbaner</h2>
<h2 className="section-title text-3xl sm:text-4xl">{filterHeading}</h2>
</div>
</>
) : (
@ -923,9 +945,11 @@ export default function FacilitySearch({
<p className="text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#9A6A5E]">
{formatUpdatedDate(facility.footnote_updated_at || facility.status_updated_at)}
</p>
<p className="mt-2 text-[15px] italic leading-7 text-[#7B3D2C]" style={noteClampStyle}>
{facility.footnote}
</p>
<div
className="mt-2 text-[15px] italic leading-7 text-[#7B3D2C] [&_a]:font-bold [&_a]:text-[#C94F2D] [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-[#9F3117] [&_em]:italic [&_strong]:font-extrabold"
style={noteClampStyle}
dangerouslySetInnerHTML={{ __html: sanitizeRichText(facility.footnote) }}
/>
</div>
)}

View file

@ -197,6 +197,57 @@ type InlineEditProps = {
inputRows?: number;
editorWidthClassName?: string;
displayClassName?: string;
renderHtml?: boolean;
};
const escapeHtml = (value: string) =>
value
.replace(/&/g, "&amp;")
.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 sanitizeInlineRichText = (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*a\b([^>]*)>/gi, (_, attrs: string) => {
const hrefMatch = attrs.match(/href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
const href = sanitizeHref(hrefMatch?.[1] || hrefMatch?.[2] || hrefMatch?.[3] || "#");
if (/^(\/|#|mailto:|tel:)/i.test(href) || /^https?:\/\/([^/]+\.)?teeoff\.no(\/|$)/i.test(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;
};
const InlineEdit = ({
@ -210,6 +261,7 @@ const InlineEdit = ({
inputRows = 2,
editorWidthClassName = 'max-w-[200px]',
displayClassName = 'text-[10px] text-blue-600 break-all max-w-[150px] leading-tight line-clamp-2',
renderHtml = false,
}: InlineEditProps) => {
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(initialValue || '');
@ -240,7 +292,16 @@ const InlineEdit = ({
return (
<div className="group flex items-start gap-2 cursor-pointer p-1.5 -ml-1.5 rounded-lg hover:bg-white border border-transparent hover:border-gray-200 hover:shadow-sm transition-all" onClick={() => setIsEditing(true)} title={title}>
<div className={displayClassName}>
{initialValue ? initialValue : <span className="text-red-400 italic">{emptyLabel}</span>}
{initialValue ? (
renderHtml ? (
<span
className="[&_a]:font-bold [&_a]:text-[#C94F2D] [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-[#9F3117] [&_em]:italic [&_strong]:font-extrabold"
dangerouslySetInnerHTML={{ __html: sanitizeInlineRichText(initialValue) }}
/>
) : (
initialValue
)
) : <span className="text-red-400 italic">{emptyLabel}</span>}
</div>
<span className="opacity-0 group-hover:opacity-100 text-[10px] bg-gray-100 p-1 rounded transition-opacity"></span>
</div>
@ -1878,6 +1939,7 @@ export default function AdminDashboard() {
inputRows={4}
editorWidthClassName="max-w-full"
displayClassName="text-sm italic leading-6 text-[#11280f] whitespace-pre-wrap"
renderHtml
/>
</div>
</section>
@ -2126,6 +2188,7 @@ export default function AdminDashboard() {
inputRows={4}
editorWidthClassName="max-w-[320px]"
displayClassName="mb-2 text-[11px] italic leading-5 text-[#11280f] whitespace-pre-wrap"
renderHtml
/>
{f.course_statuses && f.course_statuses.map((cs: any, idx: number) => {
let badgeColor = "bg-gray-100 text-gray-500";

View file

@ -317,9 +317,10 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
<p className="mb-3 text-[11px] font-black uppercase tracking-[0.24em] text-[#C98B76]">
{formatDate(facility.footnote_updated_at || facility.status_updated_at)}
</p>
<div className="italic text-[#ff5722] text-lg font-serif">
{facility.footnote}
</div>
<div
className="italic text-[#ff5722] text-lg font-serif leading-8 [&_a]:font-bold [&_a]:text-[#d53300] [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-[#a82800] [&_em]:italic [&_strong]:font-bold"
dangerouslySetInnerHTML={{ __html: sanitizeRichText(facility.footnote) }}
/>
</div>
)}
<div

View file

@ -0,0 +1,60 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import FacilitySearch from "@/app/FacilitySearch";
import PlaceMap from "@/components/PlaceMap";
import {
type EnrichedFacility,
type FacilityRecord,
enrichFacilities,
filterFacilitiesByArea,
} from "@/app/facilityData";
type PlaceExplorerProps = {
facilities: FacilityRecord[];
placeLabel: string;
placeAreaFilter: string;
placeTitle: string;
};
const PREPOSITION_PA_LABELS = new Set(["Vestlandet", "Sørlandet", "Østlandet"]);
export default function PlaceExplorer({
facilities,
placeLabel,
placeAreaFilter,
placeTitle,
}: PlaceExplorerProps) {
const facilitiesInPlace = useMemo(
() => filterFacilitiesByArea(enrichFacilities(facilities), placeAreaFilter),
[facilities, placeAreaFilter]
);
const [filteredFacilities, setFilteredFacilities] = useState<EnrichedFacility[]>(facilitiesInPlace);
useEffect(() => {
setFilteredFacilities(facilitiesInPlace);
}, [facilitiesInPlace]);
const preposition = PREPOSITION_PA_LABELS.has(placeLabel) ? "på" : "i";
const filterHeading = `Filtrer golfbaner ${preposition} ${placeLabel}`;
const filterIntro = `Filtrer golfbanene ${preposition} ${placeLabel} videre etter banestatus, antall hull og andre egenskaper.`;
return (
<>
<div className="mt-8">
<PlaceMap facilities={filteredFacilities} placeLabel={placeLabel} />
</div>
<FacilitySearch
initialFacilities={facilities}
variant="home"
title={placeTitle}
intro={filterIntro}
fixedAreaFilter={placeAreaFilter}
hideTitleBlock
onFilteredFacilitiesChange={setFilteredFacilities}
filterHeading={filterHeading}
/>
</>
);
}

View file

@ -1,7 +1,6 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import FacilitySearch from "@/app/FacilitySearch";
import PlaceMap from "@/components/PlaceMap";
import PlaceExplorer from "@/app/sted/[slug]/PlaceExplorer";
import {
type FacilityRecord,
enrichFacilities,
@ -127,19 +126,13 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
</div>
</div>
<div className="mt-8">
<PlaceMap facilities={facilitiesInPlace} placeLabel={place.label} />
</div>
<PlaceExplorer
facilities={safeData}
placeLabel={place.label}
placeAreaFilter={place.areaFilter}
placeTitle={place.title}
/>
</section>
<FacilitySearch
initialFacilities={safeData}
variant="catalog"
title={place.title}
intro={`Filtrer golfbanene i ${place.label} videre etter banestatus, antall hull og andre egenskaper.`}
fixedAreaFilter={place.areaFilter}
hideTitleBlock
/>
</main>
</>
);