Før endringer i enkeltbanefilteret
This commit is contained in:
parent
a0af7a1151
commit
f624546ebf
10 changed files with 696 additions and 87 deletions
|
|
@ -573,6 +573,10 @@ class QuickEditRequest(BaseModel):
|
||||||
class FacilityVisibilityRequest(BaseModel):
|
class FacilityVisibilityRequest(BaseModel):
|
||||||
is_published: bool
|
is_published: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PlacePageUpsertRequest(BaseModel):
|
||||||
|
factbox_intro_html: Optional[str] = ""
|
||||||
|
|
||||||
class GreenfeeApproval(BaseModel):
|
class GreenfeeApproval(BaseModel):
|
||||||
facility_id: int
|
facility_id: int
|
||||||
greenfee: List[dict]
|
greenfee: List[dict]
|
||||||
|
|
@ -715,6 +719,20 @@ def format_row(row):
|
||||||
return d
|
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:
|
def normalize_club_lookup_value(value: str | None) -> str:
|
||||||
text = str(value or "").strip().lower()
|
text = str(value or "").strip().lower()
|
||||||
if not text:
|
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):
|
async def ensure_articles_table(conn):
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS articles (
|
CREATE TABLE IF NOT EXISTS articles (
|
||||||
|
|
@ -1602,6 +1631,7 @@ async def lifespan(app: FastAPI):
|
||||||
)
|
)
|
||||||
async with app.state.pool.acquire() as conn:
|
async with app.state.pool.acquire() as conn:
|
||||||
await ensure_facility_columns(conn)
|
await ensure_facility_columns(conn)
|
||||||
|
await ensure_place_pages_table(conn)
|
||||||
await ensure_articles_table(conn)
|
await ensure_articles_table(conn)
|
||||||
await ensure_public_user_tables(conn)
|
await ensure_public_user_tables(conn)
|
||||||
await ensure_scrape_jobs_table(conn)
|
await ensure_scrape_jobs_table(conn)
|
||||||
|
|
@ -2502,6 +2532,29 @@ async def get_facility(slug: str):
|
||||||
return format_row(row)
|
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")
|
@app.get("/api/admin/facilities")
|
||||||
async def get_admin_facilities():
|
async def get_admin_facilities():
|
||||||
"""Henter alle golfanlegg for admin, også upubliserte."""
|
"""Henter alle golfanlegg for admin, også upubliserte."""
|
||||||
|
|
@ -2584,6 +2637,52 @@ async def get_admin_facility(slug: str):
|
||||||
return format_row(row)
|
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")
|
@app.get("/api/course-visits")
|
||||||
async def get_course_visits():
|
async def get_course_visits():
|
||||||
"""Henter publiserte Banebesøk-artikler."""
|
"""Henter publiserte Banebesøk-artikler."""
|
||||||
|
|
|
||||||
|
|
@ -394,7 +394,9 @@ const matchesSpecialFilter = (specialFilter: string, flags: SpecialFlags) => {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const WEATHER_DAY_OPTIONS = [
|
const OSLO_TIME_ZONE = "Europe/Oslo";
|
||||||
|
|
||||||
|
const WEATHER_DAY_BASE_OPTIONS = [
|
||||||
{ value: "", label: "Alle dager" },
|
{ value: "", label: "Alle dager" },
|
||||||
{ value: "0", label: "I dag" },
|
{ value: "0", label: "I dag" },
|
||||||
{ value: "1", label: "I morgen" },
|
{ value: "1", label: "I morgen" },
|
||||||
|
|
@ -406,6 +408,75 @@ const WEATHER_DAY_OPTIONS = [
|
||||||
{ value: "7", label: "Om en uke" },
|
{ 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) =>
|
const getSearchShellClasses = (variant: Variant) =>
|
||||||
variant === "home"
|
variant === "home"
|
||||||
? "rounded-[2rem] bg-[#39443B] px-4 py-5 text-white shadow-2xl sm:px-6 sm:py-7"
|
? "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 [holeFilter, setHoleFilter] = useState("");
|
||||||
const [specialFilter, setSpecialFilter] = useState("");
|
const [specialFilter, setSpecialFilter] = useState("");
|
||||||
const [weatherDayFilter, setWeatherDayFilter] = useState("");
|
const [weatherDayFilter, setWeatherDayFilter] = useState("");
|
||||||
|
const [weatherDayOptions, setWeatherDayOptions] = useState(() => getWeatherDayOptions());
|
||||||
const [architectFilter, setArchitectFilter] = useState("");
|
const [architectFilter, setArchitectFilter] = useState("");
|
||||||
const [facilityFilter, setFacilityFilter] = useState("");
|
const [facilityFilter, setFacilityFilter] = useState("");
|
||||||
const [sortMethod, setSortMethod] = useState<SortMethod>("updated");
|
const [sortMethod, setSortMethod] = useState<SortMethod>("updated");
|
||||||
|
|
@ -448,6 +520,28 @@ export default function FacilitySearch({
|
||||||
setAreaFilter(fixedAreaFilter);
|
setAreaFilter(fixedAreaFilter);
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!("geolocation" in navigator)) return;
|
if (!("geolocation" in navigator)) return;
|
||||||
|
|
||||||
|
|
@ -791,7 +885,7 @@ export default function FacilitySearch({
|
||||||
onChange={setWeatherDayFilter}
|
onChange={setWeatherDayFilter}
|
||||||
labelClassName={labelClassName}
|
labelClassName={labelClassName}
|
||||||
>
|
>
|
||||||
{WEATHER_DAY_OPTIONS.map((option) => (
|
{weatherDayOptions.map((option) => (
|
||||||
<option key={option.value || "all"} value={option.value}>
|
<option key={option.value || "all"} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
|
|
|
||||||
|
|
@ -1308,6 +1308,9 @@ export default function AdminDashboard() {
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-[9px] font-bold uppercase tracking-widest text-gray-500">Innhold</div>
|
<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">
|
<Link href="/admin/artikler" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
|
||||||
Artikler
|
Artikler
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -1367,6 +1370,9 @@ export default function AdminDashboard() {
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 mt-6">
|
<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>
|
<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">
|
<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'}
|
{isSidebarCollapsed ? 'A' : 'Artikler'}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -1410,6 +1416,12 @@ export default function AdminDashboard() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/admin/steder"
|
||||||
|
className="btn btn-md btn-secondary"
|
||||||
|
>
|
||||||
|
Sted-sider
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/artikler"
|
href="/admin/artikler"
|
||||||
className="btn btn-md btn-secondary"
|
className="btn btn-md btn-secondary"
|
||||||
|
|
|
||||||
185
frontend/src/app/admin/steder/page.tsx
Normal file
185
frontend/src/app/admin/steder/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,57 @@ import { adminFetch } from "@/config/adminFetch";
|
||||||
import AdminMobileMenu from "@/components/AdminMobileMenu";
|
import AdminMobileMenu from "@/components/AdminMobileMenu";
|
||||||
import Link from 'next/link';
|
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() {
|
export default function VtgWasher() {
|
||||||
const [drafts, setDrafts] = useState<any[]>([]);
|
const [drafts, setDrafts] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -145,11 +196,28 @@ export default function VtgWasher() {
|
||||||
|
|
||||||
{drafts.map((draft, index) => (
|
{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]'}`}>
|
<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="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="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-grow space-y-4">
|
||||||
<div className="flex flex-col gap-3 border-b pb-4 md:flex-row md:items-center md:justify-between">
|
<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>
|
<a href={draft.vtg_lenke?.split(',')[0]} target="_blank" className="btn btn-md btn-secondary w-full md:w-auto">Sjekk Nettside ↗</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -159,48 +227,87 @@ export default function VtgWasher() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 gap-8 xl:grid-cols-2">
|
||||||
{/* Pris & Beskrivelse */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="text-xs font-black uppercase tracking-widest text-green-600">Pris & Beskrivelse</h4>
|
<h4 className="text-xs font-black uppercase tracking-widest text-green-600">Pris & Beskrivelse</h4>
|
||||||
<div>
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<label className="text-[10px] font-bold text-gray-500 uppercase">Standardpris for Voksen (kr)</label>
|
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4">
|
||||||
<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 className="mb-3 text-[10px] font-black uppercase tracking-widest text-gray-500">I dag</div>
|
||||||
</div>
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] font-bold text-gray-500 uppercase">Selgende tekst / Inkludert i kurset</label>
|
<div className="text-[10px] font-bold uppercase text-gray-500">Standardpris for voksen</div>
|
||||||
<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="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 på 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Kursdatoer */}
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-black uppercase tracking-widest text-green-600 mb-4">Kursdatoer</h4>
|
<h4 className="text-xs font-black uppercase tracking-widest text-green-600 mb-4">Kursdatoer</h4>
|
||||||
<div className="space-y-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
{draft.edit_datoer.length === 0 ? (
|
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4">
|
||||||
<div className="p-4 bg-gray-50 rounded-xl text-sm text-gray-500 italic">Fant ingen spesifikke kursdatoer.</div>
|
<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." />
|
||||||
draft.edit_datoer.map((row: any, idx: number) => (
|
</div>
|
||||||
<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">
|
<div className={`rounded-2xl border p-4 ${datesChanged ? 'border-green-200 bg-green-50/60' : 'border-gray-200 bg-white'}`}>
|
||||||
<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" />
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<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)}>
|
<div className="text-[10px] font-black uppercase tracking-widest text-gray-500">Forslag</div>
|
||||||
<option value="Ledig">Ledig</option>
|
<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'}`}>
|
||||||
<option value="Fulltegnet">Fulltegnet</option>
|
{datesChanged ? 'Har endringer' : 'Lik dagens datoer'}
|
||||||
<option value="Venteliste">Venteliste</option>
|
</span>
|
||||||
<option value="Få plasser">Få plasser</option>
|
</div>
|
||||||
</select>
|
<div className="space-y-2">
|
||||||
<button onClick={() => removeDateRow(draft.id, idx)} className="btn btn-sm btn-danger sm:opacity-0 sm:group-hover:opacity-100" title="Slett dato">✕</button>
|
{draft.edit_datoer.length === 0 ? (
|
||||||
</div>
|
<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) => (
|
||||||
<button onClick={() => addDateRow(draft.id)} className="btn btn-sm btn-secondary mt-2">
|
<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">
|
||||||
+ Legg til ny dato
|
<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" />
|
||||||
</button>
|
<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">Få 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -386,71 +386,72 @@ export default function FacilityDetailView({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SIDEBAR (22%) */}
|
{/* SIDEBAR (22%) */}
|
||||||
<div className="lg:w-[22%] bg-white p-10 md:rounded-[3rem] shadow-sm flex flex-col order-last lg:order-none">
|
<div className="order-last flex flex-col gap-6 lg:order-none lg:w-[22%]">
|
||||||
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-10">Kontakt & Adresse</h3>
|
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm flex flex-col">
|
||||||
<div className="flex-grow space-y-7 text-sm font-bold">
|
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-10">Kontakt & Adresse</h3>
|
||||||
{facility.website_url ? (
|
<div className="flex-grow space-y-7 text-sm font-bold">
|
||||||
<a href={facility.website_url} target="_blank" rel="noreferrer" className={sidebarLinkClass}>
|
{facility.website_url ? (
|
||||||
<Icon children={ICONS.web} /> <span className={sidebarLinkTextClass}>Besøk nettsiden</span>
|
<a href={facility.website_url} target="_blank" rel="noreferrer" className={sidebarLinkClass}>
|
||||||
</a>
|
<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 className="flex items-center gap-4 text-[#8A9488]">
|
||||||
</div>
|
<Icon children={ICONS.web} /> <span>Nettside ikke oppgitt</span>
|
||||||
)}
|
</div>
|
||||||
{facility.phone ? (
|
)}
|
||||||
<a href={`tel:${formatPhoneForUrl(facility.phone)}`} className={sidebarLinkClass}>
|
{facility.phone ? (
|
||||||
<Icon children={ICONS.phone} /> <span className={sidebarLinkTextClass}>{facility.phone}</span>
|
<a href={`tel:${formatPhoneForUrl(facility.phone)}`} className={sidebarLinkClass}>
|
||||||
</a>
|
<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 className="flex items-center gap-4 text-[#8A9488]">
|
||||||
</div>
|
<Icon children={ICONS.phone} /> <span>Telefon ikke oppgitt</span>
|
||||||
)}
|
</div>
|
||||||
{facility.email ? (
|
)}
|
||||||
<a href={`mailto:${facility.email}`} className={sidebarLinkClass}>
|
{facility.email ? (
|
||||||
<Icon children={ICONS.mail} /> <span className={`truncate ${sidebarLinkTextClass}`}>{facility.email}</span>
|
<a href={`mailto:${facility.email}`} className={sidebarLinkClass}>
|
||||||
</a>
|
<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 className="flex items-center gap-4 text-[#8A9488]">
|
||||||
</div>
|
<Icon children={ICONS.mail} /> <span>E-post ikke oppgitt</span>
|
||||||
)}
|
</div>
|
||||||
<div className="pt-2 border-t border-gray-50 mt-4">
|
)}
|
||||||
|
<div className="pt-2 border-t border-gray-50 mt-4">
|
||||||
{mapUrl ? (
|
{mapUrl ? (
|
||||||
<a href={mapUrl} target="_blank" rel="noreferrer" className={sidebarLinkClass + " pt-4 leading-tight items-start"}>
|
<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>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-start gap-4 pt-4 text-[#8A9488] leading-tight">
|
<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>
|
<Icon children={ICONS.pin} /> <span>{facility.address || "Adresse ikke oppgitt"}{facility.city ? <><br />{facility.city}</> : null}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</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">
|
{socialLinks.length > 0 && (
|
||||||
<Link href={`/sted/${placeSlug}`} className={`${detailMetaLinkClass} flex items-center gap-1`}>
|
<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} →
|
Se alle baner i {facility.county} →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 4. 3-KOLONNE INFO */}
|
{/* 4. 3-KOLONNE INFO */}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,75 @@ import {
|
||||||
createPageMetadata,
|
createPageMetadata,
|
||||||
} from "@/app/seo";
|
} from "@/app/seo";
|
||||||
|
|
||||||
|
type PlacePageData = {
|
||||||
|
slug?: string;
|
||||||
|
factbox_intro_html?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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 dynamicParams = true;
|
||||||
export const revalidate = 3600;
|
export const revalidate = 3600;
|
||||||
|
|
||||||
|
|
@ -56,6 +125,7 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
|
||||||
}
|
}
|
||||||
|
|
||||||
let facilities: FacilityRecord[] = [];
|
let facilities: FacilityRecord[] = [];
|
||||||
|
let placePage: PlacePageData | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/facilities?summary=1`, {
|
const res = await fetch(`${API_URL}/facilities?summary=1`, {
|
||||||
|
|
@ -72,6 +142,21 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
|
||||||
facilities = [];
|
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 safeData = Array.isArray(facilities) ? facilities : [];
|
||||||
const enrichedFacilities = enrichFacilities(safeData);
|
const enrichedFacilities = enrichFacilities(safeData);
|
||||||
const facilitiesInPlace = filterFacilitiesByArea(enrichedFacilities, place.areaFilter);
|
const facilitiesInPlace = filterFacilitiesByArea(enrichedFacilities, place.areaFilter);
|
||||||
|
|
@ -149,6 +234,17 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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="px-5 py-6 sm:px-6 lg:px-8 lg:py-8">
|
||||||
<div className="max-w-5xl">
|
<div className="max-w-5xl">
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const NAV_ITEMS = [
|
||||||
{ href: '/admin/greenfee', label: 'Greenfee', match: (pathname: string) => pathname.startsWith('/admin/greenfee') },
|
{ 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/golfpakker', label: 'Golfpakker', match: (pathname: string) => pathname.startsWith('/admin/golfpakker') },
|
||||||
{ href: '/admin/vtg', label: 'VTG', match: (pathname: string) => pathname.startsWith('/admin/vtg') },
|
{ 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) {
|
export default function AdminMobileMenu({ onOpenTwoFactor }: AdminMobileMenuProps) {
|
||||||
|
|
|
||||||
6
migrations/2026-04-22_create_place_pages.sql
Normal file
6
migrations/2026-04-22_create_place_pages.sql
Normal 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()
|
||||||
|
);
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
DROP TABLE IF EXISTS place_pages CASCADE;
|
||||||
-- Slett gamle tabeller slik at vi starter med helt blanke ark
|
-- Slett gamle tabeller slik at vi starter med helt blanke ark
|
||||||
DROP TABLE IF EXISTS hole_lengths CASCADE;
|
DROP TABLE IF EXISTS hole_lengths CASCADE;
|
||||||
DROP TABLE IF EXISTS holes CASCADE;
|
DROP TABLE IF EXISTS holes CASCADE;
|
||||||
|
|
@ -33,6 +34,13 @@ CREATE TABLE facilities (
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
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)
|
-- 2. Tabellen for BANER (Tilhører et anlegg)
|
||||||
CREATE TABLE courses (
|
CREATE TABLE courses (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue