Før skjema nederst på hver baneside
This commit is contained in:
parent
d53383b6fd
commit
d3a967c664
5 changed files with 164 additions and 23 deletions
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -197,6 +197,57 @@ type InlineEditProps = {
|
|||
inputRows?: number;
|
||||
editorWidthClassName?: string;
|
||||
displayClassName?: string;
|
||||
renderHtml?: boolean;
|
||||
};
|
||||
|
||||
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 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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
60
frontend/src/app/sted/[slug]/PlaceExplorer.tsx
Normal file
60
frontend/src/app/sted/[slug]/PlaceExplorer.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue