Før endringer i enkeltbanefilteret

This commit is contained in:
Erol Haagenrud 2026-04-24 09:17:14 +02:00
parent a0af7a1151
commit f624546ebf
10 changed files with 696 additions and 87 deletions

View file

@ -573,6 +573,10 @@ class QuickEditRequest(BaseModel):
class FacilityVisibilityRequest(BaseModel):
is_published: bool
class PlacePageUpsertRequest(BaseModel):
factbox_intro_html: Optional[str] = ""
class GreenfeeApproval(BaseModel):
facility_id: int
greenfee: List[dict]
@ -715,6 +719,20 @@ def format_row(row):
return d
def format_place_page_row(row):
if row is None:
return None
d = dict(row)
for key in ["created_at", "updated_at"]:
if isinstance(d.get(key), (date, datetime)):
d[key] = d[key].isoformat()
d["factbox_intro_html"] = str(d.get("factbox_intro_html") or "")
return d
def normalize_club_lookup_value(value: str | None) -> str:
text = str(value or "").strip().lower()
if not text:
@ -1436,6 +1454,17 @@ async def ensure_facility_columns(conn):
""")
async def ensure_place_pages_table(conn):
await conn.execute("""
CREATE TABLE IF NOT EXISTS place_pages (
slug VARCHAR(255) PRIMARY KEY,
factbox_intro_html TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
async def ensure_articles_table(conn):
await conn.execute("""
CREATE TABLE IF NOT EXISTS articles (
@ -1602,6 +1631,7 @@ async def lifespan(app: FastAPI):
)
async with app.state.pool.acquire() as conn:
await ensure_facility_columns(conn)
await ensure_place_pages_table(conn)
await ensure_articles_table(conn)
await ensure_public_user_tables(conn)
await ensure_scrape_jobs_table(conn)
@ -2502,6 +2532,29 @@ async def get_facility(slug: str):
return format_row(row)
@app.get("/api/place-pages/{slug}")
async def get_place_page(slug: str):
normalized_slug = str(slug or "").strip().lower()
if not normalized_slug:
raise HTTPException(status_code=400, detail="Slug mangler.")
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM place_pages WHERE slug = $1",
normalized_slug,
)
if not row:
return {
"slug": normalized_slug,
"factbox_intro_html": "",
"created_at": None,
"updated_at": None,
}
return format_place_page_row(row)
@app.get("/api/admin/facilities")
async def get_admin_facilities():
"""Henter alle golfanlegg for admin, også upubliserte."""
@ -2584,6 +2637,52 @@ async def get_admin_facility(slug: str):
return format_row(row)
@app.get("/api/admin/place-pages/{slug}")
async def get_admin_place_page(slug: str):
normalized_slug = str(slug or "").strip().lower()
if not normalized_slug:
raise HTTPException(status_code=400, detail="Slug mangler.")
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM place_pages WHERE slug = $1",
normalized_slug,
)
if not row:
return {
"slug": normalized_slug,
"factbox_intro_html": "",
"created_at": None,
"updated_at": None,
}
return format_place_page_row(row)
@app.put("/api/admin/place-pages/{slug}")
async def update_admin_place_page(slug: str, request: PlacePageUpsertRequest):
normalized_slug = str(slug or "").strip().lower()
if not normalized_slug:
raise HTTPException(status_code=400, detail="Slug mangler.")
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO place_pages (slug, factbox_intro_html)
VALUES ($1, $2)
ON CONFLICT (slug) DO UPDATE
SET factbox_intro_html = EXCLUDED.factbox_intro_html,
updated_at = NOW()
RETURNING *
""",
normalized_slug,
request.factbox_intro_html or "",
)
return format_place_page_row(row)
@app.get("/api/course-visits")
async def get_course_visits():
"""Henter publiserte Banebesøk-artikler."""

View file

@ -394,7 +394,9 @@ const matchesSpecialFilter = (specialFilter: string, flags: SpecialFlags) => {
return true;
};
const WEATHER_DAY_OPTIONS = [
const OSLO_TIME_ZONE = "Europe/Oslo";
const WEATHER_DAY_BASE_OPTIONS = [
{ value: "", label: "Alle dager" },
{ value: "0", label: "I dag" },
{ value: "1", label: "I morgen" },
@ -406,6 +408,75 @@ const WEATHER_DAY_OPTIONS = [
{ value: "7", label: "Om en uke" },
];
const osloDateFormatter = new Intl.DateTimeFormat("nb-NO", {
timeZone: OSLO_TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const osloWeekdayFormatter = new Intl.DateTimeFormat("nb-NO", {
timeZone: OSLO_TIME_ZONE,
weekday: "long",
});
const getOsloDateParts = (date: Date) => {
const parts = osloDateFormatter.formatToParts(date);
const year = Number(parts.find((part) => part.type === "year")?.value ?? "0");
const month = Number(parts.find((part) => part.type === "month")?.value ?? "1");
const day = Number(parts.find((part) => part.type === "day")?.value ?? "1");
return { year, month, day };
};
const getOsloDateAtNoonUtc = (date: Date) => {
const { year, month, day } = getOsloDateParts(date);
return new Date(Date.UTC(year, month - 1, day, 12));
};
const getWeatherDayOptions = (date: Date = new Date()) => {
const osloToday = getOsloDateAtNoonUtc(date);
return WEATHER_DAY_BASE_OPTIONS.map((option) => {
if (!option.value) return option;
const dayOffset = Number.parseInt(option.value, 10);
const targetDate = new Date(osloToday);
targetDate.setUTCDate(targetDate.getUTCDate() + dayOffset);
return {
...option,
label: `${option.label} (${osloWeekdayFormatter.format(targetDate)})`,
};
});
};
const getOsloDateKey = (date: Date = new Date()) => {
const { year, month, day } = getOsloDateParts(date);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
};
const getMsUntilNextOsloDateChange = (date: Date = new Date()) => {
const minuteMs = 60_000;
const currentKey = getOsloDateKey(date);
let low = date.getTime();
let high = low + minuteMs;
while (getOsloDateKey(new Date(high)) === currentKey) {
high += 30 * minuteMs;
}
while (high - low > minuteMs) {
const mid = Math.floor((low + high) / 2);
if (getOsloDateKey(new Date(mid)) === currentKey) {
low = mid;
} else {
high = mid;
}
}
return Math.max(minuteMs, high - date.getTime() + minuteMs);
};
const getSearchShellClasses = (variant: Variant) =>
variant === "home"
? "rounded-[2rem] bg-[#39443B] px-4 py-5 text-white shadow-2xl sm:px-6 sm:py-7"
@ -438,6 +509,7 @@ export default function FacilitySearch({
const [holeFilter, setHoleFilter] = useState("");
const [specialFilter, setSpecialFilter] = useState("");
const [weatherDayFilter, setWeatherDayFilter] = useState("");
const [weatherDayOptions, setWeatherDayOptions] = useState(() => getWeatherDayOptions());
const [architectFilter, setArchitectFilter] = useState("");
const [facilityFilter, setFacilityFilter] = useState("");
const [sortMethod, setSortMethod] = useState<SortMethod>("updated");
@ -448,6 +520,28 @@ export default function FacilitySearch({
setAreaFilter(fixedAreaFilter);
}, [fixedAreaFilter]);
useEffect(() => {
let timeoutId: number | undefined;
let cancelled = false;
const scheduleRefresh = () => {
timeoutId = window.setTimeout(() => {
if (cancelled) return;
setWeatherDayOptions(getWeatherDayOptions());
scheduleRefresh();
}, getMsUntilNextOsloDateChange());
};
scheduleRefresh();
return () => {
cancelled = true;
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
};
}, []);
useEffect(() => {
if (!("geolocation" in navigator)) return;
@ -791,7 +885,7 @@ export default function FacilitySearch({
onChange={setWeatherDayFilter}
labelClassName={labelClassName}
>
{WEATHER_DAY_OPTIONS.map((option) => (
{weatherDayOptions.map((option) => (
<option key={option.value || "all"} value={option.value}>
{option.label}
</option>

View file

@ -1308,6 +1308,9 @@ export default function AdminDashboard() {
<div className="space-y-2">
<div className="text-[9px] font-bold uppercase tracking-widest text-gray-500">Innhold</div>
<Link href="/admin/steder" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
Steder
</Link>
<Link href="/admin/artikler" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
Artikler
</Link>
@ -1367,6 +1370,9 @@ export default function AdminDashboard() {
</div>
<div className="space-y-2 mt-6">
<div className="text-[8px] text-gray-500 font-bold uppercase tracking-widest pl-4 mb-2 opacity-50">Innhold</div>
<Link href="/admin/steder" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Steder">
{isSidebarCollapsed ? 'S' : 'Steder'}
</Link>
<Link href="/admin/artikler" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Artikler">
{isSidebarCollapsed ? 'A' : 'Artikler'}
</Link>
@ -1410,6 +1416,12 @@ export default function AdminDashboard() {
</div>
<div className="flex flex-wrap items-center gap-3">
<Link
href="/admin/steder"
className="btn btn-md btn-secondary"
>
Sted-sider
</Link>
<Link
href="/admin/artikler"
className="btn btn-md btn-secondary"

View file

@ -0,0 +1,185 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import AdminMobileMenu from "@/components/AdminMobileMenu";
import TiptapHtmlEditor from "@/components/TiptapHtmlEditor";
import { HIERARCHICAL_AREA_OPTIONS, getPlaceConfigFromSlug } from "@/app/facilityData";
import { adminFetch } from "@/config/adminFetch";
import { API_URL } from "@/config/constants";
type PlacePageResponse = {
slug: string;
factbox_intro_html?: string | null;
updated_at?: string | null;
};
const DEFAULT_PLACE_SLUG = "norge";
const formatDateTime = (value: string | null | undefined) => {
if (!value) return "Ikke lagret ennå";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Ukjent";
return date.toLocaleString("nb-NO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
export default function AdminPlacePagesPage() {
const [selectedSlug, setSelectedSlug] = useState(DEFAULT_PLACE_SLUG);
const [html, setHtml] = useState("");
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [feedback, setFeedback] = useState("");
const selectedPlace = getPlaceConfigFromSlug(selectedSlug);
useEffect(() => {
const controller = new AbortController();
const loadPlacePage = async () => {
setIsLoading(true);
setFeedback("");
try {
const response = await adminFetch(`${API_URL}/admin/place-pages/${selectedSlug}`, {
credentials: "include",
signal: controller.signal,
});
if (!response.ok) {
throw new Error("Kunne ikke hente sted-siden.");
}
const data = (await response.json()) as PlacePageResponse;
setHtml(data.factbox_intro_html || "");
setUpdatedAt(data.updated_at || null);
} catch (error) {
if (controller.signal.aborted) return;
setHtml("");
setUpdatedAt(null);
setFeedback(error instanceof Error ? error.message : "Kunne ikke hente sted-siden.");
} finally {
if (!controller.signal.aborted) {
setIsLoading(false);
}
}
};
loadPlacePage();
return () => controller.abort();
}, [selectedSlug]);
const handleSave = async () => {
setIsSaving(true);
setFeedback("");
try {
const response = await adminFetch(`${API_URL}/admin/place-pages/${selectedSlug}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ factbox_intro_html: html }),
});
if (!response.ok) {
throw new Error("Kunne ikke lagre sted-siden.");
}
const data = (await response.json()) as PlacePageResponse;
setUpdatedAt(data.updated_at || null);
setFeedback("Lagret.");
} catch (error) {
setFeedback(error instanceof Error ? error.message : "Kunne ikke lagre sted-siden.");
} finally {
setIsSaving(false);
}
};
return (
<main className="mx-auto min-h-screen max-w-[1100px] bg-white p-4 md:p-8">
<AdminMobileMenu />
<div className="mb-10 border-b border-gray-200 pb-6">
<Link href="/admin" className="mb-2 block text-sm font-bold text-gray-500 hover:text-[#8bc34a]">
Tilbake til oversikten
</Link>
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-black uppercase tracking-[0.2em] text-[#7ca982]">Sted-sider</p>
<h1 className="mt-2 text-4xl font-black tracking-tight text-[#11280f]">Rediger innhold over faktaboksen</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-[#536256]">
Dette innholdet vises over nøkkeltall-/faktaboksen på `/sted/[slug]`.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<Link href={`/sted/${selectedSlug}`} target="_blank" className="btn btn-md btn-secondary">
Åpne sted-siden
</Link>
<button
type="button"
onClick={handleSave}
disabled={isLoading || isSaving}
className="btn btn-md btn-primary disabled:opacity-50"
>
{isSaving ? "Lagrer..." : "Lagre"}
</button>
</div>
</div>
</div>
<section className="rounded-[2rem] border border-gray-200 bg-gray-50 p-6 shadow-sm md:p-8">
<div className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
<div className="space-y-4">
<label className="flex flex-col gap-2">
<span className="text-xs font-black uppercase tracking-widest text-gray-600">Sted-side</span>
<select
value={selectedSlug}
onChange={(event) => setSelectedSlug(event.target.value)}
className="rounded-2xl border-2 border-gray-300 bg-white px-4 py-4 text-base font-bold text-black outline-none focus:border-[#8bc34a]"
>
{HIERARCHICAL_AREA_OPTIONS.map((option) => (
<option key={option.slug} value={option.slug}>
{option.label}
</option>
))}
</select>
</label>
<div className="rounded-[1.5rem] border border-[#112015]/8 bg-white p-5">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Aktiv side</p>
<p className="mt-2 text-xl font-black text-[#112015]">{selectedPlace?.title || selectedSlug}</p>
<p className="mt-2 text-sm leading-6 text-[#536256]">{selectedPlace?.intro || "Ingen intro tilgjengelig."}</p>
<p className="mt-4 text-xs font-bold uppercase tracking-widest text-gray-500">
Sist lagret: {formatDateTime(updatedAt)}
</p>
{feedback ? (
<p className="mt-3 text-sm font-bold text-[#11280f]">{feedback}</p>
) : null}
</div>
</div>
<div>
{isLoading ? (
<div className="rounded-[1.75rem] border border-[#112015]/8 bg-white px-5 py-12 text-sm font-bold text-[#536256]">
Laster sted-side...
</div>
) : (
<TiptapHtmlEditor
value={html}
onChange={setHtml}
placeholder="Skriv HTML-innholdet som skal vises over faktaboksen på denne sted-siden."
/>
)}
</div>
</div>
</section>
</main>
);
}

View file

@ -5,6 +5,57 @@ import { adminFetch } from "@/config/adminFetch";
import AdminMobileMenu from "@/components/AdminMobileMenu";
import Link from 'next/link';
type VtgDateRow = {
dato?: string;
status?: string;
};
const normalizeDateRows = (value: any): VtgDateRow[] => {
if (!Array.isArray(value)) return [];
return value.map((row) => ({
dato: typeof row?.dato === 'string' ? row.dato : '',
status: typeof row?.status === 'string' ? row.status : 'Ledig'
}));
};
const datesAreEqual = (left: any, right: any) => (
JSON.stringify(normalizeDateRows(left)) === JSON.stringify(normalizeDateRows(right))
);
const textValue = (value: any) => (
typeof value === 'string' ? value.trim() : ''
);
const priceValue = (value: any) => {
if (value === '' || value === null || typeof value === 'undefined') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const formatPriceLabel = (value: any) => {
const parsed = priceValue(value);
return parsed === null ? 'Ingen pris registrert' : `${parsed} kr`;
};
function ReadOnlyDateList({ dates, emptyLabel }: { dates: any; emptyLabel: string }) {
const normalizedDates = normalizeDateRows(dates);
if (normalizedDates.length === 0) {
return <div className="rounded-xl bg-gray-50 p-4 text-sm italic text-gray-500">{emptyLabel}</div>;
}
return (
<div className="space-y-2">
{normalizedDates.map((row, idx) => (
<div key={`${row.dato}-${row.status}-${idx}`} className="grid gap-2 rounded-lg border border-gray-200 bg-white p-3 sm:grid-cols-[minmax(0,1fr)_150px] sm:items-center">
<div className="text-sm font-bold text-gray-800">{row.dato || 'Uten dato'}</div>
<div className="text-xs font-bold uppercase tracking-wide text-gray-500">{row.status || 'Ukjent status'}</div>
</div>
))}
</div>
);
}
export default function VtgWasher() {
const [drafts, setDrafts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
@ -145,11 +196,28 @@ export default function VtgWasher() {
{drafts.map((draft, index) => (
<div key={draft.id} className={`p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/10 ring-2 ring-[#8bc34a]/20' : index % 2 === 0 ? 'border-[#e3edd7] bg-white' : 'border-[#dbe7f5] bg-[#f8fbff]'}`}>
{(() => {
const priceChanged = priceValue(draft.vtg_pris) !== priceValue(draft.edit_pris);
const descriptionChanged = textValue(draft.vtg_beskrivelse) !== textValue(draft.edit_beskrivelse);
const datesChanged = !datesAreEqual(draft.vtg_datoer, draft.edit_datoer);
const changedCount = [priceChanged, descriptionChanged, datesChanged].filter(Boolean).length;
return (
<div className="flex gap-6 items-start">
<div className="pt-2"><input type="checkbox" className="w-6 h-6 accent-[#8bc34a] cursor-pointer" checked={selectedIds.includes(draft.id)} onChange={() => toggleOne(draft.id)} /></div>
<div className="flex-grow space-y-4">
<div className="flex flex-col gap-3 border-b pb-4 md:flex-row md:items-center md:justify-between">
<h3 className="text-2xl font-black">{draft.name} <span className="text-xs font-mono font-bold bg-gray-100 text-gray-400 px-2 py-1 rounded-md">ID: {draft.id}</span></h3>
<div className="space-y-2">
<h3 className="text-2xl font-black">{draft.name} <span className="text-xs font-mono font-bold bg-gray-100 text-gray-400 px-2 py-1 rounded-md">ID: {draft.id}</span></h3>
<div className="flex flex-wrap gap-2">
<span className={`rounded-full px-3 py-1 text-[11px] font-black uppercase tracking-widest ${changedCount > 0 ? 'bg-amber-100 text-amber-900' : 'bg-gray-100 text-gray-500'}`}>
{changedCount > 0 ? `${changedCount} felt endret` : 'Ingen reell endring'}
</span>
{priceChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Pris</span>}
{descriptionChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Beskrivelse</span>}
{datesChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Kursdatoer</span>}
</div>
</div>
<a href={draft.vtg_lenke?.split(',')[0]} target="_blank" className="btn btn-md btn-secondary w-full md:w-auto">Sjekk Nettside </a>
</div>
@ -159,48 +227,87 @@ export default function VtgWasher() {
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Pris & Beskrivelse */}
<div className="grid grid-cols-1 gap-8 xl:grid-cols-2">
<div className="space-y-4">
<h4 className="text-xs font-black uppercase tracking-widest text-green-600">Pris & Beskrivelse</h4>
<div>
<label className="text-[10px] font-bold text-gray-500 uppercase">Standardpris for Voksen (kr)</label>
<input className="w-full mt-1 p-3 rounded-xl border border-gray-200 text-sm focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_pris} onChange={e => updateField(draft.id, 'edit_pris', e.target.value)} placeholder="Eks: 1990" />
</div>
<div>
<label className="text-[10px] font-bold text-gray-500 uppercase">Selgende tekst / Inkludert i kurset</label>
<textarea className="w-full mt-1 p-3 rounded-xl border border-gray-200 text-sm focus:border-[#8bc34a] outline-none resize-y" rows={5} value={draft.edit_beskrivelse} onChange={e => updateField(draft.id, 'edit_beskrivelse', e.target.value)} placeholder="Beskriv kurset..." />
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4">
<div className="mb-3 text-[10px] font-black uppercase tracking-widest text-gray-500">I dag</div>
<div className="space-y-4">
<div>
<div className="text-[10px] font-bold uppercase text-gray-500">Standardpris for voksen</div>
<div className="mt-1 text-base font-black text-gray-900">{formatPriceLabel(draft.vtg_pris)}</div>
</div>
<div>
<div className="text-[10px] font-bold uppercase text-gray-500">Tekst siden</div>
<div className="mt-1 whitespace-pre-wrap rounded-xl bg-white p-3 text-sm text-gray-700">
{textValue(draft.vtg_beskrivelse) || 'Ingen beskrivelse registrert'}
</div>
</div>
</div>
</div>
<div className={`rounded-2xl border p-4 ${priceChanged || descriptionChanged ? 'border-green-200 bg-green-50/60' : 'border-gray-200 bg-white'}`}>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-[10px] font-black uppercase tracking-widest text-gray-500">Forslag</div>
<span className={`rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-widest ${priceChanged || descriptionChanged ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}>
{priceChanged || descriptionChanged ? 'Har endringer' : 'Lik dagens verdi'}
</span>
</div>
<div>
<label className="text-[10px] font-bold text-gray-500 uppercase">Standardpris for Voksen (kr)</label>
<input className="w-full mt-1 p-3 rounded-xl border border-gray-200 text-sm focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_pris} onChange={e => updateField(draft.id, 'edit_pris', e.target.value)} placeholder="Eks: 1990" />
</div>
<div className="mt-4">
<label className="text-[10px] font-bold text-gray-500 uppercase">Selgende tekst / Inkludert i kurset</label>
<textarea className="w-full mt-1 p-3 rounded-xl border border-gray-200 text-sm focus:border-[#8bc34a] outline-none resize-y" rows={5} value={draft.edit_beskrivelse} onChange={e => updateField(draft.id, 'edit_beskrivelse', e.target.value)} placeholder="Beskriv kurset..." />
</div>
</div>
</div>
</div>
{/* Kursdatoer */}
<div>
<h4 className="text-xs font-black uppercase tracking-widest text-green-600 mb-4">Kursdatoer</h4>
<div className="space-y-2">
{draft.edit_datoer.length === 0 ? (
<div className="p-4 bg-gray-50 rounded-xl text-sm text-gray-500 italic">Fant ingen spesifikke kursdatoer.</div>
) : (
draft.edit_datoer.map((row: any, idx: number) => (
<div key={idx} className="grid gap-2 rounded-lg border border-gray-200 bg-white p-3 relative group sm:grid-cols-[minmax(0,1fr)_150px_auto] sm:items-center">
<input className="w-full rounded border border-gray-100 p-2 text-xs font-bold outline-none focus:border-[#8bc34a]" value={row.dato} onChange={e => updateDateRow(draft.id, idx, 'dato', e.target.value)} placeholder="F.eks: 12.-14. mai" />
<select className="w-full rounded border border-gray-100 p-2 text-xs outline-none focus:border-[#8bc34a] bg-white" value={row.status} onChange={e => updateDateRow(draft.id, idx, 'status', e.target.value)}>
<option value="Ledig">Ledig</option>
<option value="Fulltegnet">Fulltegnet</option>
<option value="Venteliste">Venteliste</option>
<option value="Få plasser"> plasser</option>
</select>
<button onClick={() => removeDateRow(draft.id, idx)} className="btn btn-sm btn-danger sm:opacity-0 sm:group-hover:opacity-100" title="Slett dato"></button>
</div>
))
)}
<button onClick={() => addDateRow(draft.id)} className="btn btn-sm btn-secondary mt-2">
+ Legg til ny dato
</button>
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4">
<div className="mb-3 text-[10px] font-black uppercase tracking-widest text-gray-500">I dag</div>
<ReadOnlyDateList dates={draft.vtg_datoer} emptyLabel="Ingen kursdatoer registrert i dag." />
</div>
<div className={`rounded-2xl border p-4 ${datesChanged ? 'border-green-200 bg-green-50/60' : 'border-gray-200 bg-white'}`}>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-[10px] font-black uppercase tracking-widest text-gray-500">Forslag</div>
<span className={`rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-widest ${datesChanged ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}>
{datesChanged ? 'Har endringer' : 'Lik dagens datoer'}
</span>
</div>
<div className="space-y-2">
{draft.edit_datoer.length === 0 ? (
<div className="p-4 bg-white rounded-xl text-sm text-gray-500 italic border border-gray-200">Fant ingen spesifikke kursdatoer.</div>
) : (
draft.edit_datoer.map((row: any, idx: number) => (
<div key={idx} className="grid gap-2 rounded-lg border border-gray-200 bg-white p-3 relative group sm:grid-cols-[minmax(0,1fr)_150px_auto] sm:items-center">
<input className="w-full rounded border border-gray-100 p-2 text-xs font-bold outline-none focus:border-[#8bc34a]" value={row.dato} onChange={e => updateDateRow(draft.id, idx, 'dato', e.target.value)} placeholder="F.eks: 12.-14. mai" />
<select className="w-full rounded border border-gray-100 p-2 text-xs outline-none focus:border-[#8bc34a] bg-white" value={row.status} onChange={e => updateDateRow(draft.id, idx, 'status', e.target.value)}>
<option value="Ledig">Ledig</option>
<option value="Fulltegnet">Fulltegnet</option>
<option value="Venteliste">Venteliste</option>
<option value="Få plasser"> plasser</option>
</select>
<button onClick={() => removeDateRow(draft.id, idx)} className="btn btn-sm btn-danger sm:opacity-0 sm:group-hover:opacity-100" title="Slett dato"></button>
</div>
))
)}
<button onClick={() => addDateRow(draft.id)} className="btn btn-sm btn-secondary mt-2">
+ Legg til ny dato
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
})()}
</div>
))}
</div>

View file

@ -386,71 +386,72 @@ export default function FacilityDetailView({
</div>
{/* SIDEBAR (22%) */}
<div className="lg:w-[22%] bg-white p-10 md:rounded-[3rem] shadow-sm flex flex-col order-last lg:order-none">
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-10">Kontakt & Adresse</h3>
<div className="flex-grow space-y-7 text-sm font-bold">
{facility.website_url ? (
<a href={facility.website_url} target="_blank" rel="noreferrer" className={sidebarLinkClass}>
<Icon children={ICONS.web} /> <span className={sidebarLinkTextClass}>Besøk nettsiden</span>
</a>
) : (
<div className="flex items-center gap-4 text-[#8A9488]">
<Icon children={ICONS.web} /> <span>Nettside ikke oppgitt</span>
</div>
)}
{facility.phone ? (
<a href={`tel:${formatPhoneForUrl(facility.phone)}`} className={sidebarLinkClass}>
<Icon children={ICONS.phone} /> <span className={sidebarLinkTextClass}>{facility.phone}</span>
</a>
) : (
<div className="flex items-center gap-4 text-[#8A9488]">
<Icon children={ICONS.phone} /> <span>Telefon ikke oppgitt</span>
</div>
)}
{facility.email ? (
<a href={`mailto:${facility.email}`} className={sidebarLinkClass}>
<Icon children={ICONS.mail} /> <span className={`truncate ${sidebarLinkTextClass}`}>{facility.email}</span>
</a>
) : (
<div className="flex items-center gap-4 text-[#8A9488]">
<Icon children={ICONS.mail} /> <span>E-post ikke oppgitt</span>
</div>
)}
<div className="pt-2 border-t border-gray-50 mt-4">
<div className="order-last flex flex-col gap-6 lg:order-none lg:w-[22%]">
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm flex flex-col">
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-10">Kontakt & Adresse</h3>
<div className="flex-grow space-y-7 text-sm font-bold">
{facility.website_url ? (
<a href={facility.website_url} target="_blank" rel="noreferrer" className={sidebarLinkClass}>
<Icon children={ICONS.web} /> <span className={sidebarLinkTextClass}>Besøk nettsiden</span>
</a>
) : (
<div className="flex items-center gap-4 text-[#8A9488]">
<Icon children={ICONS.web} /> <span>Nettside ikke oppgitt</span>
</div>
)}
{facility.phone ? (
<a href={`tel:${formatPhoneForUrl(facility.phone)}`} className={sidebarLinkClass}>
<Icon children={ICONS.phone} /> <span className={sidebarLinkTextClass}>{facility.phone}</span>
</a>
) : (
<div className="flex items-center gap-4 text-[#8A9488]">
<Icon children={ICONS.phone} /> <span>Telefon ikke oppgitt</span>
</div>
)}
{facility.email ? (
<a href={`mailto:${facility.email}`} className={sidebarLinkClass}>
<Icon children={ICONS.mail} /> <span className={`truncate ${sidebarLinkTextClass}`}>{facility.email}</span>
</a>
) : (
<div className="flex items-center gap-4 text-[#8A9488]">
<Icon children={ICONS.mail} /> <span>E-post ikke oppgitt</span>
</div>
)}
<div className="pt-2 border-t border-gray-50 mt-4">
{mapUrl ? (
<a href={mapUrl} target="_blank" rel="noreferrer" className={sidebarLinkClass + " pt-4 leading-tight items-start"}>
<Icon children={ICONS.pin} /> <span className="text-gray-400 group-hover:text-[#ff5722] transition-colors">{facility.address}<br/>{facility.city}</span>
<Icon children={ICONS.pin} /> <span className="text-gray-400 group-hover:text-[#ff5722] transition-colors">{facility.address}<br/>{facility.city}</span>
</a>
) : (
<div className="flex items-start gap-4 pt-4 text-[#8A9488] leading-tight">
<Icon children={ICONS.pin} /> <span>{facility.address || "Adresse ikke oppgitt"}{facility.city ? <><br />{facility.city}</> : null}</span>
</div>
)}
</div>
</div>
</div>
{/* SOSIALE MEDIER IKONER */}
{socialLinks.length > 0 && (
<div className="pt-6 border-t border-gray-50 mt-6 flex flex-wrap gap-3">
{socialLinks.map((social: any, idx: number) => {
const platform = (social.platform || '').toLowerCase().trim();
const iconData = SOCIAL_ICONS[platform] || <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3" />;
return (
<a key={idx} href={social.url} target="_blank" rel="noreferrer" title={social.platform} className="btn-icon h-10 w-10 rounded-full shadow-sm">
<Icon children={iconData} className="w-4 h-4 text-current" />
</a>
);
})}
</div>
)}
<div className="mt-10 pt-6 border-t border-gray-50">
<Link href={`/sted/${placeSlug}`} className={`${detailMetaLinkClass} flex items-center gap-1`}>
{socialLinks.length > 0 && (
<div className="mt-6 flex flex-wrap gap-3 border-t border-gray-50 pt-6">
{socialLinks.map((social: any, idx: number) => {
const platform = (social.platform || '').toLowerCase().trim();
const iconData = SOCIAL_ICONS[platform] || <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3" />;
return (
<a key={idx} href={social.url} target="_blank" rel="noreferrer" title={social.platform} className="btn-icon h-10 w-10 rounded-full shadow-sm">
<Icon children={iconData} className="w-4 h-4 text-current" />
</a>
);
})}
</div>
)}
<div className="mt-10 border-t border-gray-50 pt-6">
<Link href={`/sted/${placeSlug}`} className={`${detailMetaLinkClass} flex items-center gap-1`}>
Se alle baner i {facility.county}
</Link>
</div>
</div>
</Link>
</div>
</div>
</div>
</section>
{/* 4. 3-KOLONNE INFO */}

View file

@ -21,6 +21,75 @@ import {
createPageMetadata,
} from "@/app/seo";
type PlacePageData = {
slug?: string;
factbox_intro_html?: string | null;
updated_at?: string | null;
};
const escapeHtml = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const sanitizeHref = (value: string) => {
const href = value.trim();
return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#";
};
const isInternalTeeoffHref = (href: string) =>
/^(\/|#|mailto:|tel:)/i.test(href) || /^https?:\/\/([^/]+\.)?teeoff\.no(\/|$)/i.test(href);
const sanitizePlaceRichText = (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*u\s*>/gi, () => keep("<u>"))
.replace(/<\s*\/\s*u\s*>/gi, () => keep("</u>"))
.replace(/<\s*(p|blockquote)\s*>/gi, (_, tag: string) => keep(`<${tag.toLowerCase()}>`))
.replace(/<\s*\/\s*(p|blockquote)\s*>/gi, (_, tag: string) => keep(`</${tag.toLowerCase()}>`))
.replace(/<\s*(ul|ol)\s*>/gi, (_, tag: string) => keep(`<${tag.toLowerCase()}>`))
.replace(/<\s*\/\s*(ul|ol)\s*>/gi, (_, tag: string) => keep(`</${tag.toLowerCase()}>`))
.replace(/<\s*li\s*>/gi, () => keep("<li>"))
.replace(/<\s*\/\s*li\s*>/gi, () => keep("</li>"))
.replace(/<\s*(h2|h3)\s*>/gi, (_, tag: string) => keep(`<${tag.toLowerCase()}>`))
.replace(/<\s*\/\s*(h2|h3)\s*>/gi, (_, tag: string) => keep(`</${tag.toLowerCase()}>`))
.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 (isInternalTeeoffHref(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;
};
export const dynamicParams = true;
export const revalidate = 3600;
@ -56,6 +125,7 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
}
let facilities: FacilityRecord[] = [];
let placePage: PlacePageData | null = null;
try {
const res = await fetch(`${API_URL}/facilities?summary=1`, {
@ -72,6 +142,21 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
facilities = [];
}
try {
const res = await fetch(`${API_URL}/place-pages/${slug}`, {
cache: "no-store",
});
if (!res.ok) {
throw new Error(`API returnerte status ${res.status}`);
}
placePage = await res.json();
} catch (error) {
console.error("Kritisk feil ved henting av sted-sideinnhold:", error);
placePage = null;
}
const safeData = Array.isArray(facilities) ? facilities : [];
const enrichedFacilities = enrichFacilities(safeData);
const facilitiesInPlace = filterFacilitiesByArea(enrichedFacilities, place.areaFilter);
@ -149,6 +234,17 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
</div>
</div>
{placePage?.factbox_intro_html ? (
<section className="mt-8 overflow-hidden rounded-[2rem] border border-[#E7D8CE] bg-[#fff8f4] shadow-sm">
<div className="px-5 py-6 sm:px-6 lg:px-8 lg:py-8">
<div
className="max-w-4xl text-base leading-8 text-[#4e3d34] [&_a]:font-bold [&_a]:text-[#d53300] [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-[#a82800] [&_blockquote]:border-l-4 [&_blockquote]:border-[#e4b9a8] [&_blockquote]:pl-4 [&_em]:italic [&_h2]:mt-8 [&_h2]:text-2xl [&_h2]:font-black [&_h2]:text-[#112015] [&_h2:first-child]:mt-0 [&_h3]:mt-6 [&_h3]:text-xl [&_h3]:font-black [&_h3]:text-[#112015] [&_ol]:my-5 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-5 [&_p:last-child]:mb-0 [&_strong]:font-bold [&_u]:underline [&_ul]:my-5 [&_ul]:list-disc [&_ul]:pl-6"
dangerouslySetInnerHTML={{ __html: sanitizePlaceRichText(placePage.factbox_intro_html) }}
/>
</div>
</section>
) : null}
<section className="mt-8 overflow-hidden rounded-[2rem] border border-[#D7DED0] bg-white shadow-sm">
<div className="px-5 py-6 sm:px-6 lg:px-8 lg:py-8">
<div className="max-w-5xl">

View file

@ -15,6 +15,7 @@ const NAV_ITEMS = [
{ href: '/admin/greenfee', label: 'Greenfee', match: (pathname: string) => pathname.startsWith('/admin/greenfee') },
{ href: '/admin/golfpakker', label: 'Golfpakker', match: (pathname: string) => pathname.startsWith('/admin/golfpakker') },
{ href: '/admin/vtg', label: 'VTG', match: (pathname: string) => pathname.startsWith('/admin/vtg') },
{ href: '/admin/steder', label: 'Steder', match: (pathname: string) => pathname.startsWith('/admin/steder') },
];
export default function AdminMobileMenu({ onOpenTwoFactor }: AdminMobileMenuProps) {

View file

@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS place_pages (
slug VARCHAR(255) PRIMARY KEY,
factbox_intro_html TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View file

@ -1,3 +1,4 @@
DROP TABLE IF EXISTS place_pages CASCADE;
-- Slett gamle tabeller slik at vi starter med helt blanke ark
DROP TABLE IF EXISTS hole_lengths CASCADE;
DROP TABLE IF EXISTS holes CASCADE;
@ -33,6 +34,13 @@ CREATE TABLE facilities (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE place_pages (
slug VARCHAR(255) PRIMARY KEY,
factbox_intro_html TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 2. Tabellen for BANER (Tilhører et anlegg)
CREATE TABLE courses (
id SERIAL PRIMARY KEY,