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 Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||||
|
import { type EnrichedFacility } from "@/app/facilityData";
|
||||||
|
|
||||||
type SortMethod = "updated" | "dist" | "alpha";
|
type SortMethod = "updated" | "dist" | "alpha";
|
||||||
type Variant = "home" | "catalog";
|
type Variant = "home" | "catalog";
|
||||||
|
|
@ -65,6 +66,8 @@ type FacilitySearchProps = {
|
||||||
intro?: string;
|
intro?: string;
|
||||||
fixedAreaFilter?: string;
|
fixedAreaFilter?: string;
|
||||||
hideTitleBlock?: boolean;
|
hideTitleBlock?: boolean;
|
||||||
|
onFilteredFacilitiesChange?: (facilities: EnrichedFacility[]) => void;
|
||||||
|
filterHeading?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SpecialFlags = {
|
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.",
|
intro = "Bruk område, banestatus og fasiliteter for å snevre inn oversikten. Her får katalogen være arbeidsflate, ikke hero.",
|
||||||
fixedAreaFilter = "",
|
fixedAreaFilter = "",
|
||||||
hideTitleBlock = false,
|
hideTitleBlock = false,
|
||||||
|
onFilteredFacilitiesChange,
|
||||||
|
filterHeading = "Søk golfbaner",
|
||||||
}: FacilitySearchProps) {
|
}: FacilitySearchProps) {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [areaFilter, setAreaFilter] = useState(fixedAreaFilter);
|
const [areaFilter, setAreaFilter] = useState(fixedAreaFilter);
|
||||||
|
|
@ -509,6 +514,13 @@ export default function FacilitySearch({
|
||||||
const hasGolfamore = facility.golfamore === true || Object.keys(golfamoreData).length > 0;
|
const hasGolfamore = facility.golfamore === true || Object.keys(golfamoreData).length > 0;
|
||||||
const hasNSG = Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0;
|
const hasNSG = Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0;
|
||||||
const hasSimulator = hasTruthyAmenity(amenities.simulator);
|
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 architectKey = normalizeText(facility.architect || "");
|
||||||
|
|
||||||
const updatedTsRaw = facility.status_updated_at ? new Date(facility.status_updated_at).getTime() : 0;
|
const updatedTsRaw = facility.status_updated_at ? new Date(facility.status_updated_at).getTime() : 0;
|
||||||
|
|
@ -570,9 +582,15 @@ export default function FacilitySearch({
|
||||||
return {
|
return {
|
||||||
...facility,
|
...facility,
|
||||||
holeValue,
|
holeValue,
|
||||||
|
countySlug,
|
||||||
|
regions,
|
||||||
|
statuses,
|
||||||
primaryStatus,
|
primaryStatus,
|
||||||
hasGolfamore,
|
hasGolfamore,
|
||||||
hasNSG,
|
hasNSG,
|
||||||
|
hasSimulator,
|
||||||
|
hasDrivingRange,
|
||||||
|
hasVtg,
|
||||||
distance,
|
distance,
|
||||||
lastUpdatedTs,
|
lastUpdatedTs,
|
||||||
matchesSearch,
|
matchesSearch,
|
||||||
|
|
@ -644,6 +662,10 @@ export default function FacilitySearch({
|
||||||
}
|
}
|
||||||
}, [filtersCount, isCollapsibleHomeSearch]);
|
}, [filtersCount, isCollapsibleHomeSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onFilteredFacilitiesChange?.(processedFacilities);
|
||||||
|
}, [onFilteredFacilitiesChange, processedFacilities]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto max-w-[1400px] px-4 py-6 sm:px-6 sm:py-8 lg:px-8 lg:py-10">
|
<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 && (
|
{variant === "catalog" && !hideTitleBlock && (
|
||||||
|
|
@ -664,7 +686,7 @@ export default function FacilitySearch({
|
||||||
aria-expanded={searchPanelOpen}
|
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"
|
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
|
<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 ${
|
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" : ""
|
searchPanelOpen ? "rotate-180" : ""
|
||||||
|
|
@ -677,7 +699,7 @@ export default function FacilitySearch({
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="hidden md:block">
|
<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>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -923,9 +945,11 @@ export default function FacilitySearch({
|
||||||
<p className="text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#9A6A5E]">
|
<p className="text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#9A6A5E]">
|
||||||
{formatUpdatedDate(facility.footnote_updated_at || facility.status_updated_at)}
|
{formatUpdatedDate(facility.footnote_updated_at || facility.status_updated_at)}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-[15px] italic leading-7 text-[#7B3D2C]" style={noteClampStyle}>
|
<div
|
||||||
{facility.footnote}
|
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"
|
||||||
</p>
|
style={noteClampStyle}
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitizeRichText(facility.footnote) }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,57 @@ type InlineEditProps = {
|
||||||
inputRows?: number;
|
inputRows?: number;
|
||||||
editorWidthClassName?: string;
|
editorWidthClassName?: string;
|
||||||
displayClassName?: 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 = ({
|
const InlineEdit = ({
|
||||||
|
|
@ -210,6 +261,7 @@ const InlineEdit = ({
|
||||||
inputRows = 2,
|
inputRows = 2,
|
||||||
editorWidthClassName = 'max-w-[200px]',
|
editorWidthClassName = 'max-w-[200px]',
|
||||||
displayClassName = 'text-[10px] text-blue-600 break-all max-w-[150px] leading-tight line-clamp-2',
|
displayClassName = 'text-[10px] text-blue-600 break-all max-w-[150px] leading-tight line-clamp-2',
|
||||||
|
renderHtml = false,
|
||||||
}: InlineEditProps) => {
|
}: InlineEditProps) => {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [value, setValue] = useState(initialValue || '');
|
const [value, setValue] = useState(initialValue || '');
|
||||||
|
|
@ -240,7 +292,16 @@ const InlineEdit = ({
|
||||||
return (
|
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="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}>
|
<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>
|
</div>
|
||||||
<span className="opacity-0 group-hover:opacity-100 text-[10px] bg-gray-100 p-1 rounded transition-opacity">✏️</span>
|
<span className="opacity-0 group-hover:opacity-100 text-[10px] bg-gray-100 p-1 rounded transition-opacity">✏️</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1878,6 +1939,7 @@ export default function AdminDashboard() {
|
||||||
inputRows={4}
|
inputRows={4}
|
||||||
editorWidthClassName="max-w-full"
|
editorWidthClassName="max-w-full"
|
||||||
displayClassName="text-sm italic leading-6 text-[#11280f] whitespace-pre-wrap"
|
displayClassName="text-sm italic leading-6 text-[#11280f] whitespace-pre-wrap"
|
||||||
|
renderHtml
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -2126,6 +2188,7 @@ export default function AdminDashboard() {
|
||||||
inputRows={4}
|
inputRows={4}
|
||||||
editorWidthClassName="max-w-[320px]"
|
editorWidthClassName="max-w-[320px]"
|
||||||
displayClassName="mb-2 text-[11px] italic leading-5 text-[#11280f] whitespace-pre-wrap"
|
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) => {
|
{f.course_statuses && f.course_statuses.map((cs: any, idx: number) => {
|
||||||
let badgeColor = "bg-gray-100 text-gray-500";
|
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]">
|
<p className="mb-3 text-[11px] font-black uppercase tracking-[0.24em] text-[#C98B76]">
|
||||||
{formatDate(facility.footnote_updated_at || facility.status_updated_at)}
|
{formatDate(facility.footnote_updated_at || facility.status_updated_at)}
|
||||||
</p>
|
</p>
|
||||||
<div className="italic text-[#ff5722] text-lg font-serif">
|
<div
|
||||||
{facility.footnote}
|
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"
|
||||||
</div>
|
dangerouslySetInnerHTML={{ __html: sanitizeRichText(facility.footnote) }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<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 type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import FacilitySearch from "@/app/FacilitySearch";
|
import PlaceExplorer from "@/app/sted/[slug]/PlaceExplorer";
|
||||||
import PlaceMap from "@/components/PlaceMap";
|
|
||||||
import {
|
import {
|
||||||
type FacilityRecord,
|
type FacilityRecord,
|
||||||
enrichFacilities,
|
enrichFacilities,
|
||||||
|
|
@ -127,19 +126,13 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<PlaceExplorer
|
||||||
<PlaceMap facilities={facilitiesInPlace} placeLabel={place.label} />
|
facilities={safeData}
|
||||||
</div>
|
placeLabel={place.label}
|
||||||
|
placeAreaFilter={place.areaFilter}
|
||||||
|
placeTitle={place.title}
|
||||||
|
/>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue