Før endringer i artikkelpubliseringssystemet
This commit is contained in:
parent
cf0f049ea6
commit
d000c87324
7 changed files with 167 additions and 29 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
BIN
2026-04-20 09.33.55 teeoff.no cc7384f6fb3e.jpg
Normal file
BIN
2026-04-20 09.33.55 teeoff.no cc7384f6fb3e.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
|
|
@ -4,7 +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";
|
import { getPublicCourseDisplayName, type EnrichedFacility } from "@/app/facilityData";
|
||||||
|
|
||||||
type SortMethod = "updated" | "dist" | "alpha";
|
type SortMethod = "updated" | "dist" | "alpha";
|
||||||
type Variant = "home" | "catalog";
|
type Variant = "home" | "catalog";
|
||||||
|
|
@ -247,6 +247,38 @@ const formatUpdatedDate = (value: string | null | undefined) => {
|
||||||
|
|
||||||
const getStatusLabel = (status: string) => STATUS_MAP[status] || "Ukjent";
|
const getStatusLabel = (status: string) => STATUS_MAP[status] || "Ukjent";
|
||||||
|
|
||||||
|
type StatusBadge = {
|
||||||
|
label: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFacilityStatusBadges = (statuses: CourseStatus[]): StatusBadge[] => {
|
||||||
|
const normalizedStatuses = (Array.isArray(statuses) ? statuses : [])
|
||||||
|
.map((status) => ({
|
||||||
|
name: String(status?.name || "").trim(),
|
||||||
|
status: normalizeStatus(status?.status) || "ukjent",
|
||||||
|
}))
|
||||||
|
.filter((status) => status.status);
|
||||||
|
|
||||||
|
if (normalizedStatuses.length === 0) {
|
||||||
|
return [{ label: getStatusLabel("ukjent"), status: "ukjent" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueStatuses = [...new Set(normalizedStatuses.map((status) => status.status))];
|
||||||
|
if (normalizedStatuses.length === 1 || uniqueStatuses.length === 1) {
|
||||||
|
const status = uniqueStatuses[0] || "ukjent";
|
||||||
|
return [{ label: getStatusLabel(status), status }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedStatuses.map((course, index) => {
|
||||||
|
const courseName = getPublicCourseDisplayName(course.name, index, normalizedStatuses.length);
|
||||||
|
return {
|
||||||
|
label: courseName ? `${courseName}: ${getStatusLabel(course.status)}` : getStatusLabel(course.status),
|
||||||
|
status: course.status,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const buildMapUrl = (lat?: number | null, lng?: number | null) => {
|
const buildMapUrl = (lat?: number | null, lng?: number | null) => {
|
||||||
if (typeof lat !== "number" || typeof lng !== "number") return null;
|
if (typeof lat !== "number" || typeof lng !== "number") return null;
|
||||||
return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`;
|
return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`;
|
||||||
|
|
@ -505,7 +537,7 @@ export default function FacilitySearch({
|
||||||
const statuses =
|
const statuses =
|
||||||
Array.isArray(rawStatuses) && rawStatuses.length > 0
|
Array.isArray(rawStatuses) && rawStatuses.length > 0
|
||||||
? rawStatuses
|
? rawStatuses
|
||||||
: [{ status: "ukjent", name: "Hovedbane" }];
|
: [{ status: "ukjent", name: "" }];
|
||||||
|
|
||||||
const countySlug = slugify(facility.county || "");
|
const countySlug = slugify(facility.county || "");
|
||||||
const regions = getFacilityRegions(facility.county || "");
|
const regions = getFacilityRegions(facility.county || "");
|
||||||
|
|
@ -870,11 +902,14 @@ export default function FacilitySearch({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 grid grid-cols-1 gap-5 md:grid-cols-2 2xl:grid-cols-3">
|
<div className="mt-6 grid grid-cols-1 gap-5 md:grid-cols-2 2xl:grid-cols-3">
|
||||||
{processedFacilities.map((facility) => (
|
{processedFacilities.map((facility) => {
|
||||||
<article
|
const statusBadges = buildFacilityStatusBadges(facility.statuses);
|
||||||
key={facility.id}
|
|
||||||
className="surface-card group flex h-full flex-col overflow-hidden rounded-[2rem] transition hover:-translate-y-1 hover:shadow-xl"
|
return (
|
||||||
>
|
<article
|
||||||
|
key={facility.id}
|
||||||
|
className="surface-card group flex h-full flex-col overflow-hidden rounded-[2rem] transition hover:-translate-y-1 hover:shadow-xl"
|
||||||
|
>
|
||||||
<Link href={`/golfbaner/${facility.slug}`} className="block shrink-0">
|
<Link href={`/golfbaner/${facility.slug}`} className="block shrink-0">
|
||||||
<div className="relative h-56 overflow-hidden bg-[#D9DED5] sm:h-60">
|
<div className="relative h-56 overflow-hidden bg-[#D9DED5] sm:h-60">
|
||||||
<Image
|
<Image
|
||||||
|
|
@ -888,13 +923,16 @@ export default function FacilitySearch({
|
||||||
|
|
||||||
<div className="absolute left-4 top-4 flex max-w-[calc(100%-7rem)] flex-col items-start gap-2">
|
<div className="absolute left-4 top-4 flex max-w-[calc(100%-7rem)] flex-col items-start gap-2">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<span
|
{statusBadges.map((badge) => (
|
||||||
className={`rounded-full px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] ${
|
<span
|
||||||
STATUS_CLASSES[facility.primaryStatus] || STATUS_CLASSES.ukjent
|
key={badge.label}
|
||||||
}`}
|
className={`rounded-full px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] ${
|
||||||
>
|
STATUS_CLASSES[badge.status] || STATUS_CLASSES.ukjent
|
||||||
{getStatusLabel(facility.primaryStatus)}
|
}`}
|
||||||
</span>
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
{facility.hasGolfamore && (
|
{facility.hasGolfamore && (
|
||||||
<span className="rounded-full bg-[#FF5722] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-white">
|
<span className="rounded-full bg-[#FF5722] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-white">
|
||||||
Golfamore
|
Golfamore
|
||||||
|
|
@ -1009,8 +1047,9 @@ export default function FacilitySearch({
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,35 @@ export const normalizeStatus = (value: unknown) =>
|
||||||
.replace(/[^a-z0-9_]+/g, "")
|
.replace(/[^a-z0-9_]+/g, "")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
|
const GENERIC_SINGLE_COURSE_NAMES = new Set(["hovedbane", "hovedbanen"]);
|
||||||
|
|
||||||
|
export const getPublicCourseDisplayName = (name: unknown, index: number, total: number) => {
|
||||||
|
const trimmedName = String(name ?? "").trim();
|
||||||
|
const normalizedName = normalizeText(trimmedName).replace(/\s+/g, "");
|
||||||
|
|
||||||
|
if (total <= 1 && GENERIC_SINGLE_COURSE_NAMES.has(normalizedName)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedName) {
|
||||||
|
return trimmedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total <= 1) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
return "Hovedbane";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total === 2) {
|
||||||
|
return "Sekundærbane";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Sekundærbane ${index}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const slugify = (value: unknown) =>
|
export const slugify = (value: unknown) =>
|
||||||
normalizeText(value)
|
normalizeText(value)
|
||||||
.replace(/\s+/g, "-")
|
.replace(/\s+/g, "-")
|
||||||
|
|
@ -237,7 +266,7 @@ export const enrichFacilities = (
|
||||||
const statuses =
|
const statuses =
|
||||||
Array.isArray(rawStatuses) && rawStatuses.length > 0
|
Array.isArray(rawStatuses) && rawStatuses.length > 0
|
||||||
? rawStatuses
|
? rawStatuses
|
||||||
: [{ status: "ukjent", name: "Hovedbane" }];
|
: [{ status: "ukjent", name: "" }];
|
||||||
const holeValue = String(amenities.antall_hull || "").trim();
|
const holeValue = String(amenities.antall_hull || "").trim();
|
||||||
const countySlug = slugify(facility.county || "");
|
const countySlug = slugify(facility.county || "");
|
||||||
const regions = getFacilityRegions(facility.county || "");
|
const regions = getFacilityRegions(facility.county || "");
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ const getTeeTheme = (label: string) => {
|
||||||
return { header: "bg-gray-200 text-gray-700", col: "bg-gray-100/60", text: "text-gray-600" };
|
return { header: "bg-gray-200 text-gray-700", col: "bg-gray-100/60", text: "text-gray-600" };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CourseDisplay({ course }: { course: any }) {
|
export default function CourseDisplay({ course, courseDisplayName = "" }: { course: any; courseDisplayName?: string }) {
|
||||||
const [hcp, setHcp] = useState("15.0");
|
const [hcp, setHcp] = useState("15.0");
|
||||||
const [gender, setGender] = useState<'herrer' | 'damer'>('herrer');
|
const [gender, setGender] = useState<'herrer' | 'damer'>('herrer');
|
||||||
const [selectedTeeIndex, setSelectedTeeIndex] = useState(0);
|
const [selectedTeeIndex, setSelectedTeeIndex] = useState(0);
|
||||||
|
|
@ -134,7 +134,9 @@ export default function CourseDisplay({ course }: { course: any }) {
|
||||||
{/* HEADER / KALKULATOR */}
|
{/* HEADER / KALKULATOR */}
|
||||||
<div className="flex min-w-0 flex-col items-stretch justify-between gap-5 border-b border-gray-100 bg-white p-4 sm:p-5 md:flex-row md:items-center md:gap-8 md:p-12">
|
<div className="flex min-w-0 flex-col items-stretch justify-between gap-5 border-b border-gray-100 bg-white p-4 sm:p-5 md:flex-row md:items-center md:gap-8 md:p-12">
|
||||||
<div className="min-w-0 text-left">
|
<div className="min-w-0 text-left">
|
||||||
<h2 className="break-words text-4xl font-black tracking-tighter text-[#11280f] sm:text-5xl">{course.name}</h2>
|
{courseDisplayName ? (
|
||||||
|
<h2 className="break-words text-4xl font-black tracking-tighter text-[#11280f] sm:text-5xl">{courseDisplayName}</h2>
|
||||||
|
) : null}
|
||||||
<p className="text-[#7ca982] font-black uppercase text-xs tracking-[0.2em] mt-2 mb-1">
|
<p className="text-[#7ca982] font-black uppercase text-xs tracking-[0.2em] mt-2 mb-1">
|
||||||
Par {course.par} • {course.length_meters || '--'} meter
|
Par {course.par} • {course.length_meters || '--'} meter
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants";
|
import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants";
|
||||||
import { STATUS_ICON_PATHS, buildMapUrl, getPrimaryStatus, parseJson as parseSharedJson, slugify } from "@/app/facilityData";
|
import { STATUS_ICON_PATHS, buildMapUrl, getPrimaryStatus, getPublicCourseDisplayName, parseJson as parseSharedJson, slugify } from "@/app/facilityData";
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import CourseDisplay from './CourseDisplay';
|
import CourseDisplay from './CourseDisplay';
|
||||||
import FacilityFeedbackForm from './FacilityFeedbackForm';
|
import FacilityFeedbackForm from './FacilityFeedbackForm';
|
||||||
|
|
@ -277,11 +277,16 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
{/* BANESTATUS BADGES */}
|
{/* BANESTATUS BADGES */}
|
||||||
<div className="absolute top-8 right-8 z-40 flex flex-col items-end gap-2">
|
<div className="absolute top-8 right-8 z-40 flex flex-col items-end gap-2">
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
{activeCourses.map((c: any) => (
|
{activeCourses.map((c: any, index: number) => {
|
||||||
<span key={c.id} className="px-3 py-1.5 rounded-lg text-[10px] font-black uppercase bg-[#7ca982] text-white shadow-xl">
|
const courseName = getPublicCourseDisplayName(c.name, index, activeCourses.length);
|
||||||
{c.name.toUpperCase()}: {STATUS_MAP[c.status] || c.status}
|
const label = STATUS_MAP[c.status] || c.status;
|
||||||
</span>
|
|
||||||
))}
|
return (
|
||||||
|
<span key={c.id} className="px-3 py-1.5 rounded-lg text-[10px] font-black uppercase bg-[#7ca982] text-white shadow-xl">
|
||||||
|
{courseName ? `${courseName.toUpperCase()}: ${label}` : label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{facility.status_updated_at && (
|
{facility.status_updated_at && (
|
||||||
<span className="text-white/60 text-[10px] uppercase font-black tracking-widest bg-black/20 px-2 py-1 rounded">
|
<span className="text-white/60 text-[10px] uppercase font-black tracking-widest bg-black/20 px-2 py-1 rounded">
|
||||||
|
|
@ -769,9 +774,9 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
<section id="scorecards" className="pt-10 space-y-20 overflow-hidden">
|
<section id="scorecards" className="pt-10 space-y-20 overflow-hidden">
|
||||||
<h3 className="text-center text-3xl md:text-5xl font-black uppercase tracking-tighter">Scorekort</h3>
|
<h3 className="text-center text-3xl md:text-5xl font-black uppercase tracking-tighter">Scorekort</h3>
|
||||||
<div className="w-full flex flex-col items-center gap-20">
|
<div className="w-full flex flex-col items-center gap-20">
|
||||||
{activeCourses.map((c: any) => (
|
{activeCourses.map((c: any, index: number) => (
|
||||||
<div key={c.id} className="w-full overflow-x-auto md:overflow-visible no-scrollbar">
|
<div key={c.id} className="w-full overflow-x-auto md:overflow-visible no-scrollbar">
|
||||||
<div className="min-w-[800px] md:min-w-0"><CourseDisplay course={c} /></div>
|
<div className="min-w-[800px] md:min-w-0"><CourseDisplay course={c} courseDisplayName={getPublicCourseDisplayName(c.name, index, activeCourses.length)} /></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
|
||||||
import {
|
import {
|
||||||
buildMapUrl,
|
buildMapUrl,
|
||||||
formatUpdatedDate,
|
formatUpdatedDate,
|
||||||
|
getPublicCourseDisplayName,
|
||||||
getStatusLabel,
|
getStatusLabel,
|
||||||
parseJson,
|
parseJson,
|
||||||
STATUS_ICON_PATHS,
|
STATUS_ICON_PATHS,
|
||||||
|
|
@ -17,6 +18,11 @@ type PlaceMapLeafletProps = {
|
||||||
facilities: EnrichedFacility[];
|
facilities: EnrichedFacility[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PopupStatusBadge = {
|
||||||
|
label: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
const markerIconCache: Record<string, Icon> = {};
|
const markerIconCache: Record<string, Icon> = {};
|
||||||
|
|
||||||
const getMarkerIcon = (status: string) => {
|
const getMarkerIcon = (status: string) => {
|
||||||
|
|
@ -32,6 +38,53 @@ const getMarkerIcon = (status: string) => {
|
||||||
return markerIconCache[key];
|
return markerIconCache[key];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||||
|
aapen: "bg-[#8BC34A] text-white",
|
||||||
|
aapen_med_vintergreener: "bg-[#D2A63A] text-[#112015]",
|
||||||
|
stenger_snart: "bg-[#FF5722] text-white",
|
||||||
|
aapner_snart: "bg-sky-600 text-white",
|
||||||
|
stengt: "bg-[#B6473D] text-white",
|
||||||
|
under_utvikling: "bg-slate-600 text-white",
|
||||||
|
nedlagt: "bg-[#112015] text-white",
|
||||||
|
ukjent: "bg-[#D9DED5] text-[#112015]",
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeStatus = (value: unknown) =>
|
||||||
|
String(value ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/\s+/g, "_")
|
||||||
|
.replace(/[^a-z0-9_]+/g, "")
|
||||||
|
.trim() || "ukjent";
|
||||||
|
|
||||||
|
const buildPopupStatusBadges = (statuses: Array<{ name?: string; status?: string }>): PopupStatusBadge[] => {
|
||||||
|
const normalizedStatuses = (Array.isArray(statuses) ? statuses : [])
|
||||||
|
.map((status) => ({
|
||||||
|
name: String(status?.name || "").trim(),
|
||||||
|
status: normalizeStatus(status?.status),
|
||||||
|
}))
|
||||||
|
.filter((status) => status.status);
|
||||||
|
|
||||||
|
if (normalizedStatuses.length === 0) {
|
||||||
|
return [{ label: getStatusLabel("ukjent"), status: "ukjent" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueStatuses = [...new Set(normalizedStatuses.map((status) => status.status))];
|
||||||
|
if (normalizedStatuses.length === 1 || uniqueStatuses.length === 1) {
|
||||||
|
const status = uniqueStatuses[0] || "ukjent";
|
||||||
|
return [{ label: getStatusLabel(status), status }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedStatuses.map((course, index) => {
|
||||||
|
const courseName = getPublicCourseDisplayName(course.name, index, normalizedStatuses.length);
|
||||||
|
return {
|
||||||
|
label: courseName ? `${courseName}: ${getStatusLabel(course.status)}` : getStatusLabel(course.status),
|
||||||
|
status: course.status,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
function ShiftScrollZoomGuard() {
|
function ShiftScrollZoomGuard() {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
|
|
||||||
|
|
@ -180,6 +233,7 @@ export default function PlaceMapLeaflet({ facilities }: PlaceMapLeafletProps) {
|
||||||
const socialLinks = parseJson<Array<{ platform?: string; url?: string }>>(facility.social_links, []);
|
const socialLinks = parseJson<Array<{ platform?: string; url?: string }>>(facility.social_links, []);
|
||||||
const facebook = socialLinks.find((entry) => entry.platform?.toLowerCase() === "facebook")?.url;
|
const facebook = socialLinks.find((entry) => entry.platform?.toLowerCase() === "facebook")?.url;
|
||||||
const instagram = socialLinks.find((entry) => entry.platform?.toLowerCase() === "instagram")?.url;
|
const instagram = socialLinks.find((entry) => entry.platform?.toLowerCase() === "instagram")?.url;
|
||||||
|
const statusBadges = buildPopupStatusBadges(facility.statuses);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Marker
|
<Marker
|
||||||
|
|
@ -198,8 +252,17 @@ export default function PlaceMapLeaflet({ facilities }: PlaceMapLeafletProps) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="inline-flex rounded-full bg-[#F3F6EE] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#112015]">
|
<div className="flex flex-wrap gap-2">
|
||||||
{getStatusLabel(facility.primaryStatus)}
|
{statusBadges.map((badge) => (
|
||||||
|
<div
|
||||||
|
key={badge.label}
|
||||||
|
className={`rounded-full px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] ${
|
||||||
|
STATUS_BADGE_CLASSES[badge.status] || STATUS_BADGE_CLASSES.ukjent
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{facility.status_updated_at && (
|
{facility.status_updated_at && (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue