Ferdig med endringer enkeltfilter

This commit is contained in:
Erol Haagenrud 2026-04-24 09:37:01 +02:00
parent f624546ebf
commit f17075da0f
4 changed files with 128 additions and 24 deletions

View file

@ -3,6 +3,7 @@
import { STATUS_MAP } from "@/config/constants"; 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 { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { getPublicCourseDisplayName, type EnrichedFacility } from "@/app/facilityData"; import { getPublicCourseDisplayName, type EnrichedFacility } from "@/app/facilityData";
@ -503,6 +504,7 @@ export default function FacilitySearch({
onFilteredFacilitiesChange, onFilteredFacilitiesChange,
filterHeading = "Søk golfbaner", filterHeading = "Søk golfbaner",
}: FacilitySearchProps) { }: FacilitySearchProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [areaFilter, setAreaFilter] = useState(fixedAreaFilter); const [areaFilter, setAreaFilter] = useState(fixedAreaFilter);
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState("");
@ -511,7 +513,6 @@ export default function FacilitySearch({
const [weatherDayFilter, setWeatherDayFilter] = useState(""); const [weatherDayFilter, setWeatherDayFilter] = useState("");
const [weatherDayOptions, setWeatherDayOptions] = useState(() => getWeatherDayOptions()); const [weatherDayOptions, setWeatherDayOptions] = useState(() => getWeatherDayOptions());
const [architectFilter, setArchitectFilter] = useState(""); const [architectFilter, setArchitectFilter] = useState("");
const [facilityFilter, setFacilityFilter] = useState("");
const [sortMethod, setSortMethod] = useState<SortMethod>("updated"); const [sortMethod, setSortMethod] = useState<SortMethod>("updated");
const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null); const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null);
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false); const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
@ -707,8 +708,6 @@ export default function FacilitySearch({
: weatherForecast.find((entry) => Number(entry?.day_offset) === selectedWeatherDayOffset); : weatherForecast.find((entry) => Number(entry?.day_offset) === selectedWeatherDayOffset);
const matchesWeather = !weatherDayFilter || Boolean(weatherDay?.dry_daylight); const matchesWeather = !weatherDayFilter || Boolean(weatherDay?.dry_daylight);
const matchesArchitect = !architectFilter || architectKey === architectFilter; const matchesArchitect = !architectFilter || architectKey === architectFilter;
const matchesFacility = !facilityFilter || facility.slug === facilityFilter;
return { return {
...facility, ...facility,
holeValue, holeValue,
@ -730,7 +729,6 @@ export default function FacilitySearch({
matchesSpecial, matchesSpecial,
matchesWeather, matchesWeather,
matchesArchitect, matchesArchitect,
matchesFacility,
}; };
}) })
.filter( .filter(
@ -741,8 +739,7 @@ export default function FacilitySearch({
facility.matchesHoles && facility.matchesHoles &&
facility.matchesSpecial && facility.matchesSpecial &&
facility.matchesWeather && facility.matchesWeather &&
facility.matchesArchitect && facility.matchesArchitect
facility.matchesFacility
) )
.sort((a, b) => { .sort((a, b) => {
if (sortMethod === "dist") { if (sortMethod === "dist") {
@ -758,7 +755,6 @@ export default function FacilitySearch({
}, [ }, [
areaFilter, areaFilter,
architectFilter, architectFilter,
facilityFilter,
holeFilter, holeFilter,
initialFacilities, initialFacilities,
searchQuery, searchQuery,
@ -776,7 +772,6 @@ export default function FacilitySearch({
specialFilter, specialFilter,
weatherDayFilter, weatherDayFilter,
architectFilter, architectFilter,
facilityFilter,
searchQuery.trim(), searchQuery.trim(),
].filter(Boolean).length; ].filter(Boolean).length;
const summaryText = `${processedFacilities.length} baner • ${getAreaLabel(areaFilter, countyOptions)}${ const summaryText = `${processedFacilities.length} baner • ${getAreaLabel(areaFilter, countyOptions)}${
@ -786,6 +781,11 @@ export default function FacilitySearch({
const isCollapsibleHomeSearch = variant === "home"; const isCollapsibleHomeSearch = variant === "home";
const searchPanelOpen = !isCollapsibleHomeSearch || isMobileSearchOpen; const searchPanelOpen = !isCollapsibleHomeSearch || isMobileSearchOpen;
const handleFacilitySelect = (slug: string) => {
if (!slug) return;
router.push(`/golfbaner/${slug}`);
};
useEffect(() => { useEffect(() => {
if (isCollapsibleHomeSearch && filtersCount > 0) { if (isCollapsibleHomeSearch && filtersCount > 0) {
setIsMobileSearchOpen(true); setIsMobileSearchOpen(true);
@ -916,11 +916,11 @@ export default function FacilitySearch({
<FieldSelect <FieldSelect
label="Golfanlegg" label="Golfanlegg"
value={facilityFilter} value=""
onChange={setFacilityFilter} onChange={handleFacilitySelect}
labelClassName={labelClassName} labelClassName={labelClassName}
> >
<option value="">Alle golfanlegg</option> <option value="">direkte til golfanlegg</option>
{facilityOptions.map((option) => ( {facilityOptions.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
{option.label} {option.label}
@ -957,7 +957,6 @@ export default function FacilitySearch({
setSpecialFilter(""); setSpecialFilter("");
setWeatherDayFilter(""); setWeatherDayFilter("");
setArchitectFilter(""); setArchitectFilter("");
setFacilityFilter("");
setSortMethod(userLocation ? "dist" : "updated"); setSortMethod(userLocation ? "dist" : "updated");
}} }}
className={`btn btn-md mt-[1.72rem] h-[52px] ${ className={`btn btn-md mt-[1.72rem] h-[52px] ${

View file

@ -312,6 +312,8 @@ export default function AdminDashboard() {
const [facilities, setFacilities] = useState<any[]>([]); const [facilities, setFacilities] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedFacilities, setSelectedFacilities] = useState<number[]>([]); const [selectedFacilities, setSelectedFacilities] = useState<number[]>([]);
const [facilityJumpSlug, setFacilityJumpSlug] = useState('');
const [showBackToTop, setShowBackToTop] = useState(false);
const [scrapeJobs, setScrapeJobs] = useState<ScrapeJob[]>([]); const [scrapeJobs, setScrapeJobs] = useState<ScrapeJob[]>([]);
const [isQueueing, setIsQueueing] = useState(false); const [isQueueing, setIsQueueing] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
@ -334,6 +336,7 @@ export default function AdminDashboard() {
const [manualEditForm, setManualEditForm] = useState<ManualOverrideForm>(EMPTY_MANUAL_OVERRIDE_FORM); const [manualEditForm, setManualEditForm] = useState<ManualOverrideForm>(EMPTY_MANUAL_OVERRIDE_FORM);
const [isManualSaving, setIsManualSaving] = useState(false); const [isManualSaving, setIsManualSaving] = useState(false);
const [dismissedLatestJobKeys, setDismissedLatestJobKeys] = useState<Partial<Record<AdminTab, string>>>({}); const [dismissedLatestJobKeys, setDismissedLatestJobKeys] = useState<Partial<Record<AdminTab, string>>>({});
const mainContentRef = useRef<HTMLElement | null>(null);
const fetchFacilities = () => { const fetchFacilities = () => {
adminFetch(`${API_URL}/admin/facilities`) adminFetch(`${API_URL}/admin/facilities`)
@ -434,6 +437,19 @@ export default function AdminDashboard() {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [showMobileAdminMenu]); }, [showMobileAdminMenu]);
useEffect(() => {
const container = mainContentRef.current;
if (!container) return;
const handleScroll = () => {
setShowBackToTop(container.scrollTop > 500);
};
handleScroll();
container.addEventListener('scroll', handleScroll, { passive: true });
return () => container.removeEventListener('scroll', handleScroll);
}, []);
const filteredFacilities = useMemo(() => { const filteredFacilities = useMemo(() => {
if (statusFilter === 'alle') return facilities; if (statusFilter === 'alle') return facilities;
return facilities.map(facility => { return facilities.map(facility => {
@ -451,6 +467,36 @@ export default function AdminDashboard() {
}).filter(facility => facility.course_statuses && facility.course_statuses.length > 0); }).filter(facility => facility.course_statuses && facility.course_statuses.length > 0);
}, [facilities, statusFilter]); }, [facilities, statusFilter]);
const facilityJumpOptions = useMemo(
() =>
[...facilities]
.filter((facility) => facility?.slug && facility?.name)
.sort((a, b) => String(a.name).localeCompare(String(b.name), 'nb-NO'))
.map((facility) => ({
slug: String(facility.slug),
label: String(facility.name),
city: String(facility.city || '').trim(),
})),
[facilities]
);
useEffect(() => {
if (!facilityJumpSlug) return;
if (activeTab === 'banestatus' && statusFilter !== 'alle') {
setStatusFilter('alle');
return;
}
const targetId = `facility-card-${facilityJumpSlug}`;
const frameId = window.requestAnimationFrame(() => {
const target = document.getElementById(targetId);
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
return () => window.cancelAnimationFrame(frameId);
}, [activeTab, facilityJumpSlug, filteredFacilities, statusFilter]);
const latestJobSummary = useMemo(() => { const latestJobSummary = useMemo(() => {
if (!latestJob?.result_summary) return ''; if (!latestJob?.result_summary) return '';
@ -1397,7 +1443,7 @@ export default function AdminDashboard() {
</aside> </aside>
{/* HOVEDINNHOLD */} {/* HOVEDINNHOLD */}
<main className="flex-1 min-w-0 p-4 md:p-8 lg:p-10 h-screen overflow-auto"> <main ref={mainContentRef} className="flex-1 min-w-0 p-4 md:p-8 lg:p-10 h-screen overflow-auto">
<div className="bg-white rounded-[2rem] shadow-2xl p-6 lg:p-10 border border-white"> <div className="bg-white rounded-[2rem] shadow-2xl p-6 lg:p-10 border border-white">
<div className="mb-6 flex md:hidden"> <div className="mb-6 flex md:hidden">
<button <button
@ -1746,15 +1792,35 @@ export default function AdminDashboard() {
Hvert anlegg vises som et eget arbeidskort, slik at du ser innhold, status og handlinger samlet uten sideveis scrolling. Hvert anlegg vises som et eget arbeidskort, slik at du ser innhold, status og handlinger samlet uten sideveis scrolling.
</p> </p>
</div> </div>
<label className="inline-flex items-center gap-3 rounded-2xl bg-white px-4 py-3 text-xs font-black uppercase tracking-widest text-gray-500 shadow-sm"> <div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<input <label className="block min-w-[18rem]">
type="checkbox" <span className="mb-2 block text-[10px] font-black uppercase tracking-[0.2em] text-gray-500">
className="h-5 w-5 cursor-pointer accent-[#8bc34a]" Hopp til golfanlegg
checked={selectedFacilities.length === filteredFacilities.length && filteredFacilities.length > 0} </span>
onChange={handleSelectAll} <select
/> value={facilityJumpSlug}
Velg alle i visningen onChange={(e) => setFacilityJumpSlug(e.target.value)}
</label> className="w-full rounded-2xl border-2 border-gray-200 bg-white px-4 py-3 text-sm font-bold text-[#11280f] outline-none transition-colors focus:border-[#8bc34a]"
>
<option value="">Velg anlegg</option>
{facilityJumpOptions.map((facility) => (
<option key={facility.slug} value={facility.slug}>
{facility.city ? `${facility.label}${facility.city}` : facility.label}
</option>
))}
</select>
</label>
<label className="inline-flex items-center gap-3 rounded-2xl bg-white px-4 py-3 text-xs font-black uppercase tracking-widest text-gray-500 shadow-sm">
<input
type="checkbox"
className="h-5 w-5 cursor-pointer accent-[#8bc34a]"
checked={selectedFacilities.length === filteredFacilities.length && filteredFacilities.length > 0}
onChange={handleSelectAll}
/>
Velg alle i visningen
</label>
</div>
</div> </div>
<div className="mb-6 rounded-[1.75rem] border border-[#dbe7cf] bg-white p-4 shadow-sm"> <div className="mb-6 rounded-[1.75rem] border border-[#dbe7cf] bg-white p-4 shadow-sm">
@ -1810,7 +1876,14 @@ export default function AdminDashboard() {
return ( return (
<article <article
key={f.id} key={f.id}
className={`rounded-[1.9rem] border p-5 shadow-sm transition-all md:p-6 ${accentStyle} ${selectedFacilities.includes(f.id) ? 'ring-2 ring-[#8bc34a]/35 shadow-lg' : ''}`} id={`facility-card-${f.slug}`}
className={`rounded-[1.9rem] border p-5 shadow-sm transition-all scroll-mt-6 md:p-6 ${accentStyle} ${
selectedFacilities.includes(f.id)
? 'ring-2 ring-[#8bc34a]/35 shadow-lg'
: facilityJumpSlug === f.slug
? 'ring-2 ring-[#11280f]/15 shadow-lg'
: ''
}`}
> >
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
@ -2323,6 +2396,19 @@ export default function AdminDashboard() {
</div> </div>
</div> </div>
</main> </main>
{showBackToTop && (
<button
type="button"
onClick={() => mainContentRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
className="btn btn-ink fixed bottom-4 right-4 z-[95] flex h-12 items-center justify-center gap-2 rounded-full px-4 shadow-2xl md:bottom-8 md:right-8 md:h-14"
aria-label="Tilbake til toppen"
title="Tilbake til toppen"
>
<span className="text-base leading-none"></span>
<span className="hidden text-[10px] font-black uppercase tracking-[0.18em] sm:inline">Toppen</span>
</button>
)}
</div> </div>
); );
} }

View file

@ -179,12 +179,14 @@ const SOCIAL_ICONS: Record<string, React.ReactNode> = {
export default function FacilityDetailView({ export default function FacilityDetailView({
facility, facility,
relatedArticles = { banebesok: [], meninger: [] }, relatedArticles = { banebesok: [], meninger: [] },
canOpenAdminShortcut = false,
}: { }: {
facility: any; facility: any;
relatedArticles?: { relatedArticles?: {
banebesok: any[]; banebesok: any[];
meninger: any[]; meninger: any[];
}; };
canOpenAdminShortcut?: boolean;
}) { }) {
const [showBackToTop, setShowBackToTop] = useState(false); const [showBackToTop, setShowBackToTop] = useState(false);
const [currentSlide, setCurrentSlide] = useState(0); const [currentSlide, setCurrentSlide] = useState(0);
@ -340,6 +342,16 @@ export default function FacilityDetailView({
)} )}
<h1 className="text-5xl md:text-8xl font-black text-white mb-3 tracking-tighter drop-shadow-2xl">{facility.name}</h1> <h1 className="text-5xl md:text-8xl font-black text-white mb-3 tracking-tighter drop-shadow-2xl">{facility.name}</h1>
<p className="text-[#7ca982] uppercase tracking-[0.4em] font-black text-xs md:text-sm pl-1">{facility.county} {facility.city}</p> <p className="text-[#7ca982] uppercase tracking-[0.4em] font-black text-xs md:text-sm pl-1">{facility.county} {facility.city}</p>
{canOpenAdminShortcut && facilitySlug && (
<div className="mt-5">
<Link
href={`/admin/${facilitySlug}`}
className="inline-flex items-center rounded-full border border-white/15 bg-black/30 px-4 py-2 text-[10px] font-black uppercase tracking-[0.18em] text-white shadow-xl backdrop-blur-md transition hover:border-[#8bc34a]/70 hover:bg-black/45 hover:text-[#D9F0B9] md:text-[11px]"
>
Admin
</Link>
</div>
)}
</div> </div>
</div> </div>

View file

@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cookies } from "next/headers";
import { notFound, permanentRedirect } from "next/navigation"; import { notFound, permanentRedirect } from "next/navigation";
import { API_URL } from "@/config/constants"; import { API_URL } from "@/config/constants";
import { resolveFacilityAlias } from "@/app/facilityAliases"; import { resolveFacilityAlias } from "@/app/facilityAliases";
@ -85,6 +86,7 @@ export async function generateMetadata({ params }: GolfCoursePageProps): Promise
export default async function GolfCoursePage({ params }: GolfCoursePageProps) { export default async function GolfCoursePage({ params }: GolfCoursePageProps) {
const { slug } = await params; const { slug } = await params;
const cookieStore = await cookies();
const facility = await getFacility(slug); const facility = await getFacility(slug);
if (!facility) { if (!facility) {
@ -99,6 +101,7 @@ export default async function GolfCoursePage({ params }: GolfCoursePageProps) {
const facilityJsonLd = createFacilityJsonLd(facility); const facilityJsonLd = createFacilityJsonLd(facility);
const vtgCourseJsonLd = createVtgCourseJsonLd(facility); const vtgCourseJsonLd = createVtgCourseJsonLd(facility);
const relatedArticles = await getFacilityEditorialArticles(facility.slug, 3); const relatedArticles = await getFacilityEditorialArticles(facility.slug, 3);
const canOpenAdminShortcut = Boolean(cookieStore.get("admin_session")?.value);
const breadcrumbJsonLd = createBreadcrumbJsonLd([ const breadcrumbJsonLd = createBreadcrumbJsonLd([
{ name: "Hjem", path: "/" }, { name: "Hjem", path: "/" },
{ name: "Golfbaner", path: "/golfbaner" }, { name: "Golfbaner", path: "/golfbaner" },
@ -121,7 +124,11 @@ export default async function GolfCoursePage({ params }: GolfCoursePageProps) {
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/> />
<FacilityDetailView facility={facility} relatedArticles={relatedArticles} /> <FacilityDetailView
facility={facility}
relatedArticles={relatedArticles}
canOpenAdminShortcut={canOpenAdminShortcut}
/>
</> </>
); );
} }