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 Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { getPublicCourseDisplayName, type EnrichedFacility } from "@/app/facilityData";
@ -503,6 +504,7 @@ export default function FacilitySearch({
onFilteredFacilitiesChange,
filterHeading = "Søk golfbaner",
}: FacilitySearchProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
const [areaFilter, setAreaFilter] = useState(fixedAreaFilter);
const [statusFilter, setStatusFilter] = useState("");
@ -511,7 +513,6 @@ export default function FacilitySearch({
const [weatherDayFilter, setWeatherDayFilter] = useState("");
const [weatherDayOptions, setWeatherDayOptions] = useState(() => getWeatherDayOptions());
const [architectFilter, setArchitectFilter] = useState("");
const [facilityFilter, setFacilityFilter] = useState("");
const [sortMethod, setSortMethod] = useState<SortMethod>("updated");
const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null);
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
@ -707,8 +708,6 @@ export default function FacilitySearch({
: weatherForecast.find((entry) => Number(entry?.day_offset) === selectedWeatherDayOffset);
const matchesWeather = !weatherDayFilter || Boolean(weatherDay?.dry_daylight);
const matchesArchitect = !architectFilter || architectKey === architectFilter;
const matchesFacility = !facilityFilter || facility.slug === facilityFilter;
return {
...facility,
holeValue,
@ -730,7 +729,6 @@ export default function FacilitySearch({
matchesSpecial,
matchesWeather,
matchesArchitect,
matchesFacility,
};
})
.filter(
@ -741,8 +739,7 @@ export default function FacilitySearch({
facility.matchesHoles &&
facility.matchesSpecial &&
facility.matchesWeather &&
facility.matchesArchitect &&
facility.matchesFacility
facility.matchesArchitect
)
.sort((a, b) => {
if (sortMethod === "dist") {
@ -758,7 +755,6 @@ export default function FacilitySearch({
}, [
areaFilter,
architectFilter,
facilityFilter,
holeFilter,
initialFacilities,
searchQuery,
@ -776,7 +772,6 @@ export default function FacilitySearch({
specialFilter,
weatherDayFilter,
architectFilter,
facilityFilter,
searchQuery.trim(),
].filter(Boolean).length;
const summaryText = `${processedFacilities.length} baner • ${getAreaLabel(areaFilter, countyOptions)}${
@ -786,6 +781,11 @@ export default function FacilitySearch({
const isCollapsibleHomeSearch = variant === "home";
const searchPanelOpen = !isCollapsibleHomeSearch || isMobileSearchOpen;
const handleFacilitySelect = (slug: string) => {
if (!slug) return;
router.push(`/golfbaner/${slug}`);
};
useEffect(() => {
if (isCollapsibleHomeSearch && filtersCount > 0) {
setIsMobileSearchOpen(true);
@ -916,11 +916,11 @@ export default function FacilitySearch({
<FieldSelect
label="Golfanlegg"
value={facilityFilter}
onChange={setFacilityFilter}
value=""
onChange={handleFacilitySelect}
labelClassName={labelClassName}
>
<option value="">Alle golfanlegg</option>
<option value="">direkte til golfanlegg</option>
{facilityOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
@ -957,7 +957,6 @@ export default function FacilitySearch({
setSpecialFilter("");
setWeatherDayFilter("");
setArchitectFilter("");
setFacilityFilter("");
setSortMethod(userLocation ? "dist" : "updated");
}}
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 [loading, setLoading] = useState(true);
const [selectedFacilities, setSelectedFacilities] = useState<number[]>([]);
const [facilityJumpSlug, setFacilityJumpSlug] = useState('');
const [showBackToTop, setShowBackToTop] = useState(false);
const [scrapeJobs, setScrapeJobs] = useState<ScrapeJob[]>([]);
const [isQueueing, setIsQueueing] = 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 [isManualSaving, setIsManualSaving] = useState(false);
const [dismissedLatestJobKeys, setDismissedLatestJobKeys] = useState<Partial<Record<AdminTab, string>>>({});
const mainContentRef = useRef<HTMLElement | null>(null);
const fetchFacilities = () => {
adminFetch(`${API_URL}/admin/facilities`)
@ -434,6 +437,19 @@ export default function AdminDashboard() {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [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(() => {
if (statusFilter === 'alle') return facilities;
return facilities.map(facility => {
@ -451,6 +467,36 @@ export default function AdminDashboard() {
}).filter(facility => facility.course_statuses && facility.course_statuses.length > 0);
}, [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(() => {
if (!latestJob?.result_summary) return '';
@ -1397,7 +1443,7 @@ export default function AdminDashboard() {
</aside>
{/* 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="mb-6 flex md:hidden">
<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.
</p>
</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">
<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 className="flex flex-col gap-3 sm:flex-row sm:items-end">
<label className="block min-w-[18rem]">
<span className="mb-2 block text-[10px] font-black uppercase tracking-[0.2em] text-gray-500">
Hopp til golfanlegg
</span>
<select
value={facilityJumpSlug}
onChange={(e) => setFacilityJumpSlug(e.target.value)}
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 className="mb-6 rounded-[1.75rem] border border-[#dbe7cf] bg-white p-4 shadow-sm">
@ -1810,7 +1876,14 @@ export default function AdminDashboard() {
return (
<article
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-4 lg:flex-row lg:items-start lg:justify-between">
@ -2323,6 +2396,19 @@ export default function AdminDashboard() {
</div>
</div>
</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>
);
}

View file

@ -179,12 +179,14 @@ const SOCIAL_ICONS: Record<string, React.ReactNode> = {
export default function FacilityDetailView({
facility,
relatedArticles = { banebesok: [], meninger: [] },
canOpenAdminShortcut = false,
}: {
facility: any;
relatedArticles?: {
banebesok: any[];
meninger: any[];
};
canOpenAdminShortcut?: boolean;
}) {
const [showBackToTop, setShowBackToTop] = useState(false);
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>
<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>

View file

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