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,
)
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
# --- KONFIGURASJON ---
@ -303,6 +303,15 @@ def collect_facility_indexnow_urls(slugs: list[str], extra_paths: list[str] | No
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:
configured = INDEXNOW_KEY_LOCATION.strip()
if configured:
@ -760,6 +769,8 @@ class ArticleUpsertRequest(BaseModel):
section: Optional[str] = "banebesok"
slug: str
title: str
meta_title: Optional[str] = None
meta_description: Optional[str] = None
description: Optional[str] = None
excerpt: 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
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):
if row is None:
return None
@ -2461,6 +2494,8 @@ async def ensure_articles_table(conn):
section VARCHAR(32) NOT NULL DEFAULT 'banebesok',
slug VARCHAR(255) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL,
meta_title TEXT,
meta_description TEXT,
description TEXT,
excerpt TEXT,
eyebrow VARCHAR(120) DEFAULT 'Banebesøk',
@ -2492,6 +2527,14 @@ async def ensure_articles_table(conn):
ALTER TABLE articles
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("""
UPDATE articles
SET section = 'banebesok'
@ -3858,7 +3901,7 @@ async def get_place_page(slug: str, response: Response):
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}")
@ -4082,6 +4125,10 @@ async def update_admin_place_page(slug: str, request: PlacePageUpsertRequest):
)
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)
@ -4131,6 +4178,18 @@ async def update_admin_site_page_seo(page_key: str, request: SitePageSeoUpsertRe
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)
@ -4577,17 +4636,19 @@ async def upsert_admin_article(request: ArticleUpsertRequest):
)
row = await conn.fetchrow("""
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,
media_gallery, featured_media_id, content_html, source_url, source_label, published_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $12::jsonb,
$13::jsonb, $14, $15, $16, $17, $18, $19
$1, $2, $3, $4, $5, $6, $7, $8, $9,
$10, $11, $12, $13, $14::jsonb,
$15::jsonb, $16, $17, $18, $19, $20, $21
)
ON CONFLICT (slug) DO UPDATE SET
section = EXCLUDED.section,
title = EXCLUDED.title,
meta_title = EXCLUDED.meta_title,
meta_description = EXCLUDED.meta_description,
description = EXCLUDED.description,
excerpt = EXCLUDED.excerpt,
eyebrow = EXCLUDED.eyebrow,
@ -4609,6 +4670,8 @@ async def upsert_admin_article(request: ArticleUpsertRequest):
section,
requested_slug,
request.title.strip(),
(request.meta_title or "").strip() or None,
(request.meta_description or "").strip() or None,
(request.description or "").strip() or None,
(request.excerpt or "").strip() or None,
(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")
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]
async with app.state.pool.acquire() as conn:
async with conn.transaction():
@ -5108,6 +5172,7 @@ async def approve_vtg_courses_bulk(request: BulkVtgCoursesRequest):
@app.post("/api/admin/vtg/approve-bulk")
async def approve_vtg_bulk(request: BulkVtgRequest):
"""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]
async with app.state.pool.acquire() as conn:
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]]:
normalized_rows = normalize_vtg_course_rows(rows)
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 { API_URL } from "@/config/constants";
import { adminFetch } from "@/config/adminFetch";
import SeoFieldset, { trimSuggestion } from "@/components/admin/SeoFieldset";
import TiptapHtmlEditor from "@/components/TiptapHtmlEditor";
type ArticleMediaItem = {
@ -20,6 +21,8 @@ type AdminArticle = {
section?: "banebesok" | "meninger";
slug: string;
title: string;
meta_title?: string | null;
meta_description?: string | null;
description?: string | null;
excerpt?: string | null;
eyebrow?: string | null;
@ -52,6 +55,8 @@ type ArticleFormState = {
section: "banebesok" | "meninger";
slug: string;
title: string;
meta_title: string;
meta_description: string;
description: string;
excerpt: string;
eyebrow: string;
@ -114,6 +119,8 @@ function createEmptyForm(): ArticleFormState {
section: "banebesok",
slug: "",
title: "",
meta_title: "",
meta_description: "",
description: "",
excerpt: "",
eyebrow: "Banebesøk",
@ -144,6 +151,8 @@ function articleToForm(article: AdminArticle): ArticleFormState {
section: article.section || "banebesok",
slug: article.slug || "",
title: article.title || "",
meta_title: article.meta_title || "",
meta_description: article.meta_description || "",
description: article.description || "",
excerpt: article.excerpt || "",
eyebrow: article.eyebrow || "Banebesøk",
@ -182,6 +191,8 @@ function buildArticlePayload(
section: form.section,
slug: form.slug.trim(),
title: form.title.trim(),
meta_title: form.meta_title.trim(),
meta_description: form.meta_description.trim(),
description: form.description.trim(),
excerpt: form.excerpt.trim(),
eyebrow: form.eyebrow.trim(),
@ -222,6 +233,14 @@ export default function AdminArticlesPage() {
const heroImageInputRef = useRef<HTMLInputElement | null>(null);
const formSectionRef = useRef<HTMLElement | 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 = () => {
window.requestAnimationFrame(() => {
@ -797,17 +816,28 @@ export default function AdminArticlesPage() {
</div>
<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">
<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
rows={3}
value={form.description}
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]"
/>
<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>
</label>
<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 facilityCity = String(formData.city || "").trim();
const facilitySuggestedTitle = seoFacilityName
? `${seoFacilityName}: banestatus, greenfee og info`
? `${seoFacilityName}: Banestatus, greenfee og baneguide | TeeOff.no`
: "";
const facilitySuggestedDescription = seoFacilityName
? 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,
)
: "";

View file

@ -5,13 +5,20 @@ import Link from "next/link";
import AdminMobileMenu from "@/components/AdminMobileMenu";
import TiptapHtmlEditor from "@/components/TiptapHtmlEditor";
import {
enrichFacilities,
filterFacilitiesByArea,
HIERARCHICAL_AREA_OPTIONS,
getPlaceConfigFromSlug,
getPlacePreposition,
type FacilityRecord,
} from "@/app/facilityData";
import { adminFetch } from "@/config/adminFetch";
import { API_URL } from "@/config/constants";
import SeoFieldset, { trimSuggestion } from "@/components/admin/SeoFieldset";
import {
buildDefaultPlaceMetaDescription,
buildDefaultPlaceMetaTitle,
} from "@/app/placeSeo";
type PlacePageResponse = {
slug: string;
@ -34,6 +41,8 @@ const SITE_PAGE_OPTIONS = [
{ key: "golfbaner", label: "/golfbaner" },
{ key: "vtg", label: "/vtg" },
{ key: "medlemskap", label: "/medlemskap" },
{ key: "banebesok", label: "/banebesok" },
{ key: "meninger", label: "/meninger" },
{ key: "simulatorer", label: "/simulatorer (fremtidig)" },
];
@ -42,19 +51,29 @@ const SITE_PAGE_SEO_SUGGESTIONS: Record<
{ title: string; description: string }
> = {
golfbaner: {
title: "Golfbaner i Norge",
title: "Alle norske golfbaner: Finn din neste runde på gress | TeeOff.no",
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: {
title: "Veien til Golf",
title: "Veien til Golf: Finn golfkurs for nybegynnere (VTG) | TeeOff.no",
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: {
title: "Medlemskap i norske golfklubber",
title: "Billig golfmedlemskap? Finn og sammenlign priser på alle klubber | TeeOff.no",
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: {
title: "Golfsimulatorer i Norge",
@ -85,6 +104,7 @@ export default function AdminPlacePagesPage() {
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [feedback, setFeedback] = useState("");
const [placeFacilities, setPlaceFacilities] = useState<FacilityRecord[]>([]);
const [selectedPageKey, setSelectedPageKey] = useState(DEFAULT_PAGE_KEY);
const [pageMetaTitle, setPageMetaTitle] = useState("");
const [pageMetaDescription, setPageMetaDescription] = useState("");
@ -95,18 +115,60 @@ export default function AdminPlacePagesPage() {
const selectedPlace = getPlaceConfigFromSlug(selectedSlug);
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
? trimSuggestion(
`Finn golfbaner ${selectedPlacePreposition} ${selectedPlace.label} med oppdatert banestatus, priser og baneprofiler på TeeOff.no.`,
buildDefaultPlaceMetaDescription(selectedPlace.label, selectedPlacePreposition),
160,
)
: "";
const effectivePlaceMetaTitle = metaTitle.trim() || placeSuggestedTitle;
const effectivePlaceMetaDescription = metaDescription.trim() || placeSuggestedDescription;
const sitePageSuggestion = SITE_PAGE_SEO_SUGGESTIONS[selectedPageKey] || {
title: "",
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(() => {
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="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>
<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">
Sist lagret: {formatDateTime(updatedAt)}
</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>
<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]">
Her kan du overstyre meta title og meta description de store landingssidene uten å endre H1 eller innholdstekst.
</p>

View file

@ -12,6 +12,33 @@ type VtgDateRow = {
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[] => {
if (!Array.isArray(value)) return [];
return value.map((row) => ({
@ -26,6 +53,68 @@ const datesAreEqual = (left: any, right: any) => (
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) => (
typeof value === 'string' ? value.trim() : ''
);
@ -52,6 +141,7 @@ const hasContentDraftChanges = (draft: any) => (
const hasCourseDraftChanges = (draft: any) => (
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 }) {
const normalizedDates = normalizeDateRows(dates);
@ -195,6 +285,22 @@ export default function VtgWasher() {
};
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 => ({
facility_id: d.id,
vtg_datoer: d.edit_datoer
@ -214,7 +320,8 @@ export default function VtgWasher() {
setSelectedIds([]);
fetchDrafts();
} 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) {
alert("Nettverksfeil");
@ -266,6 +373,7 @@ export default function VtgWasher() {
const descriptionChanged = textValue(draft.vtg_beskrivelse) !== textValue(draft.edit_beskrivelse);
const datesChanged = !datesAreEqual(draft.vtg_datoer, draft.edit_datoer);
const contentChangedCount = [priceChanged, descriptionChanged].filter(Boolean).length;
const invalidCourseRows = getInvalidDateRows(normalizeDateRows(draft.edit_datoer));
const changedCount = [priceChanged, descriptionChanged, datesChanged].filter(Boolean).length;
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>}
{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>}
{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>
<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>
<ReadOnlyDateList dates={draft.vtg_datoer} emptyLabel="Ingen kursdatoer registrert i dag." />
</div>
<div className={`rounded-2xl border p-4 ${datesChanged ? 'border-green-200 bg-green-50/60' : 'border-gray-200 bg-white'}`}>
<div className={`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="text-[10px] font-black uppercase tracking-widest text-gray-500">Forslag</div>
<span className={`rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-widest ${datesChanged ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}>
{datesChanged ? 'Har endringer' : 'Lik dagens kurs'}
<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'}`}>
{invalidCourseRows.length > 0 ? 'Må rettes' : datesChanged ? 'Har endringer' : 'Lik dagens kurs'}
</span>
</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">
{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>
) : (
draft.edit_datoer.map((row: any, idx: number) => (
<div key={idx} className="grid gap-2 rounded-lg border border-gray-200 bg-white p-3 relative group sm:grid-cols-[minmax(0,1fr)_150px_auto] sm:items-center">
<input className="w-full rounded border border-gray-100 p-2 text-xs font-bold outline-none focus:border-[#8bc34a]" value={row.dato} onChange={e => updateDateRow(draft.id, idx, 'dato', e.target.value)} placeholder="F.eks: 12.-14. mai" />
<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 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)}>
<option value="Ledig">Ledig</option>
<option value="Fulltegnet">Fulltegnet</option>
@ -361,6 +475,11 @@ export default function VtgWasher() {
<option value="Få plasser"> plasser</option>
</select>
<button onClick={() => removeDateRow(draft.id, idx)} className="btn btn-sm btn-danger sm:opacity-0 sm:group-hover:opacity-100" title="Slett dato"></button>
{!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>
))
)}

View file

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

View file

@ -6,25 +6,30 @@ import {
createCollectionPageJsonLd,
createPageMetadata,
} from "@/app/seo";
import { resolveSitePageSeo } from "@/app/pageSeo";
const pageTitle = "Banebesøk";
const pageDescription =
"Redaksjonelle artikler fra norske golfbaner, bygget for lange historier, sterke bilder og nyttige lenker videre til TeeOffs baneprofiler.";
export const metadata = createPageMetadata({
title: pageTitle,
description: pageDescription,
path: "/banebesok",
});
const fallbackPageTitle = "Golfreiser og banebesøk: Erfaringer fra norske golfbaner | TeeOff.no";
const fallbackPageDescription =
"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 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() {
const articles = await getCourseVisits();
const seo = await resolveSitePageSeo("banebesok", fallbackPageTitle, fallbackPageDescription);
const collectionJsonLd = createCollectionPageJsonLd({
name: pageTitle,
description: pageDescription,
name: seo.title,
description: seo.description,
path: "/banebesok",
});

View file

@ -68,8 +68,8 @@ export async function generateMetadata({ params }: GolfCoursePageProps): Promise
});
}
const fallbackTitle = `${facility.name}: banestatus, priser og info`;
const fallbackDescription = `Se banestatus, priser, kontaktinfo, kart og praktisk informasjon for ${facility.name} på TeeOff.no.`;
const fallbackTitle = `${facility.name}: Banestatus, greenfee og baneguide | 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({
title: resolveSeoTitle(facility.meta_title, fallbackTitle),

View file

@ -12,17 +12,23 @@ import {
export const revalidate = 900;
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 =
"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() {
const seo = await resolveSitePageSeo("golfbaner", fallbackPageTitle, fallbackPageDescription);
return createPageMetadata({
const metadata = createPageMetadata({
title: seo.title,
description: seo.description,
path: "/golfbaner",
});
return {
...metadata,
alternates: {
canonical: "/",
},
};
}
export default async function GolfCoursesIndexPage() {

View file

@ -8,9 +8,9 @@ import {
createPageMetadata,
} from "@/app/seo";
const pageTitle = "Klubbnummer i Golfbox";
const pageTitle = "NGF Klubbnummer: Oversikt for Golfbox og Gimmie | TeeOff.no";
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({
title: pageTitle,

View file

@ -10,9 +10,10 @@ import {
export const revalidate = 1800;
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 =
"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() {
const seo = await resolveSitePageSeo("medlemskap", fallbackPageTitle, fallbackPageDescription);
@ -59,12 +60,41 @@ export default async function MembershipPage() {
Medlemskap
</p>
<h1 className="text-5xl font-black text-[#112015] sm:text-6xl">
Dette koster medlemskap i norske golfklubber
Sammenlign priser golfmedlemskap i Norge
</h1>
<p className="mt-6 text-base leading-7 text-[#4F5F50] sm:text-lg">
Velg hvilken type medlemskap du vil sammenligne under. Hver rad kan åpnes for flere
detaljer, sist oppdatert-dato og lenke til klubbens egen innmelding.
<div className="mt-6 max-w-4xl space-y-5 text-base leading-7 text-[#4F5F50] sm:text-lg">
<p>Beløpene oppdateres fortløpende, snart jeg oppdager endringer.</p>
<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>
<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>
</section>

View file

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

View file

@ -6,25 +6,30 @@ import {
createCollectionPageJsonLd,
createPageMetadata,
} from "@/app/seo";
import { resolveSitePageSeo } from "@/app/pageSeo";
const pageTitle = "Meninger";
const pageDescription =
"Redaksjonelle artikler, siste nytt og kommentarer fra TeeOff, samlet i én egen seksjon.";
export const metadata = createPageMetadata({
title: pageTitle,
description: pageDescription,
path: "/meninger",
});
const fallbackPageTitle = "Golfblogg: Meninger, humor og skråblikk på golf-Norge | TeeOff.no";
const fallbackPageDescription =
"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 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() {
const articles = await getOpinionArticles();
const seo = await resolveSitePageSeo("meninger", fallbackPageTitle, fallbackPageDescription);
const collectionJsonLd = createCollectionPageJsonLd({
name: pageTitle,
description: pageDescription,
name: seo.title,
description: seo.description,
path: "/meninger",
});

View file

@ -7,9 +7,9 @@ import { createPageMetadata } from "@/app/seo";
export const revalidate = 900;
export const dynamic = "force-dynamic";
export const metadata = createPageMetadata({
title: "Komplett oversikt over ALLE norske golfbaner",
title: "Golfbaner i Norge: Komplett oversikt over alle baner | TeeOff.no",
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: "/",
});

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",
}: MetadataInput): Metadata {
const ogImage = resolveImageUrl(image);
const normalizedTitle = String(title || "").trim();
const metadataTitle = /\|\s*TeeOff\.no\s*$/i.test(normalizedTitle)
? { absolute: normalizedTitle }
: normalizedTitle;
return {
title,
title: metadataTitle,
description,
alternates: {
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 { API_URL } from "@/config/constants";
import { getAvailablePlaceConfigs } from "@/app/facilityData";
@ -10,78 +12,158 @@ type SitemapFacility = {
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 dynamic = "force-dynamic";
const staticRoutes: MetadataRoute.Sitemap = [
const staticRouteConfigs: StaticRouteConfig[] = [
{
url: buildAbsoluteUrl("/"),
lastModified: new Date(),
path: "/",
changeFrequency: "daily",
priority: 1,
sourceFiles: ["src/app/page.tsx"],
},
{
url: buildAbsoluteUrl("/golfbaner"),
lastModified: new Date(),
path: "/golfbaner",
changeFrequency: "daily",
priority: 0.95,
sourceFiles: ["src/app/golfbaner/page.tsx", "src/app/pageSeo.ts"],
sitePageKey: "golfbaner",
},
{
url: buildAbsoluteUrl("/medlemskap"),
lastModified: new Date(),
path: "/medlemskap",
changeFrequency: "daily",
priority: 0.8,
sourceFiles: ["src/app/medlemskap/page.tsx", "src/app/pageSeo.ts"],
sitePageKey: "medlemskap",
},
{
url: buildAbsoluteUrl("/vtg"),
lastModified: new Date(),
path: "/vtg",
changeFrequency: "daily",
priority: 0.8,
sourceFiles: ["src/app/vtg/page.tsx", "src/app/pageSeo.ts"],
sitePageKey: "vtg",
},
{
url: buildAbsoluteUrl("/banebesok"),
lastModified: new Date(),
path: "/banebesok",
changeFrequency: "weekly",
priority: 0.72,
sourceFiles: ["src/app/banebesok/page.tsx", "src/app/pageSeo.ts"],
sitePageKey: "banebesok",
},
{
url: buildAbsoluteUrl("/meninger"),
lastModified: new Date(),
path: "/meninger",
changeFrequency: "weekly",
priority: 0.7,
sourceFiles: ["src/app/meninger/page.tsx", "src/app/pageSeo.ts"],
sitePageKey: "meninger",
},
{
url: buildAbsoluteUrl("/turneringer"),
lastModified: new Date(),
path: "/turneringer",
changeFrequency: "daily",
priority: 0.68,
sourceFiles: ["src/app/turneringer/page.tsx"],
},
{
url: buildAbsoluteUrl("/klubbnummer"),
lastModified: new Date(),
path: "/klubbnummer",
changeFrequency: "weekly",
priority: 0.64,
sourceFiles: ["src/app/klubbnummer/page.tsx"],
},
{
url: buildAbsoluteUrl("/om"),
lastModified: new Date(),
path: "/om",
changeFrequency: "monthly",
priority: 0.45,
sourceFiles: ["src/app/om/page.tsx"],
},
{
url: buildAbsoluteUrl("/kontakt"),
lastModified: new Date(),
path: "/kontakt",
changeFrequency: "monthly",
priority: 0.42,
sourceFiles: ["src/app/kontakt/page.tsx"],
},
{
url: buildAbsoluteUrl("/personvern-og-cookies"),
lastModified: new Date(),
path: "/personvern-og-cookies",
changeFrequency: "monthly",
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> {
let facilities: SitemapFacility[] = [];
@ -97,18 +179,49 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
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}`),
lastModified: new Date(),
lastModified:
maxDate([await fetchPlacePageUpdatedAt(slug), placeSourceLastModified]) || new Date(),
changeFrequency: "daily" as const,
priority: slug === "norge" ? 0.9 : 0.75,
}));
})),
);
const facilityPageSourceLastModified = getSourceLastModified([
"src/app/golfbaner/[slug]/page.tsx",
]);
const facilityRoutes = facilities
.filter((facility) => Boolean(facility.slug))
.map((facility) => ({
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,
priority: 0.7,
}));

View file

@ -23,6 +23,10 @@ import {
resolveSeoDescription,
resolveSeoTitle,
} from "@/app/seo";
import {
buildDefaultPlaceMetaDescription,
buildDefaultPlaceMetaTitle,
} from "@/app/placeSeo";
import { fetchPublicFacilities } from "@/app/publicFacilities";
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({
params,
}: {
@ -131,11 +137,21 @@ export async function generateMetadata({
});
}
const facilities = await fetchPlaceFacilities();
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({
title: resolveSeoTitle(placePage?.meta_title, place.title),
title: resolveSeoTitle(placePage?.meta_title, fallbackTitle),
description: resolveSeoDescription(placePage?.meta_description, fallbackDescription),
path: `/sted/${slug}`,
});
@ -151,7 +167,7 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
let placePage: PlacePageData | null = null;
const facilities = await fetchPublicFacilities<FacilityRecord>("place", revalidate);
const facilities = await fetchPlaceFacilities();
try {
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.`
: null;
const collectionJsonLd = createCollectionPageJsonLd({
name: resolveSeoTitle(placePage?.meta_title, place.title),
name: resolveSeoTitle(
placePage?.meta_title,
buildDefaultPlaceMetaTitle(placeStats.facilityCount, place.label, placePreposition),
),
description: resolveSeoDescription(
placePage?.meta_description,
`${place.intro} TeeOff samler golfbaner i ${place.label} med oppdatert banestatus og baneprofiler.`,
buildDefaultPlaceMetaDescription(place.label, placePreposition),
),
path: `/sted/${slug}`,
});
const itemListJsonLd = createItemListJsonLd({
name: resolveSeoTitle(placePage?.meta_title, place.title),
name: resolveSeoTitle(
placePage?.meta_title,
buildDefaultPlaceMetaTitle(placeStats.facilityCount, place.label, placePreposition),
),
path: `/sted/${slug}`,
items: facilitiesInPlace
.filter((facility) => facility?.slug && facility?.name)

View file

@ -11,7 +11,9 @@ import {
export const dynamic = "force-dynamic";
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!";
function renderBlock(block: CourseVisitBodyBlock, index: number) {
if (block.type !== "richText") {
@ -38,7 +40,7 @@ export async function generateMetadata() {
return createPageMetadata({
title: pageTitle,
description: article?.description || "Viktige turneringslenker i Golfbox samlet på TeeOff.",
description: pageDescription,
path: "/turneringer",
image: article?.heroImages[0]?.src,
});
@ -53,7 +55,7 @@ export default async function TournamentsPage() {
const collectionJsonLd = createCollectionPageJsonLd({
name: pageTitle,
description: article.description,
description: pageDescription,
path: "/turneringer",
});
const breadcrumbJsonLd = createBreadcrumbJsonLd([

View file

@ -11,9 +11,9 @@ import {
export const revalidate = 1800;
export const dynamic = "force-dynamic";
const fallbackPageTitle = "Veien til Golf";
const fallbackPageTitle = "Veien til Golf: Finn golfkurs for nybegynnere (VTG) | TeeOff.no";
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() {
const seo = await resolveSitePageSeo("vtg", fallbackPageTitle, fallbackPageDescription);

View file

@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
import path from "node:path";
import { cookies } from "next/headers";
import { API_URL } from "@/config/constants";
import importedMeninger from "@/content/importedMeninger.json";
export type ArticleSection = "banebesok" | "meninger";
@ -58,6 +59,8 @@ export type EditorialArticle = {
slug: string;
eyebrow: string;
title: string;
metaTitle?: string;
metaDescription?: string;
description: string;
excerpt: string;
locationLabel: string;
@ -84,6 +87,8 @@ type ArticleApiRecord = {
status?: string | null;
slug: string;
title: string;
meta_title?: string | null;
meta_description?: string | null;
description?: string | null;
excerpt?: string | null;
eyebrow?: string | null;
@ -106,6 +111,25 @@ type FacilityMeta = {
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> = {
"lofoten-golfklubb": { name: "Lofoten Golfklubb", region: "Nordland" },
"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";
}
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) {
return `/${section}/${slug}`;
}
@ -534,6 +563,8 @@ function mapApiArticle(entry: ArticleApiRecord): EditorialArticle {
slug: entry.slug,
eyebrow: String(entry.eyebrow || "").trim() || getSectionLabel(section),
title: entry.title,
metaTitle: String(entry.meta_title || "").trim() || undefined,
metaDescription: String(entry.meta_description || "").trim() || undefined,
description: String(entry.description || "").trim() || excerpt,
excerpt,
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) {
const response = await fetch(`${API_URL}/articles?section=${section}`, { cache: "no-store" });
if (!response.ok) {
@ -644,6 +776,10 @@ export async function getEditorialArticles(section: ArticleSection) {
// Returnerer tom liste dersom API-et ikke er tilgjengelig.
}
if (section === "meninger") {
return getImportedOpinionArticles();
}
return [];
}
@ -672,6 +808,10 @@ export async function getEditorialArticleBySlug(
// Returnerer null dersom API-et ikke er tilgjengelig.
}
if (section === "meninger") {
return getImportedOpinionArticleBySlug(slug);
}
return null;
}