Før generering av sidemal.
This commit is contained in:
parent
228aa3590c
commit
3375535366
23 changed files with 798 additions and 115 deletions
BIN
2026-04-28_135017.png
Normal file
BIN
2026-04-28_135017.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
: "";
|
||||
|
|
|
|||
|
|
@ -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 på de store landingssidene uten å endre H1 eller innholdstekst.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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">Få plasser</option>
|
||||
</select>
|
||||
<button onClick={() => removeDateRow(draft.id, idx)} className="btn btn-sm btn-danger sm:opacity-0 sm:group-hover:opacity-100" title="Slett dato">✕</button>
|
||||
{!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>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 på 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.
|
||||
</p>
|
||||
<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, så 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 så mye jeg ønsker på denne banen?
|
||||
</p>
|
||||
<p>
|
||||
<strong>Billigst mulig:</strong> Hva vil det koste meg å være medlem her, dersom
|
||||
jeg aksepterer at jeg må betale greenfee hver runde? (Medlemskapet skal også gi
|
||||
rett til greenfeespill på andre baner.) Dette er ofte kjent som fjernmedlemskap.
|
||||
</p>
|
||||
<p>Svarene på 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 på detaljene.
|
||||
</p>
|
||||
<p>
|
||||
Bruk listene for hva de er verdt, men husk: Har du anledning, så 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
|
||||
"greenfee", så er det i denne sammenhengen bare navnet på en type
|
||||
medlemskap klubben tilbyr.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: "/",
|
||||
});
|
||||
|
||||
|
|
|
|||
16
frontend/src/app/placeSeo.ts
Normal file
16
frontend/src/app/placeSeo.ts
Normal 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!`;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
url: buildAbsoluteUrl(`/sted/${slug}`),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily" as const,
|
||||
priority: slug === "norge" ? 0.9 : 0.75,
|
||||
}));
|
||||
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:
|
||||
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,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue