Før generering av sidemal.

This commit is contained in:
Erol Haagenrud 2026-04-28 13:53:00 +02:00
parent 228aa3590c
commit 3375535366
23 changed files with 798 additions and 115 deletions

BIN
2026-04-28_135017.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View file

@ -50,7 +50,7 @@ from course_status_history import (
log_course_status_change, log_course_status_change,
) )
from env_config import get_database_url, get_required_env from env_config import get_database_url, get_required_env
from vtg_courses import filter_upcoming_courses, normalize_vtg_course_rows from vtg_courses import filter_upcoming_courses, get_invalid_vtg_course_labels, normalize_vtg_course_rows
from weather_forecast import ensure_weather_forecast_table, weather_sync_loop from weather_forecast import ensure_weather_forecast_table, weather_sync_loop
# --- KONFIGURASJON --- # --- KONFIGURASJON ---
@ -303,6 +303,15 @@ def collect_facility_indexnow_urls(slugs: list[str], extra_paths: list[str] | No
return dedupe_strings(urls) return dedupe_strings(urls)
def collect_page_indexnow_urls(paths: list[str]) -> list[str]:
urls: list[str] = []
for path in paths:
public_url = build_absolute_public_url(path)
if public_url:
urls.append(public_url)
return dedupe_strings(urls)
def get_indexnow_key_location() -> str | None: def get_indexnow_key_location() -> str | None:
configured = INDEXNOW_KEY_LOCATION.strip() configured = INDEXNOW_KEY_LOCATION.strip()
if configured: if configured:
@ -760,6 +769,8 @@ class ArticleUpsertRequest(BaseModel):
section: Optional[str] = "banebesok" section: Optional[str] = "banebesok"
slug: str slug: str
title: str title: str
meta_title: Optional[str] = None
meta_description: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
excerpt: Optional[str] = None excerpt: Optional[str] = None
eyebrow: Optional[str] = None eyebrow: Optional[str] = None
@ -1044,6 +1055,28 @@ async def replace_facility_vtg_courses(conn, facility_id: int, rows: Any) -> lis
return normalized_rows return normalized_rows
def ensure_valid_vtg_course_rows(approvals: list[Any]) -> None:
invalid_entries: list[str] = []
for approval in approvals:
invalid_labels = get_invalid_vtg_course_labels(getattr(approval, "vtg_datoer", None))
if invalid_labels:
preview = ", ".join(invalid_labels[:3])
if len(invalid_labels) > 3:
preview = f"{preview}, +{len(invalid_labels) - 3} til"
invalid_entries.append(
f"anlegg {getattr(approval, 'facility_id', 'ukjent')}: {preview}"
)
if invalid_entries:
raise HTTPException(
status_code=400,
detail=(
"Kun kurs med gyldige datoer kan godkjennes. Ugyldige kursrader funnet for "
+ "; ".join(invalid_entries)
),
)
def format_place_page_row(row): def format_place_page_row(row):
if row is None: if row is None:
return None return None
@ -2461,6 +2494,8 @@ async def ensure_articles_table(conn):
section VARCHAR(32) NOT NULL DEFAULT 'banebesok', section VARCHAR(32) NOT NULL DEFAULT 'banebesok',
slug VARCHAR(255) UNIQUE NOT NULL, slug VARCHAR(255) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
meta_title TEXT,
meta_description TEXT,
description TEXT, description TEXT,
excerpt TEXT, excerpt TEXT,
eyebrow VARCHAR(120) DEFAULT 'Banebesøk', eyebrow VARCHAR(120) DEFAULT 'Banebesøk',
@ -2492,6 +2527,14 @@ async def ensure_articles_table(conn):
ALTER TABLE articles ALTER TABLE articles
ADD COLUMN IF NOT EXISTS featured_media_id VARCHAR(255) ADD COLUMN IF NOT EXISTS featured_media_id VARCHAR(255)
""") """)
await conn.execute("""
ALTER TABLE articles
ADD COLUMN IF NOT EXISTS meta_title TEXT
""")
await conn.execute("""
ALTER TABLE articles
ADD COLUMN IF NOT EXISTS meta_description TEXT
""")
await conn.execute(""" await conn.execute("""
UPDATE articles UPDATE articles
SET section = 'banebesok' SET section = 'banebesok'
@ -3858,7 +3901,7 @@ async def get_place_page(slug: str, response: Response):
return payload return payload
VALID_SITE_PAGE_SEO_KEYS = {"golfbaner", "vtg", "medlemskap", "simulatorer"} VALID_SITE_PAGE_SEO_KEYS = {"golfbaner", "vtg", "medlemskap", "banebesok", "meninger", "simulatorer"}
@app.get("/api/page-seo/{page_key}") @app.get("/api/page-seo/{page_key}")
@ -4082,6 +4125,10 @@ async def update_admin_place_page(slug: str, request: PlacePageUpsertRequest):
) )
invalidate_public_api_caches(include_place_pages=True) invalidate_public_api_caches(include_place_pages=True)
schedule_indexnow_submission(
collect_page_indexnow_urls([f"/sted/{normalized_slug}"]),
reason="admin place page upsert",
)
return format_place_page_row(row) return format_place_page_row(row)
@ -4131,6 +4178,18 @@ async def update_admin_site_page_seo(page_key: str, request: SitePageSeoUpsertRe
normalize_optional_text(request.meta_description), normalize_optional_text(request.meta_description),
) )
page_path_map = {
"golfbaner": "/golfbaner",
"vtg": "/vtg",
"medlemskap": "/medlemskap",
"banebesok": "/banebesok",
"meninger": "/meninger",
"simulatorer": "/simulatorer",
}
schedule_indexnow_submission(
collect_page_indexnow_urls([page_path_map[normalized_key]]),
reason="admin site page seo upsert",
)
return format_site_page_seo_row(row) return format_site_page_seo_row(row)
@ -4577,17 +4636,19 @@ async def upsert_admin_article(request: ArticleUpsertRequest):
) )
row = await conn.fetchrow(""" row = await conn.fetchrow("""
INSERT INTO articles ( INSERT INTO articles (
section, slug, title, description, excerpt, eyebrow, location_label, section, slug, title, meta_title, meta_description, description, excerpt, eyebrow, location_label,
facility_name, facility_slug, author_name, status, hero_images, facility_name, facility_slug, author_name, status, hero_images,
media_gallery, featured_media_id, content_html, source_url, source_label, published_at, updated_at media_gallery, featured_media_id, content_html, source_url, source_label, published_at, updated_at
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $7, $1, $2, $3, $4, $5, $6, $7, $8, $9,
$8, $9, $10, $11, $12::jsonb, $10, $11, $12, $13, $14::jsonb,
$13::jsonb, $14, $15, $16, $17, $18, $19 $15::jsonb, $16, $17, $18, $19, $20, $21
) )
ON CONFLICT (slug) DO UPDATE SET ON CONFLICT (slug) DO UPDATE SET
section = EXCLUDED.section, section = EXCLUDED.section,
title = EXCLUDED.title, title = EXCLUDED.title,
meta_title = EXCLUDED.meta_title,
meta_description = EXCLUDED.meta_description,
description = EXCLUDED.description, description = EXCLUDED.description,
excerpt = EXCLUDED.excerpt, excerpt = EXCLUDED.excerpt,
eyebrow = EXCLUDED.eyebrow, eyebrow = EXCLUDED.eyebrow,
@ -4609,6 +4670,8 @@ async def upsert_admin_article(request: ArticleUpsertRequest):
section, section,
requested_slug, requested_slug,
request.title.strip(), request.title.strip(),
(request.meta_title or "").strip() or None,
(request.meta_description or "").strip() or None,
(request.description or "").strip() or None, (request.description or "").strip() or None,
(request.excerpt or "").strip() or None, (request.excerpt or "").strip() or None,
(request.eyebrow or "Banebesøk").strip(), (request.eyebrow or "Banebesøk").strip(),
@ -5077,6 +5140,7 @@ async def approve_vtg_content_bulk(request: BulkVtgContentRequest):
@app.post("/api/admin/vtg/approve-courses-bulk") @app.post("/api/admin/vtg/approve-courses-bulk")
async def approve_vtg_courses_bulk(request: BulkVtgCoursesRequest): async def approve_vtg_courses_bulk(request: BulkVtgCoursesRequest):
ensure_valid_vtg_course_rows(request.approvals)
facility_ids = [approval.facility_id for approval in request.approvals] facility_ids = [approval.facility_id for approval in request.approvals]
async with app.state.pool.acquire() as conn: async with app.state.pool.acquire() as conn:
async with conn.transaction(): async with conn.transaction():
@ -5108,6 +5172,7 @@ async def approve_vtg_courses_bulk(request: BulkVtgCoursesRequest):
@app.post("/api/admin/vtg/approve-bulk") @app.post("/api/admin/vtg/approve-bulk")
async def approve_vtg_bulk(request: BulkVtgRequest): async def approve_vtg_bulk(request: BulkVtgRequest):
"""Kompatibilitets-endepunkt som godkjenner både innhold og kurs.""" """Kompatibilitets-endepunkt som godkjenner både innhold og kurs."""
ensure_valid_vtg_course_rows(request.approvals)
facility_ids = [approval.facility_id for approval in request.approvals] facility_ids = [approval.facility_id for approval in request.approvals]
async with app.state.pool.acquire() as conn: async with app.state.pool.acquire() as conn:
async with conn.transaction(): async with conn.transaction():

View file

@ -200,3 +200,41 @@ def is_upcoming_course(row: dict[str, Any], today: date | None = None) -> bool:
def filter_upcoming_courses(rows: Any) -> list[dict[str, Any]]: def filter_upcoming_courses(rows: Any) -> list[dict[str, Any]]:
normalized_rows = normalize_vtg_course_rows(rows) normalized_rows = normalize_vtg_course_rows(rows)
return [row for row in normalized_rows if is_upcoming_course(row)] return [row for row in normalized_rows if is_upcoming_course(row)]
def get_invalid_vtg_course_labels(rows: Any) -> list[str]:
if not isinstance(rows, list):
return []
invalid_labels: list[str] = []
for row in rows:
if not isinstance(row, dict):
continue
display_label = normalize_whitespace(str(row.get("dato") or row.get("display_label") or ""))
if not display_label:
continue
explicit_start = row.get("start_date")
explicit_end = row.get("end_date")
start_date = None
end_date = None
if explicit_start:
try:
start_date = datetime.fromisoformat(str(explicit_start)).date()
except ValueError:
start_date = None
if explicit_end:
try:
end_date = datetime.fromisoformat(str(explicit_end)).date()
except ValueError:
end_date = None
if not start_date and not end_date:
start_date, end_date = parse_course_date_range(display_label)
if not start_date and not end_date:
invalid_labels.append(display_label)
return invalid_labels

View file

@ -4,6 +4,7 @@ import Link from "next/link";
import { type ChangeEvent, useEffect, useRef, useState } from "react"; import { type ChangeEvent, useEffect, useRef, useState } from "react";
import { API_URL } from "@/config/constants"; import { API_URL } from "@/config/constants";
import { adminFetch } from "@/config/adminFetch"; import { adminFetch } from "@/config/adminFetch";
import SeoFieldset, { trimSuggestion } from "@/components/admin/SeoFieldset";
import TiptapHtmlEditor from "@/components/TiptapHtmlEditor"; import TiptapHtmlEditor from "@/components/TiptapHtmlEditor";
type ArticleMediaItem = { type ArticleMediaItem = {
@ -20,6 +21,8 @@ type AdminArticle = {
section?: "banebesok" | "meninger"; section?: "banebesok" | "meninger";
slug: string; slug: string;
title: string; title: string;
meta_title?: string | null;
meta_description?: string | null;
description?: string | null; description?: string | null;
excerpt?: string | null; excerpt?: string | null;
eyebrow?: string | null; eyebrow?: string | null;
@ -52,6 +55,8 @@ type ArticleFormState = {
section: "banebesok" | "meninger"; section: "banebesok" | "meninger";
slug: string; slug: string;
title: string; title: string;
meta_title: string;
meta_description: string;
description: string; description: string;
excerpt: string; excerpt: string;
eyebrow: string; eyebrow: string;
@ -114,6 +119,8 @@ function createEmptyForm(): ArticleFormState {
section: "banebesok", section: "banebesok",
slug: "", slug: "",
title: "", title: "",
meta_title: "",
meta_description: "",
description: "", description: "",
excerpt: "", excerpt: "",
eyebrow: "Banebesøk", eyebrow: "Banebesøk",
@ -144,6 +151,8 @@ function articleToForm(article: AdminArticle): ArticleFormState {
section: article.section || "banebesok", section: article.section || "banebesok",
slug: article.slug || "", slug: article.slug || "",
title: article.title || "", title: article.title || "",
meta_title: article.meta_title || "",
meta_description: article.meta_description || "",
description: article.description || "", description: article.description || "",
excerpt: article.excerpt || "", excerpt: article.excerpt || "",
eyebrow: article.eyebrow || "Banebesøk", eyebrow: article.eyebrow || "Banebesøk",
@ -182,6 +191,8 @@ function buildArticlePayload(
section: form.section, section: form.section,
slug: form.slug.trim(), slug: form.slug.trim(),
title: form.title.trim(), title: form.title.trim(),
meta_title: form.meta_title.trim(),
meta_description: form.meta_description.trim(),
description: form.description.trim(), description: form.description.trim(),
excerpt: form.excerpt.trim(), excerpt: form.excerpt.trim(),
eyebrow: form.eyebrow.trim(), eyebrow: form.eyebrow.trim(),
@ -222,6 +233,14 @@ export default function AdminArticlesPage() {
const heroImageInputRef = useRef<HTMLInputElement | null>(null); const heroImageInputRef = useRef<HTMLInputElement | null>(null);
const formSectionRef = useRef<HTMLElement | null>(null); const formSectionRef = useRef<HTMLElement | null>(null);
const titleInputRef = useRef<HTMLInputElement | null>(null); const titleInputRef = useRef<HTMLInputElement | null>(null);
const articleSeoTitleSuggestion = trimSuggestion(
form.title.trim() ? `${form.title.trim()} | TeeOff.no` : "",
60,
);
const articleSeoDescriptionSuggestion = trimSuggestion(
form.description.trim() || form.excerpt.trim() || form.content_html,
160,
);
const focusEditor = () => { const focusEditor = () => {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
@ -797,17 +816,28 @@ export default function AdminArticlesPage() {
</div> </div>
<div className="mt-5 grid gap-5"> <div className="mt-5 grid gap-5">
<SeoFieldset
titleValue={form.meta_title}
onTitleChange={(value) => handleFieldChange("meta_title", value)}
descriptionValue={form.meta_description}
onDescriptionChange={(value) => handleFieldChange("meta_description", value)}
suggestedTitle={articleSeoTitleSuggestion}
suggestedDescription={articleSeoDescriptionSuggestion}
titlePlaceholder="Egen meta title for søkeresultater"
descriptionPlaceholder="Egen meta description for søkeresultater"
helperText="Hvis disse feltene er tomme, faller artikkelen tilbake til vanlig tittel og kort beskrivelse."
/>
<label className="flex flex-col gap-2"> <label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Meta-beskrivelse</span> <span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Kort beskrivelse</span>
<textarea <textarea
rows={3} rows={3}
value={form.description} value={form.description}
onChange={(event) => handleFieldChange("description", event.target.value)} onChange={(event) => handleFieldChange("description", event.target.value)}
placeholder="Kort SEO-/delingsbeskrivelse av artikkelen" placeholder="Kort beskrivelse av artikkelen som brukes som fallback i metadata og strukturerte data"
className="rounded-[1.3rem] border border-[#112015]/10 bg-white px-4 py-3 text-base text-[#112015] outline-none focus:border-[#8BC34A]" className="rounded-[1.3rem] border border-[#112015]/10 bg-white px-4 py-3 text-base text-[#112015] outline-none focus:border-[#8BC34A]"
/> />
<p className="text-sm leading-6 text-[#536256]"> <p className="text-sm leading-6 text-[#536256]">
Brukes primært som beskrivelse i metadata, delinger og søk. Brukes som fallback hvis egen meta description ikke er satt.
</p> </p>
</label> </label>
<label className="flex flex-col gap-2"> <label className="flex flex-col gap-2">

View file

@ -782,13 +782,12 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
}; };
const seoFacilityName = String(formData.name || "").trim(); const seoFacilityName = String(formData.name || "").trim();
const facilityCity = String(formData.city || "").trim();
const facilitySuggestedTitle = seoFacilityName const facilitySuggestedTitle = seoFacilityName
? `${seoFacilityName}: banestatus, greenfee og info` ? `${seoFacilityName}: Banestatus, greenfee og baneguide | TeeOff.no`
: ""; : "";
const facilitySuggestedDescription = seoFacilityName const facilitySuggestedDescription = seoFacilityName
? trimSuggestion( ? trimSuggestion(
`Se banestatus, greenfee, kontaktinfo, kart og praktisk informasjon for ${seoFacilityName}${facilityCity ? ` i ${facilityCity}` : ""} på TeeOff.no.`, `Er ${seoFacilityName} åpen? Se oppdatert banestatus, priser, vær og kart. Planlegg runden din med bilder og praktisk info på TeeOff.no!`,
160, 160,
) )
: ""; : "";

View file

@ -5,13 +5,20 @@ import Link from "next/link";
import AdminMobileMenu from "@/components/AdminMobileMenu"; import AdminMobileMenu from "@/components/AdminMobileMenu";
import TiptapHtmlEditor from "@/components/TiptapHtmlEditor"; import TiptapHtmlEditor from "@/components/TiptapHtmlEditor";
import { import {
enrichFacilities,
filterFacilitiesByArea,
HIERARCHICAL_AREA_OPTIONS, HIERARCHICAL_AREA_OPTIONS,
getPlaceConfigFromSlug, getPlaceConfigFromSlug,
getPlacePreposition, getPlacePreposition,
type FacilityRecord,
} from "@/app/facilityData"; } from "@/app/facilityData";
import { adminFetch } from "@/config/adminFetch"; import { adminFetch } from "@/config/adminFetch";
import { API_URL } from "@/config/constants"; import { API_URL } from "@/config/constants";
import SeoFieldset, { trimSuggestion } from "@/components/admin/SeoFieldset"; import SeoFieldset, { trimSuggestion } from "@/components/admin/SeoFieldset";
import {
buildDefaultPlaceMetaDescription,
buildDefaultPlaceMetaTitle,
} from "@/app/placeSeo";
type PlacePageResponse = { type PlacePageResponse = {
slug: string; slug: string;
@ -34,6 +41,8 @@ const SITE_PAGE_OPTIONS = [
{ key: "golfbaner", label: "/golfbaner" }, { key: "golfbaner", label: "/golfbaner" },
{ key: "vtg", label: "/vtg" }, { key: "vtg", label: "/vtg" },
{ key: "medlemskap", label: "/medlemskap" }, { key: "medlemskap", label: "/medlemskap" },
{ key: "banebesok", label: "/banebesok" },
{ key: "meninger", label: "/meninger" },
{ key: "simulatorer", label: "/simulatorer (fremtidig)" }, { key: "simulatorer", label: "/simulatorer (fremtidig)" },
]; ];
@ -42,19 +51,29 @@ const SITE_PAGE_SEO_SUGGESTIONS: Record<
{ title: string; description: string } { title: string; description: string }
> = { > = {
golfbaner: { golfbaner: {
title: "Golfbaner i Norge", title: "Alle norske golfbaner: Finn din neste runde på gress | TeeOff.no",
description: description:
"Finn golfbaner i Norge og filtrer på område, banestatus, antall hull og fasiliteter i TeeOffs samlede oversikt.", "Planlegg din neste golfrunde. Se komplett oversikt over alle norske golfbaner med oppdatert banestatus, greenfee-priser og kart på TeeOff.no.",
}, },
vtg: { vtg: {
title: "Veien til Golf", title: "Veien til Golf: Finn golfkurs for nybegynnere (VTG) | TeeOff.no",
description: description:
"Finn Veien til Golf-kurs etter område, klubb og neste kursdato i TeeOffs VTG-oversikt.", "Vil du begynne med golf? Finn komplett oversikt over Veien til Golf-kurs (VTG) i hele Norge. Se kursdatoer, priser og finn din nærmeste klubb på TeeOff.no!",
}, },
medlemskap: { medlemskap: {
title: "Medlemskap i norske golfklubber", title: "Billig golfmedlemskap? Finn og sammenlign priser på alle klubber | TeeOff.no",
description: description:
"Sammenlign priser på medlemskap i norske golfklubber, både full spillerett og rimeligste nasjonale alternativ.", "Hvor er det billigst å være medlem? Sammenlign priser på golfmedlemskap med full spillerett eller rimelige nasjonale alternativ (fjernmedlemskap) i Norge på TeeOff.no.",
},
banebesok: {
title: "Golfreiser og banebesøk: Erfaringer fra norske golfbaner | TeeOff.no",
description:
"Bli med på tur! Vi besøker Norges vakreste golfbaner og deler ærlige reiseskildringer, spektakulære bilder og nyttige tips. Finn inspirasjon til din neste golfreise her.",
},
meninger: {
title: "Golfblogg: Meninger, humor og skråblikk på golf-Norge | TeeOff.no",
description:
"Fra frustrasjon over saktespill til gleden over en perfekt drive. Les TeeOffs egne artikler, kommentarer og ærlige skråblikk på livet som golfer i Norge.",
}, },
simulatorer: { simulatorer: {
title: "Golfsimulatorer i Norge", title: "Golfsimulatorer i Norge",
@ -85,6 +104,7 @@ export default function AdminPlacePagesPage() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [feedback, setFeedback] = useState(""); const [feedback, setFeedback] = useState("");
const [placeFacilities, setPlaceFacilities] = useState<FacilityRecord[]>([]);
const [selectedPageKey, setSelectedPageKey] = useState(DEFAULT_PAGE_KEY); const [selectedPageKey, setSelectedPageKey] = useState(DEFAULT_PAGE_KEY);
const [pageMetaTitle, setPageMetaTitle] = useState(""); const [pageMetaTitle, setPageMetaTitle] = useState("");
const [pageMetaDescription, setPageMetaDescription] = useState(""); const [pageMetaDescription, setPageMetaDescription] = useState("");
@ -95,18 +115,60 @@ export default function AdminPlacePagesPage() {
const selectedPlace = getPlaceConfigFromSlug(selectedSlug); const selectedPlace = getPlaceConfigFromSlug(selectedSlug);
const selectedPlacePreposition = selectedPlace ? getPlacePreposition(selectedPlace.label) : "i"; const selectedPlacePreposition = selectedPlace ? getPlacePreposition(selectedPlace.label) : "i";
const placeSuggestedTitle = selectedPlace ? `${selectedPlace.title}: golfbaner og banestatus` : ""; const enrichedPlaceFacilities = enrichFacilities(Array.isArray(placeFacilities) ? placeFacilities : []);
const filteredPlaceFacilities = selectedPlace
? filterFacilitiesByArea(enrichedPlaceFacilities, selectedPlace.areaFilter)
: [];
const placeSuggestedTitle = selectedPlace
? buildDefaultPlaceMetaTitle(
filteredPlaceFacilities.length,
selectedPlace.label,
selectedPlacePreposition,
)
: "";
const placeSuggestedDescription = selectedPlace const placeSuggestedDescription = selectedPlace
? trimSuggestion( ? trimSuggestion(
`Finn golfbaner ${selectedPlacePreposition} ${selectedPlace.label} med oppdatert banestatus, priser og baneprofiler på TeeOff.no.`, buildDefaultPlaceMetaDescription(selectedPlace.label, selectedPlacePreposition),
160, 160,
) )
: ""; : "";
const effectivePlaceMetaTitle = metaTitle.trim() || placeSuggestedTitle;
const effectivePlaceMetaDescription = metaDescription.trim() || placeSuggestedDescription;
const sitePageSuggestion = SITE_PAGE_SEO_SUGGESTIONS[selectedPageKey] || { const sitePageSuggestion = SITE_PAGE_SEO_SUGGESTIONS[selectedPageKey] || {
title: "", title: "",
description: "", description: "",
}; };
useEffect(() => {
const controller = new AbortController();
const loadPlaceFacilities = async () => {
try {
const response = await fetch(`${API_URL}/facilities?view=place`, {
cache: "no-store",
signal: controller.signal,
});
if (!response.ok) {
throw new Error("Kunne ikke hente anleggslisten for sted-SEO.");
}
const data = (await response.json()) as FacilityRecord[];
if (!controller.signal.aborted) {
setPlaceFacilities(Array.isArray(data) ? data : []);
}
} catch {
if (!controller.signal.aborted) {
setPlaceFacilities([]);
}
}
};
loadPlaceFacilities();
return () => controller.abort();
}, []);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@ -300,6 +362,25 @@ export default function AdminPlacePagesPage() {
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Aktiv side</p> <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-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-2 text-sm leading-6 text-[#536256]">{selectedPlace?.intro || "Ingen intro tilgjengelig."}</p>
<div className="mt-4 space-y-3 rounded-2xl border border-[#112015]/8 bg-[#f7faf4] p-4">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Gjeldende meta title</p>
<p className="mt-1 text-sm font-semibold leading-6 text-[#112015]">
{effectivePlaceMetaTitle || "Ikke tilgjengelig"}
</p>
</div>
<div>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Gjeldende meta description</p>
<p className="mt-1 text-sm leading-6 text-[#536256]">
{effectivePlaceMetaDescription || "Ikke tilgjengelig"}
</p>
</div>
<p className="text-[11px] font-bold text-[#6A766C]">
{metaTitle.trim() || metaDescription.trim()
? "Viser manuell overstyring der felt er fylt ut, ellers standardverdien."
: "Viser standardverdien som brukes når feltene står tomme."}
</p>
</div>
<p className="mt-4 text-xs font-bold uppercase tracking-widest text-gray-500"> <p className="mt-4 text-xs font-bold uppercase tracking-widest text-gray-500">
Sist lagret: {formatDateTime(updatedAt)} Sist lagret: {formatDateTime(updatedAt)}
</p> </p>
@ -342,7 +423,7 @@ export default function AdminPlacePagesPage() {
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between"> <div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div> <div>
<p className="text-xs font-black uppercase tracking-[0.2em] text-[#7ca982]">Samlesider</p> <p className="text-xs font-black uppercase tracking-[0.2em] text-[#7ca982]">Samlesider</p>
<h2 className="mt-2 text-3xl font-black tracking-tight text-[#11280f]">SEO for /golfbaner, /vtg, /medlemskap og /simulatorer</h2> <h2 className="mt-2 text-3xl font-black tracking-tight text-[#11280f]">SEO for samlesider</h2>
<p className="mt-3 max-w-3xl text-sm leading-6 text-[#536256]"> <p className="mt-3 max-w-3xl text-sm leading-6 text-[#536256]">
Her kan du overstyre meta title og meta description de store landingssidene uten å endre H1 eller innholdstekst. Her kan du overstyre meta title og meta description de store landingssidene uten å endre H1 eller innholdstekst.
</p> </p>

View file

@ -12,6 +12,33 @@ type VtgDateRow = {
end_date?: string | null; end_date?: string | null;
}; };
const monthMap: Record<string, number> = {
januar: 0,
jan: 0,
februar: 1,
feb: 1,
mars: 2,
mar: 2,
april: 3,
apr: 3,
mai: 4,
juni: 5,
jun: 5,
juli: 6,
jul: 6,
august: 7,
aug: 7,
september: 8,
sep: 8,
sept: 8,
oktober: 9,
okt: 9,
november: 10,
nov: 10,
desember: 11,
des: 11,
};
const normalizeDateRows = (value: any): VtgDateRow[] => { const normalizeDateRows = (value: any): VtgDateRow[] => {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
return value.map((row) => ({ return value.map((row) => ({
@ -26,6 +53,68 @@ const datesAreEqual = (left: any, right: any) => (
JSON.stringify(normalizeDateRows(left)) === JSON.stringify(normalizeDateRows(right)) JSON.stringify(normalizeDateRows(left)) === JSON.stringify(normalizeDateRows(right))
); );
const normalizeWhitespace = (value: string) => value.replace(/\s+/g, ' ').trim();
const parseComparableDate = (raw: string) => {
const trimmed = normalizeWhitespace(raw);
if (!trimmed) return null;
const isoCandidate = new Date(trimmed);
if (!Number.isNaN(isoCandidate.getTime())) {
isoCandidate.setHours(0, 0, 0, 0);
return isoCandidate;
}
const numericDateMatch = trimmed.match(/(\d{1,2})[./](\d{1,2})[./](\d{2,4})/);
if (numericDateMatch) {
const day = Number(numericDateMatch[1]);
const month = Number(numericDateMatch[2]) - 1;
const yearValue = Number(numericDateMatch[3]);
const year = yearValue < 100 ? 2000 + yearValue : yearValue;
const parsed = new Date(year, month, day);
parsed.setHours(0, 0, 0, 0);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
const normalized = trimmed
.toLowerCase()
.replace(/[.,]/g, ' ')
.replace(/\s+/g, ' ');
const monthToken = Object.keys(monthMap).find((monthName) => normalized.includes(monthName));
if (!monthToken) return null;
const monthIndex = monthMap[monthToken];
const rangeMatch = normalized.match(/(\d{1,2})\s*(?:-||—|til)\s*(\d{1,2})/);
const dayMatch = normalized.match(/(\d{1,2})/);
if (!dayMatch) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const explicitYearMatch = normalized.match(/\b(20\d{2})\b/);
let year = explicitYearMatch ? Number(explicitYearMatch[1]) : today.getFullYear();
const day = rangeMatch ? Number(rangeMatch[2]) : Number(dayMatch[1]);
let parsed = new Date(year, monthIndex, day);
parsed.setHours(0, 0, 0, 0);
if (!explicitYearMatch && parsed.getTime() < today.getTime() - 7 * 24 * 60 * 60 * 1000) {
year += 1;
parsed = new Date(year, monthIndex, day);
parsed.setHours(0, 0, 0, 0);
}
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const isValidDateRow = (row: VtgDateRow) => {
const value = normalizeWhitespace(String(row?.dato || ''));
if (!value) return true;
return Boolean(parseComparableDate(value));
};
const getInvalidDateRows = (rows: VtgDateRow[]) => rows.filter((row) => !isValidDateRow(row));
const textValue = (value: any) => ( const textValue = (value: any) => (
typeof value === 'string' ? value.trim() : '' typeof value === 'string' ? value.trim() : ''
); );
@ -52,6 +141,7 @@ const hasContentDraftChanges = (draft: any) => (
const hasCourseDraftChanges = (draft: any) => ( const hasCourseDraftChanges = (draft: any) => (
hasCourseDraftRecord(draft) && !datesAreEqual(draft?.vtg_datoer, draft?.edit_datoer) hasCourseDraftRecord(draft) && !datesAreEqual(draft?.vtg_datoer, draft?.edit_datoer)
); );
const hasInvalidCourseDraftRows = (draft: any) => getInvalidDateRows(normalizeDateRows(draft?.edit_datoer)).length > 0;
function ReadOnlyDateList({ dates, emptyLabel }: { dates: any; emptyLabel: string }) { function ReadOnlyDateList({ dates, emptyLabel }: { dates: any; emptyLabel: string }) {
const normalizedDates = normalizeDateRows(dates); const normalizedDates = normalizeDateRows(dates);
@ -195,6 +285,22 @@ export default function VtgWasher() {
}; };
const handleApproveCourses = async () => { const handleApproveCourses = async () => {
const invalidDrafts = drafts.filter(
d => selectedIds.includes(d.id) && hasCourseDraftChanges(d) && hasInvalidCourseDraftRows(d)
);
if (invalidDrafts.length > 0) {
const labels = invalidDrafts
.map((draft) => {
const invalidRows = getInvalidDateRows(normalizeDateRows(draft.edit_datoer))
.map((row) => row.dato)
.filter(Boolean)
.join(', ');
return `${draft.name}: ${invalidRows}`;
})
.join('\n');
return alert(`Kun kurs med gyldige datoer kan godkjennes.\n\nRett eller slett disse radene først:\n${labels}`);
}
const toApprove = drafts.filter(d => selectedIds.includes(d.id) && hasCourseDraftChanges(d)).map(d => ({ const toApprove = drafts.filter(d => selectedIds.includes(d.id) && hasCourseDraftChanges(d)).map(d => ({
facility_id: d.id, facility_id: d.id,
vtg_datoer: d.edit_datoer vtg_datoer: d.edit_datoer
@ -214,7 +320,8 @@ export default function VtgWasher() {
setSelectedIds([]); setSelectedIds([]);
fetchDrafts(); fetchDrafts();
} else { } else {
alert("Noe gikk galt under lagring."); const error = await res.json().catch(() => ({ detail: "Noe gikk galt under lagring." }));
alert(error.detail || "Noe gikk galt under lagring.");
} }
} catch (e) { } catch (e) {
alert("Nettverksfeil"); alert("Nettverksfeil");
@ -266,6 +373,7 @@ export default function VtgWasher() {
const descriptionChanged = textValue(draft.vtg_beskrivelse) !== textValue(draft.edit_beskrivelse); const descriptionChanged = textValue(draft.vtg_beskrivelse) !== textValue(draft.edit_beskrivelse);
const datesChanged = !datesAreEqual(draft.vtg_datoer, draft.edit_datoer); const datesChanged = !datesAreEqual(draft.vtg_datoer, draft.edit_datoer);
const contentChangedCount = [priceChanged, descriptionChanged].filter(Boolean).length; const contentChangedCount = [priceChanged, descriptionChanged].filter(Boolean).length;
const invalidCourseRows = getInvalidDateRows(normalizeDateRows(draft.edit_datoer));
const changedCount = [priceChanged, descriptionChanged, datesChanged].filter(Boolean).length; const changedCount = [priceChanged, descriptionChanged, datesChanged].filter(Boolean).length;
return ( return (
@ -284,6 +392,7 @@ export default function VtgWasher() {
{priceChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Pris</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>} {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">Kommende kurs</span>} {datesChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Kommende kurs</span>}
{invalidCourseRows.length > 0 && <span className="rounded-full bg-red-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-red-800">Ugyldige datoer</span>}
</div> </div>
</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>
@ -340,20 +449,25 @@ export default function VtgWasher() {
<div className="mb-3 text-[10px] font-black uppercase tracking-widest text-gray-500">I dag</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." /> <ReadOnlyDateList dates={draft.vtg_datoer} emptyLabel="Ingen kursdatoer registrert i dag." />
</div> </div>
<div className={`rounded-2xl border p-4 ${datesChanged ? 'border-green-200 bg-green-50/60' : 'border-gray-200 bg-white'}`}> <div className={`rounded-2xl border p-4 ${invalidCourseRows.length > 0 ? 'border-red-200 bg-red-50/60' : 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="mb-3 flex items-center justify-between gap-3">
<div className="text-[10px] font-black uppercase tracking-widest text-gray-500">Forslag</div> <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'}`}> <span className={`rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-widest ${invalidCourseRows.length > 0 ? 'bg-red-100 text-red-800' : datesChanged ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}>
{datesChanged ? 'Har endringer' : 'Lik dagens kurs'} {invalidCourseRows.length > 0 ? 'Må rettes' : datesChanged ? 'Har endringer' : 'Lik dagens kurs'}
</span> </span>
</div> </div>
{invalidCourseRows.length > 0 && (
<div className="mb-3 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
Kun kurs med gyldige datoer kan godkjennes. Rett eller slett radene markert under.
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
{draft.edit_datoer.length === 0 ? ( {draft.edit_datoer.length === 0 ? (
<div className="p-4 bg-white rounded-xl text-sm text-gray-500 italic border border-gray-200">Ingen kommende kurs funnet i forslaget.</div> <div className="p-4 bg-white rounded-xl text-sm text-gray-500 italic border border-gray-200">Ingen kommende kurs funnet i forslaget.</div>
) : ( ) : (
draft.edit_datoer.map((row: any, idx: number) => ( 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"> <div key={idx} className={`grid gap-2 rounded-lg border bg-white p-3 relative group sm:grid-cols-[minmax(0,1fr)_150px_auto] sm:items-center ${isValidDateRow(row) ? 'border-gray-200' : 'border-red-200'}`}>
<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" /> <input className={`w-full rounded border p-2 text-xs font-bold outline-none ${isValidDateRow(row) ? 'border-gray-100 focus:border-[#8bc34a]' : 'border-red-200 focus:border-red-400'}`} 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)}> <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="Ledig">Ledig</option>
<option value="Fulltegnet">Fulltegnet</option> <option value="Fulltegnet">Fulltegnet</option>
@ -361,6 +475,11 @@ export default function VtgWasher() {
<option value="Få plasser"> plasser</option> <option value="Få plasser"> plasser</option>
</select> </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> <button onClick={() => removeDateRow(draft.id, idx)} className="btn btn-sm btn-danger sm:opacity-0 sm:group-hover:opacity-100" title="Slett dato"></button>
{!isValidDateRow(row) && (
<div className="sm:col-span-3 text-xs font-bold text-red-700">
Ugyldig datoformat. Bruk f.eks. `12. mai`, `12.-13. mai` eller `14.05.2026`.
</div>
)}
</div> </div>
)) ))
)} )}

View file

@ -9,6 +9,8 @@ import {
buildAbsoluteUrl, buildAbsoluteUrl,
createBreadcrumbJsonLd, createBreadcrumbJsonLd,
createPageMetadata, createPageMetadata,
resolveSeoDescription,
resolveSeoTitle,
} from "@/app/seo"; } from "@/app/seo";
type CourseVisitPageProps = { type CourseVisitPageProps = {
@ -130,9 +132,11 @@ export async function generateMetadata({ params, searchParams }: CourseVisitPage
}); });
} }
const seoTitle = resolveSeoTitle(article.metaTitle, article.title);
const seoDescription = resolveSeoDescription(article.metaDescription, article.description);
const metadata = createPageMetadata({ const metadata = createPageMetadata({
title: article.title, title: seoTitle,
description: article.description, description: seoDescription,
path: `/banebesok/${article.slug}`, path: `/banebesok/${article.slug}`,
image: article.heroImages[0]?.src, image: article.heroImages[0]?.src,
type: "article", type: "article",
@ -141,7 +145,7 @@ export async function generateMetadata({ params, searchParams }: CourseVisitPage
if (article.isPreview) { if (article.isPreview) {
return { return {
...metadata, ...metadata,
title: `${article.title} | Utkastforhåndsvisning`, title: `${seoTitle} | Utkastforhåndsvisning`,
robots: { robots: {
index: false, index: false,
follow: false, follow: false,
@ -171,7 +175,7 @@ export default async function CourseVisitPage({ params, searchParams }: CourseVi
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Article", "@type": "Article",
headline: article.title, headline: article.title,
description: article.description, description: resolveSeoDescription(article.metaDescription, article.description, 200),
url: buildAbsoluteUrl(`/banebesok/${article.slug}`), url: buildAbsoluteUrl(`/banebesok/${article.slug}`),
image: article.heroImages.map((image) => buildAbsoluteUrl(image.src)), image: article.heroImages.map((image) => buildAbsoluteUrl(image.src)),
datePublished: article.publishedAt, datePublished: article.publishedAt,

View file

@ -6,25 +6,30 @@ import {
createCollectionPageJsonLd, createCollectionPageJsonLd,
createPageMetadata, createPageMetadata,
} from "@/app/seo"; } from "@/app/seo";
import { resolveSitePageSeo } from "@/app/pageSeo";
const pageTitle = "Banebesøk"; const fallbackPageTitle = "Golfreiser og banebesøk: Erfaringer fra norske golfbaner | TeeOff.no";
const pageDescription = const fallbackPageDescription =
"Redaksjonelle artikler fra norske golfbaner, bygget for lange historier, sterke bilder og nyttige lenker videre til TeeOffs baneprofiler."; "Bli med på tur! Vi besøker Norges vakreste golfbaner og deler ærlige reiseskildringer, spektakulære bilder og nyttige tips. Finn inspirasjon til din neste golfreise her.";
export const metadata = createPageMetadata({
title: pageTitle,
description: pageDescription,
path: "/banebesok",
});
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function generateMetadata() {
const seo = await resolveSitePageSeo("banebesok", fallbackPageTitle, fallbackPageDescription);
return createPageMetadata({
title: seo.title,
description: seo.description,
path: "/banebesok",
});
}
export default async function CourseVisitsPage() { export default async function CourseVisitsPage() {
const articles = await getCourseVisits(); const articles = await getCourseVisits();
const seo = await resolveSitePageSeo("banebesok", fallbackPageTitle, fallbackPageDescription);
const collectionJsonLd = createCollectionPageJsonLd({ const collectionJsonLd = createCollectionPageJsonLd({
name: pageTitle, name: seo.title,
description: pageDescription, description: seo.description,
path: "/banebesok", path: "/banebesok",
}); });

View file

@ -68,8 +68,8 @@ export async function generateMetadata({ params }: GolfCoursePageProps): Promise
}); });
} }
const fallbackTitle = `${facility.name}: banestatus, priser og info`; const fallbackTitle = `${facility.name}: Banestatus, greenfee og baneguide | TeeOff.no`;
const fallbackDescription = `Se banestatus, priser, kontaktinfo, kart og praktisk informasjon for ${facility.name} på TeeOff.no.`; const fallbackDescription = `Er ${facility.name} åpen? Se oppdatert banestatus, priser, vær og kart. Planlegg runden din med bilder og praktisk info på TeeOff.no!`;
return createPageMetadata({ return createPageMetadata({
title: resolveSeoTitle(facility.meta_title, fallbackTitle), title: resolveSeoTitle(facility.meta_title, fallbackTitle),

View file

@ -12,17 +12,23 @@ import {
export const revalidate = 900; export const revalidate = 900;
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const fallbackPageTitle = "Golfbaner i Norge"; const fallbackPageTitle = "Alle norske golfbaner: Finn din neste runde på gress | TeeOff.no";
const fallbackPageDescription = const fallbackPageDescription =
"Finn golfbaner i Norge og filtrer på område, banestatus, antall hull og fasiliteter i TeeOffs samlede oversikt."; "Planlegg din neste golfrunde. Se komplett oversikt over alle norske golfbaner med oppdatert banestatus, greenfee-priser og kart på TeeOff.no.";
export async function generateMetadata() { export async function generateMetadata() {
const seo = await resolveSitePageSeo("golfbaner", fallbackPageTitle, fallbackPageDescription); const seo = await resolveSitePageSeo("golfbaner", fallbackPageTitle, fallbackPageDescription);
return createPageMetadata({ const metadata = createPageMetadata({
title: seo.title, title: seo.title,
description: seo.description, description: seo.description,
path: "/golfbaner", path: "/golfbaner",
}); });
return {
...metadata,
alternates: {
canonical: "/",
},
};
} }
export default async function GolfCoursesIndexPage() { export default async function GolfCoursesIndexPage() {

View file

@ -8,9 +8,9 @@ import {
createPageMetadata, createPageMetadata,
} from "@/app/seo"; } from "@/app/seo";
const pageTitle = "Klubbnummer i Golfbox"; const pageTitle = "NGF Klubbnummer: Oversikt for Golfbox og Gimmie | TeeOff.no";
const pageDescription = const pageDescription =
"Sorterbar oversikt over NGF-nummer og klubbnavn for norske golfanlegg på TeeOff."; "Hvilken klubb tilhører nummeret? Se komplett og sorterbar oversikt over alle norske golfklubber og deres NGF-klubbnummer for bruk i Golfbox og Gimmie.";
export const metadata = createPageMetadata({ export const metadata = createPageMetadata({
title: pageTitle, title: pageTitle,

View file

@ -10,9 +10,10 @@ import {
export const revalidate = 1800; export const revalidate = 1800;
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const fallbackPageTitle = "Medlemskap i norske golfklubber"; const fallbackPageTitle =
"Billig golfmedlemskap? Finn og sammenlign priser på alle klubber | TeeOff.no";
const fallbackPageDescription = const fallbackPageDescription =
"Sammenlign priser på medlemskap i norske golfklubber, både full spillerett og rimeligste nasjonale alternativ."; "Hvor er det billigst å være medlem? Sammenlign priser på golfmedlemskap med full spillerett eller rimelige nasjonale alternativ (fjernmedlemskap) i Norge på TeeOff.no.";
export async function generateMetadata() { export async function generateMetadata() {
const seo = await resolveSitePageSeo("medlemskap", fallbackPageTitle, fallbackPageDescription); const seo = await resolveSitePageSeo("medlemskap", fallbackPageTitle, fallbackPageDescription);
@ -59,12 +60,41 @@ export default async function MembershipPage() {
Medlemskap Medlemskap
</p> </p>
<h1 className="text-5xl font-black text-[#112015] sm:text-6xl"> <h1 className="text-5xl font-black text-[#112015] sm:text-6xl">
Dette koster medlemskap i norske golfklubber Sammenlign priser golfmedlemskap i Norge
</h1> </h1>
<p className="mt-6 text-base leading-7 text-[#4F5F50] sm:text-lg"> <div className="mt-6 max-w-4xl space-y-5 text-base leading-7 text-[#4F5F50] sm:text-lg">
Velg hvilken type medlemskap du vil sammenligne under. Hver rad kan åpnes for flere <p>Beløpene oppdateres fortløpende, snart jeg oppdager endringer.</p>
detaljer, sist oppdatert-dato og lenke til klubbens egen innmelding. <p>Jeg har stilt meg selv to spørsmål:</p>
<p>
<strong>Standardmedlemskap:</strong> Hva vil det koste meg, en
gjennomsnittsgolfer i alder og kjønn, å spille mye jeg ønsker denne banen?
</p> </p>
<p>
<strong>Billigst mulig:</strong> Hva vil det koste meg å være medlem her, dersom
jeg aksepterer at jeg betale greenfee hver runde? (Medlemskapet skal også gi
rett til greenfeespill andre baner.) Dette er ofte kjent som fjernmedlemskap.
</p>
<p>Svarene disse to spørsmålene er utgangspunktet for listene under.</p>
<p>
Det du naturligvis ikke klarer å lese ut av listene, er hva du får utover
spilleretten, hvilke spesialtilbud som gjelder, om det er dyrere dersom du velger å
dele årsavgiften opp i flere avdrag og andre ymse ting. Derfor er det også en lenke
til klubbens innmeldingssider, slik at du kan lese deg opp detaljene.
</p>
<p>
Bruk listene for hva de er verdt, men husk: Har du anledning, støtt nærklubben
din. Det koster mye penger å tilby en allright golfbane!
</p>
<p>
(Alt er oppgitt i norske kroner pr år, og jeg tar intet ansvar for eventuelle feil
i listene.)
</p>
<p>
Og la deg ikke forvirre av terminologien her. Ser du f.eks at det står
&quot;greenfee&quot;, er det i denne sammenhengen bare navnet en type
medlemskap klubben tilbyr.
</p>
</div>
</div> </div>
</section> </section>

View file

@ -9,6 +9,8 @@ import {
buildAbsoluteUrl, buildAbsoluteUrl,
createBreadcrumbJsonLd, createBreadcrumbJsonLd,
createPageMetadata, createPageMetadata,
resolveSeoDescription,
resolveSeoTitle,
} from "@/app/seo"; } from "@/app/seo";
type OpinionPageProps = { type OpinionPageProps = {
@ -130,9 +132,11 @@ export async function generateMetadata({ params, searchParams }: OpinionPageProp
}); });
} }
const seoTitle = resolveSeoTitle(article.metaTitle, article.title);
const seoDescription = resolveSeoDescription(article.metaDescription, article.description);
const metadata = createPageMetadata({ const metadata = createPageMetadata({
title: article.title, title: seoTitle,
description: article.description, description: seoDescription,
path: `/meninger/${article.slug}`, path: `/meninger/${article.slug}`,
image: article.heroImages[0]?.src, image: article.heroImages[0]?.src,
type: "article", type: "article",
@ -141,7 +145,7 @@ export async function generateMetadata({ params, searchParams }: OpinionPageProp
if (article.isPreview) { if (article.isPreview) {
return { return {
...metadata, ...metadata,
title: `${article.title} | Utkastforhåndsvisning`, title: `${seoTitle} | Utkastforhåndsvisning`,
robots: { robots: {
index: false, index: false,
follow: false, follow: false,
@ -171,7 +175,7 @@ export default async function OpinionPage({ params, searchParams }: OpinionPageP
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Article", "@type": "Article",
headline: article.title, headline: article.title,
description: article.description, description: resolveSeoDescription(article.metaDescription, article.description, 200),
url: buildAbsoluteUrl(`/meninger/${article.slug}`), url: buildAbsoluteUrl(`/meninger/${article.slug}`),
image: article.heroImages.map((image) => buildAbsoluteUrl(image.src)), image: article.heroImages.map((image) => buildAbsoluteUrl(image.src)),
datePublished: article.publishedAt, datePublished: article.publishedAt,

View file

@ -6,25 +6,30 @@ import {
createCollectionPageJsonLd, createCollectionPageJsonLd,
createPageMetadata, createPageMetadata,
} from "@/app/seo"; } from "@/app/seo";
import { resolveSitePageSeo } from "@/app/pageSeo";
const pageTitle = "Meninger"; const fallbackPageTitle = "Golfblogg: Meninger, humor og skråblikk på golf-Norge | TeeOff.no";
const pageDescription = const fallbackPageDescription =
"Redaksjonelle artikler, siste nytt og kommentarer fra TeeOff, samlet i én egen seksjon."; "Fra frustrasjon over saktespill til gleden over en perfekt drive. Les TeeOffs egne artikler, kommentarer og ærlige skråblikk på livet som golfer i Norge.";
export const metadata = createPageMetadata({
title: pageTitle,
description: pageDescription,
path: "/meninger",
});
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function generateMetadata() {
const seo = await resolveSitePageSeo("meninger", fallbackPageTitle, fallbackPageDescription);
return createPageMetadata({
title: seo.title,
description: seo.description,
path: "/meninger",
});
}
export default async function OpinionsPage() { export default async function OpinionsPage() {
const articles = await getOpinionArticles(); const articles = await getOpinionArticles();
const seo = await resolveSitePageSeo("meninger", fallbackPageTitle, fallbackPageDescription);
const collectionJsonLd = createCollectionPageJsonLd({ const collectionJsonLd = createCollectionPageJsonLd({
name: pageTitle, name: seo.title,
description: pageDescription, description: seo.description,
path: "/meninger", path: "/meninger",
}); });

View file

@ -7,9 +7,9 @@ import { createPageMetadata } from "@/app/seo";
export const revalidate = 900; export const revalidate = 900;
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const metadata = createPageMetadata({ export const metadata = createPageMetadata({
title: "Komplett oversikt over ALLE norske golfbaner", title: "Golfbaner i Norge: Komplett oversikt over alle baner | TeeOff.no",
description: description:
"Utforsk norske golfbaner med oppdatert banestatus, kart, priser, medlemskap og Veien til Golf samlet på TeeOff.", "Hvilke golfbaner i Norge er åpne nå? TeeOff gir deg komplett oversikt over alle norske golfbaner med oppdatert banestatus, kart og priser. Finn din neste runde her!",
path: "/", path: "/",
}); });

View file

@ -0,0 +1,16 @@
export function buildDefaultPlaceMetaTitle(
facilityCount: number | null | undefined,
placeLabel: string,
preposition: string,
) {
const normalizedCount =
typeof facilityCount === "number" && Number.isFinite(facilityCount) && facilityCount >= 0
? `${facilityCount} golfbaner`
: "Golfbaner";
return `${normalizedCount} ${preposition} ${placeLabel}: Se kart, banestatus og praktisk info.`;
}
export function buildDefaultPlaceMetaDescription(placeLabel: string, preposition: string) {
return `Hvilke golfbaner ${preposition} ${placeLabel} er åpne nå? Se oppdatert banestatus, sammenlign greenfee og utforsk banene med bilder og video. Planlegg runden på TeeOff.no!`;
}

View file

@ -102,9 +102,13 @@ export function createPageMetadata({
type = "website", type = "website",
}: MetadataInput): Metadata { }: MetadataInput): Metadata {
const ogImage = resolveImageUrl(image); const ogImage = resolveImageUrl(image);
const normalizedTitle = String(title || "").trim();
const metadataTitle = /\|\s*TeeOff\.no\s*$/i.test(normalizedTitle)
? { absolute: normalizedTitle }
: normalizedTitle;
return { return {
title, title: metadataTitle,
description, description,
alternates: { alternates: {
canonical: path, canonical: path,

View file

@ -1,3 +1,5 @@
import { existsSync, statSync } from "node:fs";
import path from "node:path";
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { API_URL } from "@/config/constants"; import { API_URL } from "@/config/constants";
import { getAvailablePlaceConfigs } from "@/app/facilityData"; import { getAvailablePlaceConfigs } from "@/app/facilityData";
@ -10,78 +12,158 @@ type SitemapFacility = {
vtg_updated_at?: string | null; vtg_updated_at?: string | null;
}; };
type SitePageSeoRecord = {
updated_at?: string | null;
};
type PlacePageRecord = {
updated_at?: string | null;
};
type StaticRouteConfig = {
path: string;
changeFrequency: "daily" | "weekly" | "monthly";
priority: number;
sourceFiles: string[];
sitePageKey?: string;
};
export const revalidate = 3600; export const revalidate = 3600;
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const staticRoutes: MetadataRoute.Sitemap = [ const staticRouteConfigs: StaticRouteConfig[] = [
{ {
url: buildAbsoluteUrl("/"), path: "/",
lastModified: new Date(),
changeFrequency: "daily", changeFrequency: "daily",
priority: 1, priority: 1,
sourceFiles: ["src/app/page.tsx"],
}, },
{ {
url: buildAbsoluteUrl("/golfbaner"), path: "/golfbaner",
lastModified: new Date(),
changeFrequency: "daily", changeFrequency: "daily",
priority: 0.95, priority: 0.95,
sourceFiles: ["src/app/golfbaner/page.tsx", "src/app/pageSeo.ts"],
sitePageKey: "golfbaner",
}, },
{ {
url: buildAbsoluteUrl("/medlemskap"), path: "/medlemskap",
lastModified: new Date(),
changeFrequency: "daily", changeFrequency: "daily",
priority: 0.8, priority: 0.8,
sourceFiles: ["src/app/medlemskap/page.tsx", "src/app/pageSeo.ts"],
sitePageKey: "medlemskap",
}, },
{ {
url: buildAbsoluteUrl("/vtg"), path: "/vtg",
lastModified: new Date(),
changeFrequency: "daily", changeFrequency: "daily",
priority: 0.8, priority: 0.8,
sourceFiles: ["src/app/vtg/page.tsx", "src/app/pageSeo.ts"],
sitePageKey: "vtg",
}, },
{ {
url: buildAbsoluteUrl("/banebesok"), path: "/banebesok",
lastModified: new Date(),
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 0.72, priority: 0.72,
sourceFiles: ["src/app/banebesok/page.tsx", "src/app/pageSeo.ts"],
sitePageKey: "banebesok",
}, },
{ {
url: buildAbsoluteUrl("/meninger"), path: "/meninger",
lastModified: new Date(),
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 0.7, priority: 0.7,
sourceFiles: ["src/app/meninger/page.tsx", "src/app/pageSeo.ts"],
sitePageKey: "meninger",
}, },
{ {
url: buildAbsoluteUrl("/turneringer"), path: "/turneringer",
lastModified: new Date(),
changeFrequency: "daily", changeFrequency: "daily",
priority: 0.68, priority: 0.68,
sourceFiles: ["src/app/turneringer/page.tsx"],
}, },
{ {
url: buildAbsoluteUrl("/klubbnummer"), path: "/klubbnummer",
lastModified: new Date(),
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 0.64, priority: 0.64,
sourceFiles: ["src/app/klubbnummer/page.tsx"],
}, },
{ {
url: buildAbsoluteUrl("/om"), path: "/om",
lastModified: new Date(),
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.45, priority: 0.45,
sourceFiles: ["src/app/om/page.tsx"],
}, },
{ {
url: buildAbsoluteUrl("/kontakt"), path: "/kontakt",
lastModified: new Date(),
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.42, priority: 0.42,
sourceFiles: ["src/app/kontakt/page.tsx"],
}, },
{ {
url: buildAbsoluteUrl("/personvern-og-cookies"), path: "/personvern-og-cookies",
lastModified: new Date(),
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.38, priority: 0.38,
sourceFiles: ["src/app/personvern-og-cookies/page.tsx"],
}, },
]; ];
function parseDate(value: string | Date | null | undefined) {
if (!value) return null;
const candidate = value instanceof Date ? value : new Date(value);
return Number.isNaN(candidate.getTime()) ? null : candidate;
}
function maxDate(values: Array<string | Date | null | undefined>) {
let current: Date | null = null;
for (const value of values) {
const parsed = parseDate(value);
if (!parsed) continue;
if (!current || parsed.getTime() > current.getTime()) {
current = parsed;
}
}
return current;
}
function getSourceLastModified(relativePaths: string[]) {
let current: Date | null = null;
for (const relativePath of relativePaths) {
const absolutePath = path.join(process.cwd(), relativePath);
if (!existsSync(absolutePath)) continue;
const stats = statSync(absolutePath);
if (!current || stats.mtime.getTime() > current.getTime()) {
current = stats.mtime;
}
}
return current;
}
async function fetchSitePageSeoUpdatedAt(pageKey: string) {
try {
const response = await fetch(`${API_URL}/page-seo/${pageKey}`, {
next: { revalidate },
});
if (!response.ok) return null;
const data = (await response.json()) as SitePageSeoRecord;
return parseDate(data.updated_at);
} catch {
return null;
}
}
async function fetchPlacePageUpdatedAt(slug: string) {
try {
const response = await fetch(`${API_URL}/place-pages/${slug}`, {
next: { revalidate },
});
if (!response.ok) return null;
const data = (await response.json()) as PlacePageRecord;
return parseDate(data.updated_at);
} catch {
return null;
}
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
let facilities: SitemapFacility[] = []; let facilities: SitemapFacility[] = [];
@ -97,18 +179,49 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
facilities = []; facilities = [];
} }
const placeRoutes = getAvailablePlaceConfigs().map((slug) => ({ const staticRoutes = await Promise.all(
staticRouteConfigs.map(async (route) => {
const sitePageUpdatedAt = route.sitePageKey
? await fetchSitePageSeoUpdatedAt(route.sitePageKey)
: null;
return {
url: buildAbsoluteUrl(route.path),
lastModified:
maxDate([sitePageUpdatedAt, getSourceLastModified(route.sourceFiles)]) || new Date(),
changeFrequency: route.changeFrequency,
priority: route.priority,
};
}),
);
const placeSourceLastModified = getSourceLastModified([
"src/app/sted/[slug]/page.tsx",
"src/app/placeSeo.ts",
]);
const placeRoutes = await Promise.all(
getAvailablePlaceConfigs().map(async (slug) => ({
url: buildAbsoluteUrl(`/sted/${slug}`), url: buildAbsoluteUrl(`/sted/${slug}`),
lastModified: new Date(), lastModified:
maxDate([await fetchPlacePageUpdatedAt(slug), placeSourceLastModified]) || new Date(),
changeFrequency: "daily" as const, changeFrequency: "daily" as const,
priority: slug === "norge" ? 0.9 : 0.75, priority: slug === "norge" ? 0.9 : 0.75,
})); })),
);
const facilityPageSourceLastModified = getSourceLastModified([
"src/app/golfbaner/[slug]/page.tsx",
]);
const facilityRoutes = facilities const facilityRoutes = facilities
.filter((facility) => Boolean(facility.slug)) .filter((facility) => Boolean(facility.slug))
.map((facility) => ({ .map((facility) => ({
url: buildAbsoluteUrl(`/golfbaner/${facility.slug}`), url: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
lastModified: facility.status_updated_at || facility.vtg_updated_at || new Date(), lastModified:
maxDate([
facility.status_updated_at,
facility.vtg_updated_at,
facilityPageSourceLastModified,
]) || new Date(),
changeFrequency: "daily" as const, changeFrequency: "daily" as const,
priority: 0.7, priority: 0.7,
})); }));

View file

@ -23,6 +23,10 @@ import {
resolveSeoDescription, resolveSeoDescription,
resolveSeoTitle, resolveSeoTitle,
} from "@/app/seo"; } from "@/app/seo";
import {
buildDefaultPlaceMetaDescription,
buildDefaultPlaceMetaTitle,
} from "@/app/placeSeo";
import { fetchPublicFacilities } from "@/app/publicFacilities"; import { fetchPublicFacilities } from "@/app/publicFacilities";
type PlacePageData = { type PlacePageData = {
@ -115,6 +119,8 @@ const fetchPlacePageData = cache(async (slug: string): Promise<PlacePageData | n
} }
}); });
const fetchPlaceFacilities = cache(async () => fetchPublicFacilities<FacilityRecord>("place", revalidate));
export async function generateMetadata({ export async function generateMetadata({
params, params,
}: { }: {
@ -131,11 +137,21 @@ export async function generateMetadata({
}); });
} }
const facilities = await fetchPlaceFacilities();
const placePage = await fetchPlacePageData(slug); const placePage = await fetchPlacePageData(slug);
const fallbackDescription = `${place.intro} TeeOff samler golfbaner i ${place.label} med oppdatert banestatus og baneprofiler.`; const safeData = Array.isArray(facilities) ? facilities : [];
const enrichedFacilities = enrichFacilities(safeData);
const facilitiesInPlace = filterFacilitiesByArea(enrichedFacilities, place.areaFilter);
const placePreposition = place.slug === "norge" ? "i" : getPlacePreposition(place.label);
const fallbackTitle = buildDefaultPlaceMetaTitle(
facilitiesInPlace.length,
place.label,
placePreposition,
);
const fallbackDescription = buildDefaultPlaceMetaDescription(place.label, placePreposition);
return createPageMetadata({ return createPageMetadata({
title: resolveSeoTitle(placePage?.meta_title, place.title), title: resolveSeoTitle(placePage?.meta_title, fallbackTitle),
description: resolveSeoDescription(placePage?.meta_description, fallbackDescription), description: resolveSeoDescription(placePage?.meta_description, fallbackDescription),
path: `/sted/${slug}`, path: `/sted/${slug}`,
}); });
@ -151,7 +167,7 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
let placePage: PlacePageData | null = null; let placePage: PlacePageData | null = null;
const facilities = await fetchPublicFacilities<FacilityRecord>("place", revalidate); const facilities = await fetchPlaceFacilities();
try { try {
placePage = await fetchPlacePageData(slug); placePage = await fetchPlacePageData(slug);
@ -192,15 +208,21 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
? `Det korteste golfhullet ${placePreposition} ${place.label} er ${placeStats.shortestHoleMeters} meter, mens det lengste er ${placeStats.longestHoleMeters} meter.` ? `Det korteste golfhullet ${placePreposition} ${place.label} er ${placeStats.shortestHoleMeters} meter, mens det lengste er ${placeStats.longestHoleMeters} meter.`
: null; : null;
const collectionJsonLd = createCollectionPageJsonLd({ const collectionJsonLd = createCollectionPageJsonLd({
name: resolveSeoTitle(placePage?.meta_title, place.title), name: resolveSeoTitle(
placePage?.meta_title,
buildDefaultPlaceMetaTitle(placeStats.facilityCount, place.label, placePreposition),
),
description: resolveSeoDescription( description: resolveSeoDescription(
placePage?.meta_description, placePage?.meta_description,
`${place.intro} TeeOff samler golfbaner i ${place.label} med oppdatert banestatus og baneprofiler.`, buildDefaultPlaceMetaDescription(place.label, placePreposition),
), ),
path: `/sted/${slug}`, path: `/sted/${slug}`,
}); });
const itemListJsonLd = createItemListJsonLd({ const itemListJsonLd = createItemListJsonLd({
name: resolveSeoTitle(placePage?.meta_title, place.title), name: resolveSeoTitle(
placePage?.meta_title,
buildDefaultPlaceMetaTitle(placeStats.facilityCount, place.label, placePreposition),
),
path: `/sted/${slug}`, path: `/sted/${slug}`,
items: facilitiesInPlace items: facilitiesInPlace
.filter((facility) => facility?.slug && facility?.name) .filter((facility) => facility?.slug && facility?.name)

View file

@ -11,7 +11,9 @@ import {
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const articleSlug = "note-to-self-lenker-til-viktige-turneringer-i-golfbox"; const articleSlug = "note-to-self-lenker-til-viktige-turneringer-i-golfbox";
const pageTitle = "Turneringer"; const pageTitle = "Golfturneringer i Norge: Oversikt og terminlister | TeeOff.no";
const pageDescription =
"Vanskelig å finne frem i Golfbox? Vi samler terminlister for Olyo Tour og regionale golfturneringer i hele Norge på ett sted. Finn din neste turnering her!";
const pageIntro = "Her er alle turneringene vi ikke vet hvordan vi skal finne i Golfbox (og andre steder). God golfsesong!"; const pageIntro = "Her er alle turneringene vi ikke vet hvordan vi skal finne i Golfbox (og andre steder). God golfsesong!";
function renderBlock(block: CourseVisitBodyBlock, index: number) { function renderBlock(block: CourseVisitBodyBlock, index: number) {
if (block.type !== "richText") { if (block.type !== "richText") {
@ -38,7 +40,7 @@ export async function generateMetadata() {
return createPageMetadata({ return createPageMetadata({
title: pageTitle, title: pageTitle,
description: article?.description || "Viktige turneringslenker i Golfbox samlet på TeeOff.", description: pageDescription,
path: "/turneringer", path: "/turneringer",
image: article?.heroImages[0]?.src, image: article?.heroImages[0]?.src,
}); });
@ -53,7 +55,7 @@ export default async function TournamentsPage() {
const collectionJsonLd = createCollectionPageJsonLd({ const collectionJsonLd = createCollectionPageJsonLd({
name: pageTitle, name: pageTitle,
description: article.description, description: pageDescription,
path: "/turneringer", path: "/turneringer",
}); });
const breadcrumbJsonLd = createBreadcrumbJsonLd([ const breadcrumbJsonLd = createBreadcrumbJsonLd([

View file

@ -11,9 +11,9 @@ import {
export const revalidate = 1800; export const revalidate = 1800;
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const fallbackPageTitle = "Veien til Golf"; const fallbackPageTitle = "Veien til Golf: Finn golfkurs for nybegynnere (VTG) | TeeOff.no";
const fallbackPageDescription = const fallbackPageDescription =
"Finn Veien til Golf-kurs etter område, klubb og neste kursdato i TeeOffs VTG-oversikt."; "Vil du begynne med golf? Finn komplett oversikt over Veien til Golf-kurs (VTG) i hele Norge. Se kursdatoer, priser og finn din nærmeste klubb på TeeOff.no!";
export async function generateMetadata() { export async function generateMetadata() {
const seo = await resolveSitePageSeo("vtg", fallbackPageTitle, fallbackPageDescription); const seo = await resolveSitePageSeo("vtg", fallbackPageTitle, fallbackPageDescription);

View file

@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { API_URL } from "@/config/constants"; import { API_URL } from "@/config/constants";
import importedMeninger from "@/content/importedMeninger.json";
export type ArticleSection = "banebesok" | "meninger"; export type ArticleSection = "banebesok" | "meninger";
@ -58,6 +59,8 @@ export type EditorialArticle = {
slug: string; slug: string;
eyebrow: string; eyebrow: string;
title: string; title: string;
metaTitle?: string;
metaDescription?: string;
description: string; description: string;
excerpt: string; excerpt: string;
locationLabel: string; locationLabel: string;
@ -84,6 +87,8 @@ type ArticleApiRecord = {
status?: string | null; status?: string | null;
slug: string; slug: string;
title: string; title: string;
meta_title?: string | null;
meta_description?: string | null;
description?: string | null; description?: string | null;
excerpt?: string | null; excerpt?: string | null;
eyebrow?: string | null; eyebrow?: string | null;
@ -106,6 +111,25 @@ type FacilityMeta = {
region: string; region: string;
}; };
type ImportedOpinionRecord = {
slug?: string;
status?: string | null;
title?: string | null;
excerpt?: string | null;
contentHtml?: string | null;
publishedAt?: string | null;
updatedAt?: string | null;
link?: string | null;
featuredImage?: {
url?: string | null;
alt?: string | null;
caption?: string | null;
} | null;
author?: {
name?: string | null;
} | null;
};
const facilityMetaBySlug: Record<string, FacilityMeta> = { const facilityMetaBySlug: Record<string, FacilityMeta> = {
"lofoten-golfklubb": { name: "Lofoten Golfklubb", region: "Nordland" }, "lofoten-golfklubb": { name: "Lofoten Golfklubb", region: "Nordland" },
"kjekstad-golfklubb": { name: "Kjekstad Golfklubb", region: "Buskerud" }, "kjekstad-golfklubb": { name: "Kjekstad Golfklubb", region: "Buskerud" },
@ -135,6 +159,11 @@ function normalizeStatus(value?: string | null): "draft" | "published" {
return String(value || "").trim().toLowerCase() === "published" ? "published" : "draft"; return String(value || "").trim().toLowerCase() === "published" ? "published" : "draft";
} }
function normalizeImportedStatus(value?: string | null): "draft" | "published" {
const normalized = String(value || "").trim().toLowerCase();
return normalized === "published" || normalized === "publish" ? "published" : "draft";
}
export function buildEditorialPath(section: ArticleSection, slug: string) { export function buildEditorialPath(section: ArticleSection, slug: string) {
return `/${section}/${slug}`; return `/${section}/${slug}`;
} }
@ -534,6 +563,8 @@ function mapApiArticle(entry: ArticleApiRecord): EditorialArticle {
slug: entry.slug, slug: entry.slug,
eyebrow: String(entry.eyebrow || "").trim() || getSectionLabel(section), eyebrow: String(entry.eyebrow || "").trim() || getSectionLabel(section),
title: entry.title, title: entry.title,
metaTitle: String(entry.meta_title || "").trim() || undefined,
metaDescription: String(entry.meta_description || "").trim() || undefined,
description: String(entry.description || "").trim() || excerpt, description: String(entry.description || "").trim() || excerpt,
excerpt, excerpt,
locationLabel, locationLabel,
@ -572,6 +603,107 @@ function mapApiArticle(entry: ArticleApiRecord): EditorialArticle {
}; };
} }
function mapImportedOpinionArticle(entry: ImportedOpinionRecord): EditorialArticle | null {
const slug = String(entry.slug || "").trim();
const title = String(entry.title || "").trim();
if (!slug || !title) {
return null;
}
const normalizedHtml = normalizeInternalLinks(String(entry.contentHtml || ""));
const preparedHtml = prepareRichTextHtml(normalizedHtml, title);
const extractedMedia = extractMediaFromHtml(normalizedHtml, title);
const featuredSrc = normalizeMediaUrl(String(entry.featuredImage?.url || "").trim());
const featuredAlt = String(entry.featuredImage?.alt || "").trim() || title;
const featuredCaption =
String(entry.featuredImage?.caption || "").trim() || featuredAlt || title;
const fallbackMedia = featuredSrc
? [
{
id: buildMediaId("image", featuredSrc),
type: "image" as const,
src: featuredSrc,
alt: featuredAlt,
caption: featuredCaption,
poster: "",
},
]
: [];
const mediaGallery = sanitizeMediaGallery([...fallbackMedia, ...extractedMedia], title).slice(0, 24);
const featuredMediaId = mediaGallery.find((item) => item.type === "image")?.id;
const heroImages = buildHeroImagesFromMedia(mediaGallery, title, featuredMediaId).slice(0, 6);
const excerpt =
String(entry.excerpt || "").trim() ||
stripHtml(normalizedHtml).slice(0, 220);
const publishedAt = String(entry.publishedAt || entry.updatedAt || "").trim();
return {
section: "meninger",
slug,
eyebrow: "Meninger",
title,
metaTitle: undefined,
metaDescription: undefined,
description: excerpt,
excerpt,
locationLabel: "Norge",
publishedAt,
updatedAt: String(entry.updatedAt || "").trim() || undefined,
readingTime: getReadingTime(preparedHtml),
heroImages:
heroImages.length > 0
? heroImages
: [
{
src: "/Toppbilde-standard.jpg",
alt: title,
caption: title,
},
],
mediaGallery,
featuredMediaId,
quickFacts: buildQuickFacts({
publishedAt,
authorName: String(entry.author?.name || "").trim() || undefined,
}),
highlights: buildHighlights("meninger"),
blocks: [
{
type: "richText",
html: preparedHtml,
},
],
sourceUrl: String(entry.link || "").trim() || undefined,
sourceLabel: "Legacy TeeOff",
status: normalizeImportedStatus(entry.status),
isPreview: false,
};
}
function getImportedOpinionArticles() {
return (Array.isArray(importedMeninger) ? importedMeninger : [])
.map((entry) => mapImportedOpinionArticle(entry as ImportedOpinionRecord))
.filter((entry): entry is EditorialArticle => Boolean(entry && entry.status === "published"))
.sort((a, b) => {
const aTs = Date.parse(a.publishedAt || a.updatedAt || "") || 0;
const bTs = Date.parse(b.publishedAt || b.updatedAt || "") || 0;
return bTs - aTs;
});
}
function getImportedOpinionArticleBySlug(slug: string) {
const normalizedSlug = String(slug || "").trim();
if (!normalizedSlug) {
return null;
}
return (
getImportedOpinionArticles().find((entry) => entry.slug === normalizedSlug) || null
);
}
async function fetchPublishedArticles(section: ArticleSection) { async function fetchPublishedArticles(section: ArticleSection) {
const response = await fetch(`${API_URL}/articles?section=${section}`, { cache: "no-store" }); const response = await fetch(`${API_URL}/articles?section=${section}`, { cache: "no-store" });
if (!response.ok) { if (!response.ok) {
@ -644,6 +776,10 @@ export async function getEditorialArticles(section: ArticleSection) {
// Returnerer tom liste dersom API-et ikke er tilgjengelig. // Returnerer tom liste dersom API-et ikke er tilgjengelig.
} }
if (section === "meninger") {
return getImportedOpinionArticles();
}
return []; return [];
} }
@ -672,6 +808,10 @@ export async function getEditorialArticleBySlug(
// Returnerer null dersom API-et ikke er tilgjengelig. // Returnerer null dersom API-et ikke er tilgjengelig.
} }
if (section === "meninger") {
return getImportedOpinionArticleBySlug(slug);
}
return null; return null;
} }