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,
|
log_course_status_change,
|
||||||
)
|
)
|
||||||
from env_config import get_database_url, get_required_env
|
from env_config import get_database_url, get_required_env
|
||||||
from vtg_courses import filter_upcoming_courses, normalize_vtg_course_rows
|
from vtg_courses import filter_upcoming_courses, get_invalid_vtg_course_labels, normalize_vtg_course_rows
|
||||||
from weather_forecast import ensure_weather_forecast_table, weather_sync_loop
|
from weather_forecast import ensure_weather_forecast_table, weather_sync_loop
|
||||||
|
|
||||||
# --- KONFIGURASJON ---
|
# --- KONFIGURASJON ---
|
||||||
|
|
@ -303,6 +303,15 @@ def collect_facility_indexnow_urls(slugs: list[str], extra_paths: list[str] | No
|
||||||
return dedupe_strings(urls)
|
return dedupe_strings(urls)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_page_indexnow_urls(paths: list[str]) -> list[str]:
|
||||||
|
urls: list[str] = []
|
||||||
|
for path in paths:
|
||||||
|
public_url = build_absolute_public_url(path)
|
||||||
|
if public_url:
|
||||||
|
urls.append(public_url)
|
||||||
|
return dedupe_strings(urls)
|
||||||
|
|
||||||
|
|
||||||
def get_indexnow_key_location() -> str | None:
|
def get_indexnow_key_location() -> str | None:
|
||||||
configured = INDEXNOW_KEY_LOCATION.strip()
|
configured = INDEXNOW_KEY_LOCATION.strip()
|
||||||
if configured:
|
if configured:
|
||||||
|
|
@ -760,6 +769,8 @@ class ArticleUpsertRequest(BaseModel):
|
||||||
section: Optional[str] = "banebesok"
|
section: Optional[str] = "banebesok"
|
||||||
slug: str
|
slug: str
|
||||||
title: str
|
title: str
|
||||||
|
meta_title: Optional[str] = None
|
||||||
|
meta_description: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
excerpt: Optional[str] = None
|
excerpt: Optional[str] = None
|
||||||
eyebrow: Optional[str] = None
|
eyebrow: Optional[str] = None
|
||||||
|
|
@ -1044,6 +1055,28 @@ async def replace_facility_vtg_courses(conn, facility_id: int, rows: Any) -> lis
|
||||||
return normalized_rows
|
return normalized_rows
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_valid_vtg_course_rows(approvals: list[Any]) -> None:
|
||||||
|
invalid_entries: list[str] = []
|
||||||
|
for approval in approvals:
|
||||||
|
invalid_labels = get_invalid_vtg_course_labels(getattr(approval, "vtg_datoer", None))
|
||||||
|
if invalid_labels:
|
||||||
|
preview = ", ".join(invalid_labels[:3])
|
||||||
|
if len(invalid_labels) > 3:
|
||||||
|
preview = f"{preview}, +{len(invalid_labels) - 3} til"
|
||||||
|
invalid_entries.append(
|
||||||
|
f"anlegg {getattr(approval, 'facility_id', 'ukjent')}: {preview}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if invalid_entries:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
"Kun kurs med gyldige datoer kan godkjennes. Ugyldige kursrader funnet for "
|
||||||
|
+ "; ".join(invalid_entries)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def format_place_page_row(row):
|
def format_place_page_row(row):
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -2461,6 +2494,8 @@ async def ensure_articles_table(conn):
|
||||||
section VARCHAR(32) NOT NULL DEFAULT 'banebesok',
|
section VARCHAR(32) NOT NULL DEFAULT 'banebesok',
|
||||||
slug VARCHAR(255) UNIQUE NOT NULL,
|
slug VARCHAR(255) UNIQUE NOT NULL,
|
||||||
title VARCHAR(255) NOT NULL,
|
title VARCHAR(255) NOT NULL,
|
||||||
|
meta_title TEXT,
|
||||||
|
meta_description TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
excerpt TEXT,
|
excerpt TEXT,
|
||||||
eyebrow VARCHAR(120) DEFAULT 'Banebesøk',
|
eyebrow VARCHAR(120) DEFAULT 'Banebesøk',
|
||||||
|
|
@ -2492,6 +2527,14 @@ async def ensure_articles_table(conn):
|
||||||
ALTER TABLE articles
|
ALTER TABLE articles
|
||||||
ADD COLUMN IF NOT EXISTS featured_media_id VARCHAR(255)
|
ADD COLUMN IF NOT EXISTS featured_media_id VARCHAR(255)
|
||||||
""")
|
""")
|
||||||
|
await conn.execute("""
|
||||||
|
ALTER TABLE articles
|
||||||
|
ADD COLUMN IF NOT EXISTS meta_title TEXT
|
||||||
|
""")
|
||||||
|
await conn.execute("""
|
||||||
|
ALTER TABLE articles
|
||||||
|
ADD COLUMN IF NOT EXISTS meta_description TEXT
|
||||||
|
""")
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
UPDATE articles
|
UPDATE articles
|
||||||
SET section = 'banebesok'
|
SET section = 'banebesok'
|
||||||
|
|
@ -3858,7 +3901,7 @@ async def get_place_page(slug: str, response: Response):
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
VALID_SITE_PAGE_SEO_KEYS = {"golfbaner", "vtg", "medlemskap", "simulatorer"}
|
VALID_SITE_PAGE_SEO_KEYS = {"golfbaner", "vtg", "medlemskap", "banebesok", "meninger", "simulatorer"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/page-seo/{page_key}")
|
@app.get("/api/page-seo/{page_key}")
|
||||||
|
|
@ -4082,6 +4125,10 @@ async def update_admin_place_page(slug: str, request: PlacePageUpsertRequest):
|
||||||
)
|
)
|
||||||
|
|
||||||
invalidate_public_api_caches(include_place_pages=True)
|
invalidate_public_api_caches(include_place_pages=True)
|
||||||
|
schedule_indexnow_submission(
|
||||||
|
collect_page_indexnow_urls([f"/sted/{normalized_slug}"]),
|
||||||
|
reason="admin place page upsert",
|
||||||
|
)
|
||||||
return format_place_page_row(row)
|
return format_place_page_row(row)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -4131,6 +4178,18 @@ async def update_admin_site_page_seo(page_key: str, request: SitePageSeoUpsertRe
|
||||||
normalize_optional_text(request.meta_description),
|
normalize_optional_text(request.meta_description),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
page_path_map = {
|
||||||
|
"golfbaner": "/golfbaner",
|
||||||
|
"vtg": "/vtg",
|
||||||
|
"medlemskap": "/medlemskap",
|
||||||
|
"banebesok": "/banebesok",
|
||||||
|
"meninger": "/meninger",
|
||||||
|
"simulatorer": "/simulatorer",
|
||||||
|
}
|
||||||
|
schedule_indexnow_submission(
|
||||||
|
collect_page_indexnow_urls([page_path_map[normalized_key]]),
|
||||||
|
reason="admin site page seo upsert",
|
||||||
|
)
|
||||||
return format_site_page_seo_row(row)
|
return format_site_page_seo_row(row)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -4577,17 +4636,19 @@ async def upsert_admin_article(request: ArticleUpsertRequest):
|
||||||
)
|
)
|
||||||
row = await conn.fetchrow("""
|
row = await conn.fetchrow("""
|
||||||
INSERT INTO articles (
|
INSERT INTO articles (
|
||||||
section, slug, title, description, excerpt, eyebrow, location_label,
|
section, slug, title, meta_title, meta_description, description, excerpt, eyebrow, location_label,
|
||||||
facility_name, facility_slug, author_name, status, hero_images,
|
facility_name, facility_slug, author_name, status, hero_images,
|
||||||
media_gallery, featured_media_id, content_html, source_url, source_label, published_at, updated_at
|
media_gallery, featured_media_id, content_html, source_url, source_label, published_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7,
|
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||||
$8, $9, $10, $11, $12::jsonb,
|
$10, $11, $12, $13, $14::jsonb,
|
||||||
$13::jsonb, $14, $15, $16, $17, $18, $19
|
$15::jsonb, $16, $17, $18, $19, $20, $21
|
||||||
)
|
)
|
||||||
ON CONFLICT (slug) DO UPDATE SET
|
ON CONFLICT (slug) DO UPDATE SET
|
||||||
section = EXCLUDED.section,
|
section = EXCLUDED.section,
|
||||||
title = EXCLUDED.title,
|
title = EXCLUDED.title,
|
||||||
|
meta_title = EXCLUDED.meta_title,
|
||||||
|
meta_description = EXCLUDED.meta_description,
|
||||||
description = EXCLUDED.description,
|
description = EXCLUDED.description,
|
||||||
excerpt = EXCLUDED.excerpt,
|
excerpt = EXCLUDED.excerpt,
|
||||||
eyebrow = EXCLUDED.eyebrow,
|
eyebrow = EXCLUDED.eyebrow,
|
||||||
|
|
@ -4609,6 +4670,8 @@ async def upsert_admin_article(request: ArticleUpsertRequest):
|
||||||
section,
|
section,
|
||||||
requested_slug,
|
requested_slug,
|
||||||
request.title.strip(),
|
request.title.strip(),
|
||||||
|
(request.meta_title or "").strip() or None,
|
||||||
|
(request.meta_description or "").strip() or None,
|
||||||
(request.description or "").strip() or None,
|
(request.description or "").strip() or None,
|
||||||
(request.excerpt or "").strip() or None,
|
(request.excerpt or "").strip() or None,
|
||||||
(request.eyebrow or "Banebesøk").strip(),
|
(request.eyebrow or "Banebesøk").strip(),
|
||||||
|
|
@ -5077,6 +5140,7 @@ async def approve_vtg_content_bulk(request: BulkVtgContentRequest):
|
||||||
|
|
||||||
@app.post("/api/admin/vtg/approve-courses-bulk")
|
@app.post("/api/admin/vtg/approve-courses-bulk")
|
||||||
async def approve_vtg_courses_bulk(request: BulkVtgCoursesRequest):
|
async def approve_vtg_courses_bulk(request: BulkVtgCoursesRequest):
|
||||||
|
ensure_valid_vtg_course_rows(request.approvals)
|
||||||
facility_ids = [approval.facility_id for approval in request.approvals]
|
facility_ids = [approval.facility_id for approval in request.approvals]
|
||||||
async with app.state.pool.acquire() as conn:
|
async with app.state.pool.acquire() as conn:
|
||||||
async with conn.transaction():
|
async with conn.transaction():
|
||||||
|
|
@ -5108,6 +5172,7 @@ async def approve_vtg_courses_bulk(request: BulkVtgCoursesRequest):
|
||||||
@app.post("/api/admin/vtg/approve-bulk")
|
@app.post("/api/admin/vtg/approve-bulk")
|
||||||
async def approve_vtg_bulk(request: BulkVtgRequest):
|
async def approve_vtg_bulk(request: BulkVtgRequest):
|
||||||
"""Kompatibilitets-endepunkt som godkjenner både innhold og kurs."""
|
"""Kompatibilitets-endepunkt som godkjenner både innhold og kurs."""
|
||||||
|
ensure_valid_vtg_course_rows(request.approvals)
|
||||||
facility_ids = [approval.facility_id for approval in request.approvals]
|
facility_ids = [approval.facility_id for approval in request.approvals]
|
||||||
async with app.state.pool.acquire() as conn:
|
async with app.state.pool.acquire() as conn:
|
||||||
async with conn.transaction():
|
async with conn.transaction():
|
||||||
|
|
|
||||||
|
|
@ -200,3 +200,41 @@ def is_upcoming_course(row: dict[str, Any], today: date | None = None) -> bool:
|
||||||
def filter_upcoming_courses(rows: Any) -> list[dict[str, Any]]:
|
def filter_upcoming_courses(rows: Any) -> list[dict[str, Any]]:
|
||||||
normalized_rows = normalize_vtg_course_rows(rows)
|
normalized_rows = normalize_vtg_course_rows(rows)
|
||||||
return [row for row in normalized_rows if is_upcoming_course(row)]
|
return [row for row in normalized_rows if is_upcoming_course(row)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_invalid_vtg_course_labels(rows: Any) -> list[str]:
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
invalid_labels: list[str] = []
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
display_label = normalize_whitespace(str(row.get("dato") or row.get("display_label") or ""))
|
||||||
|
if not display_label:
|
||||||
|
continue
|
||||||
|
|
||||||
|
explicit_start = row.get("start_date")
|
||||||
|
explicit_end = row.get("end_date")
|
||||||
|
start_date = None
|
||||||
|
end_date = None
|
||||||
|
|
||||||
|
if explicit_start:
|
||||||
|
try:
|
||||||
|
start_date = datetime.fromisoformat(str(explicit_start)).date()
|
||||||
|
except ValueError:
|
||||||
|
start_date = None
|
||||||
|
if explicit_end:
|
||||||
|
try:
|
||||||
|
end_date = datetime.fromisoformat(str(explicit_end)).date()
|
||||||
|
except ValueError:
|
||||||
|
end_date = None
|
||||||
|
|
||||||
|
if not start_date and not end_date:
|
||||||
|
start_date, end_date = parse_course_date_range(display_label)
|
||||||
|
|
||||||
|
if not start_date and not end_date:
|
||||||
|
invalid_labels.append(display_label)
|
||||||
|
|
||||||
|
return invalid_labels
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import Link from "next/link";
|
||||||
import { type ChangeEvent, useEffect, useRef, useState } from "react";
|
import { type ChangeEvent, useEffect, useRef, useState } from "react";
|
||||||
import { API_URL } from "@/config/constants";
|
import { API_URL } from "@/config/constants";
|
||||||
import { adminFetch } from "@/config/adminFetch";
|
import { adminFetch } from "@/config/adminFetch";
|
||||||
|
import SeoFieldset, { trimSuggestion } from "@/components/admin/SeoFieldset";
|
||||||
import TiptapHtmlEditor from "@/components/TiptapHtmlEditor";
|
import TiptapHtmlEditor from "@/components/TiptapHtmlEditor";
|
||||||
|
|
||||||
type ArticleMediaItem = {
|
type ArticleMediaItem = {
|
||||||
|
|
@ -20,6 +21,8 @@ type AdminArticle = {
|
||||||
section?: "banebesok" | "meninger";
|
section?: "banebesok" | "meninger";
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
meta_title?: string | null;
|
||||||
|
meta_description?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
excerpt?: string | null;
|
excerpt?: string | null;
|
||||||
eyebrow?: string | null;
|
eyebrow?: string | null;
|
||||||
|
|
@ -52,6 +55,8 @@ type ArticleFormState = {
|
||||||
section: "banebesok" | "meninger";
|
section: "banebesok" | "meninger";
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
meta_title: string;
|
||||||
|
meta_description: string;
|
||||||
description: string;
|
description: string;
|
||||||
excerpt: string;
|
excerpt: string;
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
|
|
@ -114,6 +119,8 @@ function createEmptyForm(): ArticleFormState {
|
||||||
section: "banebesok",
|
section: "banebesok",
|
||||||
slug: "",
|
slug: "",
|
||||||
title: "",
|
title: "",
|
||||||
|
meta_title: "",
|
||||||
|
meta_description: "",
|
||||||
description: "",
|
description: "",
|
||||||
excerpt: "",
|
excerpt: "",
|
||||||
eyebrow: "Banebesøk",
|
eyebrow: "Banebesøk",
|
||||||
|
|
@ -144,6 +151,8 @@ function articleToForm(article: AdminArticle): ArticleFormState {
|
||||||
section: article.section || "banebesok",
|
section: article.section || "banebesok",
|
||||||
slug: article.slug || "",
|
slug: article.slug || "",
|
||||||
title: article.title || "",
|
title: article.title || "",
|
||||||
|
meta_title: article.meta_title || "",
|
||||||
|
meta_description: article.meta_description || "",
|
||||||
description: article.description || "",
|
description: article.description || "",
|
||||||
excerpt: article.excerpt || "",
|
excerpt: article.excerpt || "",
|
||||||
eyebrow: article.eyebrow || "Banebesøk",
|
eyebrow: article.eyebrow || "Banebesøk",
|
||||||
|
|
@ -182,6 +191,8 @@ function buildArticlePayload(
|
||||||
section: form.section,
|
section: form.section,
|
||||||
slug: form.slug.trim(),
|
slug: form.slug.trim(),
|
||||||
title: form.title.trim(),
|
title: form.title.trim(),
|
||||||
|
meta_title: form.meta_title.trim(),
|
||||||
|
meta_description: form.meta_description.trim(),
|
||||||
description: form.description.trim(),
|
description: form.description.trim(),
|
||||||
excerpt: form.excerpt.trim(),
|
excerpt: form.excerpt.trim(),
|
||||||
eyebrow: form.eyebrow.trim(),
|
eyebrow: form.eyebrow.trim(),
|
||||||
|
|
@ -222,6 +233,14 @@ export default function AdminArticlesPage() {
|
||||||
const heroImageInputRef = useRef<HTMLInputElement | null>(null);
|
const heroImageInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const formSectionRef = useRef<HTMLElement | null>(null);
|
const formSectionRef = useRef<HTMLElement | null>(null);
|
||||||
const titleInputRef = useRef<HTMLInputElement | null>(null);
|
const titleInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const articleSeoTitleSuggestion = trimSuggestion(
|
||||||
|
form.title.trim() ? `${form.title.trim()} | TeeOff.no` : "",
|
||||||
|
60,
|
||||||
|
);
|
||||||
|
const articleSeoDescriptionSuggestion = trimSuggestion(
|
||||||
|
form.description.trim() || form.excerpt.trim() || form.content_html,
|
||||||
|
160,
|
||||||
|
);
|
||||||
|
|
||||||
const focusEditor = () => {
|
const focusEditor = () => {
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
|
|
@ -797,17 +816,28 @@ export default function AdminArticlesPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 grid gap-5">
|
<div className="mt-5 grid gap-5">
|
||||||
|
<SeoFieldset
|
||||||
|
titleValue={form.meta_title}
|
||||||
|
onTitleChange={(value) => handleFieldChange("meta_title", value)}
|
||||||
|
descriptionValue={form.meta_description}
|
||||||
|
onDescriptionChange={(value) => handleFieldChange("meta_description", value)}
|
||||||
|
suggestedTitle={articleSeoTitleSuggestion}
|
||||||
|
suggestedDescription={articleSeoDescriptionSuggestion}
|
||||||
|
titlePlaceholder="Egen meta title for søkeresultater"
|
||||||
|
descriptionPlaceholder="Egen meta description for søkeresultater"
|
||||||
|
helperText="Hvis disse feltene er tomme, faller artikkelen tilbake til vanlig tittel og kort beskrivelse."
|
||||||
|
/>
|
||||||
<label className="flex flex-col gap-2">
|
<label className="flex flex-col gap-2">
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Meta-beskrivelse</span>
|
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Kort beskrivelse</span>
|
||||||
<textarea
|
<textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
value={form.description}
|
value={form.description}
|
||||||
onChange={(event) => handleFieldChange("description", event.target.value)}
|
onChange={(event) => handleFieldChange("description", event.target.value)}
|
||||||
placeholder="Kort SEO-/delingsbeskrivelse av artikkelen"
|
placeholder="Kort beskrivelse av artikkelen som brukes som fallback i metadata og strukturerte data"
|
||||||
className="rounded-[1.3rem] border border-[#112015]/10 bg-white px-4 py-3 text-base text-[#112015] outline-none focus:border-[#8BC34A]"
|
className="rounded-[1.3rem] border border-[#112015]/10 bg-white px-4 py-3 text-base text-[#112015] outline-none focus:border-[#8BC34A]"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm leading-6 text-[#536256]">
|
<p className="text-sm leading-6 text-[#536256]">
|
||||||
Brukes primært som beskrivelse i metadata, delinger og søk.
|
Brukes som fallback hvis egen meta description ikke er satt.
|
||||||
</p>
|
</p>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-2">
|
<label className="flex flex-col gap-2">
|
||||||
|
|
|
||||||
|
|
@ -782,13 +782,12 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
};
|
};
|
||||||
|
|
||||||
const seoFacilityName = String(formData.name || "").trim();
|
const seoFacilityName = String(formData.name || "").trim();
|
||||||
const facilityCity = String(formData.city || "").trim();
|
|
||||||
const facilitySuggestedTitle = seoFacilityName
|
const facilitySuggestedTitle = seoFacilityName
|
||||||
? `${seoFacilityName}: banestatus, greenfee og info`
|
? `${seoFacilityName}: Banestatus, greenfee og baneguide | TeeOff.no`
|
||||||
: "";
|
: "";
|
||||||
const facilitySuggestedDescription = seoFacilityName
|
const facilitySuggestedDescription = seoFacilityName
|
||||||
? trimSuggestion(
|
? trimSuggestion(
|
||||||
`Se banestatus, greenfee, kontaktinfo, kart og praktisk informasjon for ${seoFacilityName}${facilityCity ? ` i ${facilityCity}` : ""} på TeeOff.no.`,
|
`Er ${seoFacilityName} åpen? Se oppdatert banestatus, priser, vær og kart. Planlegg runden din med bilder og praktisk info på TeeOff.no!`,
|
||||||
160,
|
160,
|
||||||
)
|
)
|
||||||
: "";
|
: "";
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,20 @@ import Link from "next/link";
|
||||||
import AdminMobileMenu from "@/components/AdminMobileMenu";
|
import AdminMobileMenu from "@/components/AdminMobileMenu";
|
||||||
import TiptapHtmlEditor from "@/components/TiptapHtmlEditor";
|
import TiptapHtmlEditor from "@/components/TiptapHtmlEditor";
|
||||||
import {
|
import {
|
||||||
|
enrichFacilities,
|
||||||
|
filterFacilitiesByArea,
|
||||||
HIERARCHICAL_AREA_OPTIONS,
|
HIERARCHICAL_AREA_OPTIONS,
|
||||||
getPlaceConfigFromSlug,
|
getPlaceConfigFromSlug,
|
||||||
getPlacePreposition,
|
getPlacePreposition,
|
||||||
|
type FacilityRecord,
|
||||||
} from "@/app/facilityData";
|
} from "@/app/facilityData";
|
||||||
import { adminFetch } from "@/config/adminFetch";
|
import { adminFetch } from "@/config/adminFetch";
|
||||||
import { API_URL } from "@/config/constants";
|
import { API_URL } from "@/config/constants";
|
||||||
import SeoFieldset, { trimSuggestion } from "@/components/admin/SeoFieldset";
|
import SeoFieldset, { trimSuggestion } from "@/components/admin/SeoFieldset";
|
||||||
|
import {
|
||||||
|
buildDefaultPlaceMetaDescription,
|
||||||
|
buildDefaultPlaceMetaTitle,
|
||||||
|
} from "@/app/placeSeo";
|
||||||
|
|
||||||
type PlacePageResponse = {
|
type PlacePageResponse = {
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|
@ -34,6 +41,8 @@ const SITE_PAGE_OPTIONS = [
|
||||||
{ key: "golfbaner", label: "/golfbaner" },
|
{ key: "golfbaner", label: "/golfbaner" },
|
||||||
{ key: "vtg", label: "/vtg" },
|
{ key: "vtg", label: "/vtg" },
|
||||||
{ key: "medlemskap", label: "/medlemskap" },
|
{ key: "medlemskap", label: "/medlemskap" },
|
||||||
|
{ key: "banebesok", label: "/banebesok" },
|
||||||
|
{ key: "meninger", label: "/meninger" },
|
||||||
{ key: "simulatorer", label: "/simulatorer (fremtidig)" },
|
{ key: "simulatorer", label: "/simulatorer (fremtidig)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -42,19 +51,29 @@ const SITE_PAGE_SEO_SUGGESTIONS: Record<
|
||||||
{ title: string; description: string }
|
{ title: string; description: string }
|
||||||
> = {
|
> = {
|
||||||
golfbaner: {
|
golfbaner: {
|
||||||
title: "Golfbaner i Norge",
|
title: "Alle norske golfbaner: Finn din neste runde på gress | TeeOff.no",
|
||||||
description:
|
description:
|
||||||
"Finn golfbaner i Norge og filtrer på område, banestatus, antall hull og fasiliteter i TeeOffs samlede oversikt.",
|
"Planlegg din neste golfrunde. Se komplett oversikt over alle norske golfbaner med oppdatert banestatus, greenfee-priser og kart på TeeOff.no.",
|
||||||
},
|
},
|
||||||
vtg: {
|
vtg: {
|
||||||
title: "Veien til Golf",
|
title: "Veien til Golf: Finn golfkurs for nybegynnere (VTG) | TeeOff.no",
|
||||||
description:
|
description:
|
||||||
"Finn Veien til Golf-kurs etter område, klubb og neste kursdato i TeeOffs VTG-oversikt.",
|
"Vil du begynne med golf? Finn komplett oversikt over Veien til Golf-kurs (VTG) i hele Norge. Se kursdatoer, priser og finn din nærmeste klubb på TeeOff.no!",
|
||||||
},
|
},
|
||||||
medlemskap: {
|
medlemskap: {
|
||||||
title: "Medlemskap i norske golfklubber",
|
title: "Billig golfmedlemskap? Finn og sammenlign priser på alle klubber | TeeOff.no",
|
||||||
description:
|
description:
|
||||||
"Sammenlign priser på medlemskap i norske golfklubber, både full spillerett og rimeligste nasjonale alternativ.",
|
"Hvor er det billigst å være medlem? Sammenlign priser på golfmedlemskap med full spillerett eller rimelige nasjonale alternativ (fjernmedlemskap) i Norge på TeeOff.no.",
|
||||||
|
},
|
||||||
|
banebesok: {
|
||||||
|
title: "Golfreiser og banebesøk: Erfaringer fra norske golfbaner | TeeOff.no",
|
||||||
|
description:
|
||||||
|
"Bli med på tur! Vi besøker Norges vakreste golfbaner og deler ærlige reiseskildringer, spektakulære bilder og nyttige tips. Finn inspirasjon til din neste golfreise her.",
|
||||||
|
},
|
||||||
|
meninger: {
|
||||||
|
title: "Golfblogg: Meninger, humor og skråblikk på golf-Norge | TeeOff.no",
|
||||||
|
description:
|
||||||
|
"Fra frustrasjon over saktespill til gleden over en perfekt drive. Les TeeOffs egne artikler, kommentarer og ærlige skråblikk på livet som golfer i Norge.",
|
||||||
},
|
},
|
||||||
simulatorer: {
|
simulatorer: {
|
||||||
title: "Golfsimulatorer i Norge",
|
title: "Golfsimulatorer i Norge",
|
||||||
|
|
@ -85,6 +104,7 @@ export default function AdminPlacePagesPage() {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [feedback, setFeedback] = useState("");
|
const [feedback, setFeedback] = useState("");
|
||||||
|
const [placeFacilities, setPlaceFacilities] = useState<FacilityRecord[]>([]);
|
||||||
const [selectedPageKey, setSelectedPageKey] = useState(DEFAULT_PAGE_KEY);
|
const [selectedPageKey, setSelectedPageKey] = useState(DEFAULT_PAGE_KEY);
|
||||||
const [pageMetaTitle, setPageMetaTitle] = useState("");
|
const [pageMetaTitle, setPageMetaTitle] = useState("");
|
||||||
const [pageMetaDescription, setPageMetaDescription] = useState("");
|
const [pageMetaDescription, setPageMetaDescription] = useState("");
|
||||||
|
|
@ -95,18 +115,60 @@ export default function AdminPlacePagesPage() {
|
||||||
|
|
||||||
const selectedPlace = getPlaceConfigFromSlug(selectedSlug);
|
const selectedPlace = getPlaceConfigFromSlug(selectedSlug);
|
||||||
const selectedPlacePreposition = selectedPlace ? getPlacePreposition(selectedPlace.label) : "i";
|
const selectedPlacePreposition = selectedPlace ? getPlacePreposition(selectedPlace.label) : "i";
|
||||||
const placeSuggestedTitle = selectedPlace ? `${selectedPlace.title}: golfbaner og banestatus` : "";
|
const enrichedPlaceFacilities = enrichFacilities(Array.isArray(placeFacilities) ? placeFacilities : []);
|
||||||
|
const filteredPlaceFacilities = selectedPlace
|
||||||
|
? filterFacilitiesByArea(enrichedPlaceFacilities, selectedPlace.areaFilter)
|
||||||
|
: [];
|
||||||
|
const placeSuggestedTitle = selectedPlace
|
||||||
|
? buildDefaultPlaceMetaTitle(
|
||||||
|
filteredPlaceFacilities.length,
|
||||||
|
selectedPlace.label,
|
||||||
|
selectedPlacePreposition,
|
||||||
|
)
|
||||||
|
: "";
|
||||||
const placeSuggestedDescription = selectedPlace
|
const placeSuggestedDescription = selectedPlace
|
||||||
? trimSuggestion(
|
? trimSuggestion(
|
||||||
`Finn golfbaner ${selectedPlacePreposition} ${selectedPlace.label} med oppdatert banestatus, priser og baneprofiler på TeeOff.no.`,
|
buildDefaultPlaceMetaDescription(selectedPlace.label, selectedPlacePreposition),
|
||||||
160,
|
160,
|
||||||
)
|
)
|
||||||
: "";
|
: "";
|
||||||
|
const effectivePlaceMetaTitle = metaTitle.trim() || placeSuggestedTitle;
|
||||||
|
const effectivePlaceMetaDescription = metaDescription.trim() || placeSuggestedDescription;
|
||||||
const sitePageSuggestion = SITE_PAGE_SEO_SUGGESTIONS[selectedPageKey] || {
|
const sitePageSuggestion = SITE_PAGE_SEO_SUGGESTIONS[selectedPageKey] || {
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const loadPlaceFacilities = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/facilities?view=place`, {
|
||||||
|
cache: "no-store",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Kunne ikke hente anleggslisten for sted-SEO.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as FacilityRecord[];
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setPlaceFacilities(Array.isArray(data) ? data : []);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setPlaceFacilities([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPlaceFacilities();
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
|
@ -300,6 +362,25 @@ export default function AdminPlacePagesPage() {
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Aktiv side</p>
|
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Aktiv side</p>
|
||||||
<p className="mt-2 text-xl font-black text-[#112015]">{selectedPlace?.title || selectedSlug}</p>
|
<p className="mt-2 text-xl font-black text-[#112015]">{selectedPlace?.title || selectedSlug}</p>
|
||||||
<p className="mt-2 text-sm leading-6 text-[#536256]">{selectedPlace?.intro || "Ingen intro tilgjengelig."}</p>
|
<p className="mt-2 text-sm leading-6 text-[#536256]">{selectedPlace?.intro || "Ingen intro tilgjengelig."}</p>
|
||||||
|
<div className="mt-4 space-y-3 rounded-2xl border border-[#112015]/8 bg-[#f7faf4] p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Gjeldende meta title</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold leading-6 text-[#112015]">
|
||||||
|
{effectivePlaceMetaTitle || "Ikke tilgjengelig"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Gjeldende meta description</p>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-[#536256]">
|
||||||
|
{effectivePlaceMetaDescription || "Ikke tilgjengelig"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] font-bold text-[#6A766C]">
|
||||||
|
{metaTitle.trim() || metaDescription.trim()
|
||||||
|
? "Viser manuell overstyring der felt er fylt ut, ellers standardverdien."
|
||||||
|
: "Viser standardverdien som brukes når feltene står tomme."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<p className="mt-4 text-xs font-bold uppercase tracking-widest text-gray-500">
|
<p className="mt-4 text-xs font-bold uppercase tracking-widest text-gray-500">
|
||||||
Sist lagret: {formatDateTime(updatedAt)}
|
Sist lagret: {formatDateTime(updatedAt)}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -342,7 +423,7 @@ export default function AdminPlacePagesPage() {
|
||||||
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-black uppercase tracking-[0.2em] text-[#7ca982]">Samlesider</p>
|
<p className="text-xs font-black uppercase tracking-[0.2em] text-[#7ca982]">Samlesider</p>
|
||||||
<h2 className="mt-2 text-3xl font-black tracking-tight text-[#11280f]">SEO for /golfbaner, /vtg, /medlemskap og /simulatorer</h2>
|
<h2 className="mt-2 text-3xl font-black tracking-tight text-[#11280f]">SEO for samlesider</h2>
|
||||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-[#536256]">
|
<p className="mt-3 max-w-3xl text-sm leading-6 text-[#536256]">
|
||||||
Her kan du overstyre meta title og meta description på de store landingssidene uten å endre H1 eller innholdstekst.
|
Her kan du overstyre meta title og meta description på de store landingssidene uten å endre H1 eller innholdstekst.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,33 @@ type VtgDateRow = {
|
||||||
end_date?: string | null;
|
end_date?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const monthMap: Record<string, number> = {
|
||||||
|
januar: 0,
|
||||||
|
jan: 0,
|
||||||
|
februar: 1,
|
||||||
|
feb: 1,
|
||||||
|
mars: 2,
|
||||||
|
mar: 2,
|
||||||
|
april: 3,
|
||||||
|
apr: 3,
|
||||||
|
mai: 4,
|
||||||
|
juni: 5,
|
||||||
|
jun: 5,
|
||||||
|
juli: 6,
|
||||||
|
jul: 6,
|
||||||
|
august: 7,
|
||||||
|
aug: 7,
|
||||||
|
september: 8,
|
||||||
|
sep: 8,
|
||||||
|
sept: 8,
|
||||||
|
oktober: 9,
|
||||||
|
okt: 9,
|
||||||
|
november: 10,
|
||||||
|
nov: 10,
|
||||||
|
desember: 11,
|
||||||
|
des: 11,
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeDateRows = (value: any): VtgDateRow[] => {
|
const normalizeDateRows = (value: any): VtgDateRow[] => {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value.map((row) => ({
|
return value.map((row) => ({
|
||||||
|
|
@ -26,6 +53,68 @@ const datesAreEqual = (left: any, right: any) => (
|
||||||
JSON.stringify(normalizeDateRows(left)) === JSON.stringify(normalizeDateRows(right))
|
JSON.stringify(normalizeDateRows(left)) === JSON.stringify(normalizeDateRows(right))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const normalizeWhitespace = (value: string) => value.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
const parseComparableDate = (raw: string) => {
|
||||||
|
const trimmed = normalizeWhitespace(raw);
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const isoCandidate = new Date(trimmed);
|
||||||
|
if (!Number.isNaN(isoCandidate.getTime())) {
|
||||||
|
isoCandidate.setHours(0, 0, 0, 0);
|
||||||
|
return isoCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericDateMatch = trimmed.match(/(\d{1,2})[./](\d{1,2})[./](\d{2,4})/);
|
||||||
|
if (numericDateMatch) {
|
||||||
|
const day = Number(numericDateMatch[1]);
|
||||||
|
const month = Number(numericDateMatch[2]) - 1;
|
||||||
|
const yearValue = Number(numericDateMatch[3]);
|
||||||
|
const year = yearValue < 100 ? 2000 + yearValue : yearValue;
|
||||||
|
const parsed = new Date(year, month, day);
|
||||||
|
parsed.setHours(0, 0, 0, 0);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = trimmed
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[.,]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
|
||||||
|
const monthToken = Object.keys(monthMap).find((monthName) => normalized.includes(monthName));
|
||||||
|
if (!monthToken) return null;
|
||||||
|
|
||||||
|
const monthIndex = monthMap[monthToken];
|
||||||
|
const rangeMatch = normalized.match(/(\d{1,2})\s*(?:-|–|—|til)\s*(\d{1,2})/);
|
||||||
|
const dayMatch = normalized.match(/(\d{1,2})/);
|
||||||
|
if (!dayMatch) return null;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const explicitYearMatch = normalized.match(/\b(20\d{2})\b/);
|
||||||
|
let year = explicitYearMatch ? Number(explicitYearMatch[1]) : today.getFullYear();
|
||||||
|
const day = rangeMatch ? Number(rangeMatch[2]) : Number(dayMatch[1]);
|
||||||
|
|
||||||
|
let parsed = new Date(year, monthIndex, day);
|
||||||
|
parsed.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (!explicitYearMatch && parsed.getTime() < today.getTime() - 7 * 24 * 60 * 60 * 1000) {
|
||||||
|
year += 1;
|
||||||
|
parsed = new Date(year, monthIndex, day);
|
||||||
|
parsed.setHours(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidDateRow = (row: VtgDateRow) => {
|
||||||
|
const value = normalizeWhitespace(String(row?.dato || ''));
|
||||||
|
if (!value) return true;
|
||||||
|
return Boolean(parseComparableDate(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInvalidDateRows = (rows: VtgDateRow[]) => rows.filter((row) => !isValidDateRow(row));
|
||||||
|
|
||||||
const textValue = (value: any) => (
|
const textValue = (value: any) => (
|
||||||
typeof value === 'string' ? value.trim() : ''
|
typeof value === 'string' ? value.trim() : ''
|
||||||
);
|
);
|
||||||
|
|
@ -52,6 +141,7 @@ const hasContentDraftChanges = (draft: any) => (
|
||||||
const hasCourseDraftChanges = (draft: any) => (
|
const hasCourseDraftChanges = (draft: any) => (
|
||||||
hasCourseDraftRecord(draft) && !datesAreEqual(draft?.vtg_datoer, draft?.edit_datoer)
|
hasCourseDraftRecord(draft) && !datesAreEqual(draft?.vtg_datoer, draft?.edit_datoer)
|
||||||
);
|
);
|
||||||
|
const hasInvalidCourseDraftRows = (draft: any) => getInvalidDateRows(normalizeDateRows(draft?.edit_datoer)).length > 0;
|
||||||
|
|
||||||
function ReadOnlyDateList({ dates, emptyLabel }: { dates: any; emptyLabel: string }) {
|
function ReadOnlyDateList({ dates, emptyLabel }: { dates: any; emptyLabel: string }) {
|
||||||
const normalizedDates = normalizeDateRows(dates);
|
const normalizedDates = normalizeDateRows(dates);
|
||||||
|
|
@ -195,6 +285,22 @@ export default function VtgWasher() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApproveCourses = async () => {
|
const handleApproveCourses = async () => {
|
||||||
|
const invalidDrafts = drafts.filter(
|
||||||
|
d => selectedIds.includes(d.id) && hasCourseDraftChanges(d) && hasInvalidCourseDraftRows(d)
|
||||||
|
);
|
||||||
|
if (invalidDrafts.length > 0) {
|
||||||
|
const labels = invalidDrafts
|
||||||
|
.map((draft) => {
|
||||||
|
const invalidRows = getInvalidDateRows(normalizeDateRows(draft.edit_datoer))
|
||||||
|
.map((row) => row.dato)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
return `${draft.name}: ${invalidRows}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
return alert(`Kun kurs med gyldige datoer kan godkjennes.\n\nRett eller slett disse radene først:\n${labels}`);
|
||||||
|
}
|
||||||
|
|
||||||
const toApprove = drafts.filter(d => selectedIds.includes(d.id) && hasCourseDraftChanges(d)).map(d => ({
|
const toApprove = drafts.filter(d => selectedIds.includes(d.id) && hasCourseDraftChanges(d)).map(d => ({
|
||||||
facility_id: d.id,
|
facility_id: d.id,
|
||||||
vtg_datoer: d.edit_datoer
|
vtg_datoer: d.edit_datoer
|
||||||
|
|
@ -214,7 +320,8 @@ export default function VtgWasher() {
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
fetchDrafts();
|
fetchDrafts();
|
||||||
} else {
|
} else {
|
||||||
alert("Noe gikk galt under lagring.");
|
const error = await res.json().catch(() => ({ detail: "Noe gikk galt under lagring." }));
|
||||||
|
alert(error.detail || "Noe gikk galt under lagring.");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert("Nettverksfeil");
|
alert("Nettverksfeil");
|
||||||
|
|
@ -266,6 +373,7 @@ export default function VtgWasher() {
|
||||||
const descriptionChanged = textValue(draft.vtg_beskrivelse) !== textValue(draft.edit_beskrivelse);
|
const descriptionChanged = textValue(draft.vtg_beskrivelse) !== textValue(draft.edit_beskrivelse);
|
||||||
const datesChanged = !datesAreEqual(draft.vtg_datoer, draft.edit_datoer);
|
const datesChanged = !datesAreEqual(draft.vtg_datoer, draft.edit_datoer);
|
||||||
const contentChangedCount = [priceChanged, descriptionChanged].filter(Boolean).length;
|
const contentChangedCount = [priceChanged, descriptionChanged].filter(Boolean).length;
|
||||||
|
const invalidCourseRows = getInvalidDateRows(normalizeDateRows(draft.edit_datoer));
|
||||||
const changedCount = [priceChanged, descriptionChanged, datesChanged].filter(Boolean).length;
|
const changedCount = [priceChanged, descriptionChanged, datesChanged].filter(Boolean).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -284,6 +392,7 @@ export default function VtgWasher() {
|
||||||
{priceChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Pris</span>}
|
{priceChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Pris</span>}
|
||||||
{descriptionChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Beskrivelse</span>}
|
{descriptionChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Beskrivelse</span>}
|
||||||
{datesChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Kommende kurs</span>}
|
{datesChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Kommende kurs</span>}
|
||||||
|
{invalidCourseRows.length > 0 && <span className="rounded-full bg-red-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-red-800">Ugyldige datoer</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href={draft.vtg_lenke?.split(',')[0]} target="_blank" className="btn btn-md btn-secondary w-full md:w-auto">Sjekk Nettside ↗</a>
|
<a href={draft.vtg_lenke?.split(',')[0]} target="_blank" className="btn btn-md btn-secondary w-full md:w-auto">Sjekk Nettside ↗</a>
|
||||||
|
|
@ -340,20 +449,25 @@ export default function VtgWasher() {
|
||||||
<div className="mb-3 text-[10px] font-black uppercase tracking-widest text-gray-500">I dag</div>
|
<div className="mb-3 text-[10px] font-black uppercase tracking-widest text-gray-500">I dag</div>
|
||||||
<ReadOnlyDateList dates={draft.vtg_datoer} emptyLabel="Ingen kursdatoer registrert i dag." />
|
<ReadOnlyDateList dates={draft.vtg_datoer} emptyLabel="Ingen kursdatoer registrert i dag." />
|
||||||
</div>
|
</div>
|
||||||
<div className={`rounded-2xl border p-4 ${datesChanged ? 'border-green-200 bg-green-50/60' : 'border-gray-200 bg-white'}`}>
|
<div className={`rounded-2xl border p-4 ${invalidCourseRows.length > 0 ? 'border-red-200 bg-red-50/60' : datesChanged ? 'border-green-200 bg-green-50/60' : 'border-gray-200 bg-white'}`}>
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<div className="text-[10px] font-black uppercase tracking-widest text-gray-500">Forslag</div>
|
<div className="text-[10px] font-black uppercase tracking-widest text-gray-500">Forslag</div>
|
||||||
<span className={`rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-widest ${datesChanged ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}>
|
<span className={`rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-widest ${invalidCourseRows.length > 0 ? 'bg-red-100 text-red-800' : datesChanged ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}>
|
||||||
{datesChanged ? 'Har endringer' : 'Lik dagens kurs'}
|
{invalidCourseRows.length > 0 ? 'Må rettes' : datesChanged ? 'Har endringer' : 'Lik dagens kurs'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{invalidCourseRows.length > 0 && (
|
||||||
|
<div className="mb-3 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
|
||||||
|
Kun kurs med gyldige datoer kan godkjennes. Rett eller slett radene markert under.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{draft.edit_datoer.length === 0 ? (
|
{draft.edit_datoer.length === 0 ? (
|
||||||
<div className="p-4 bg-white rounded-xl text-sm text-gray-500 italic border border-gray-200">Ingen kommende kurs funnet i forslaget.</div>
|
<div className="p-4 bg-white rounded-xl text-sm text-gray-500 italic border border-gray-200">Ingen kommende kurs funnet i forslaget.</div>
|
||||||
) : (
|
) : (
|
||||||
draft.edit_datoer.map((row: any, idx: number) => (
|
draft.edit_datoer.map((row: any, idx: number) => (
|
||||||
<div key={idx} className="grid gap-2 rounded-lg border border-gray-200 bg-white p-3 relative group sm:grid-cols-[minmax(0,1fr)_150px_auto] sm:items-center">
|
<div key={idx} className={`grid gap-2 rounded-lg border bg-white p-3 relative group sm:grid-cols-[minmax(0,1fr)_150px_auto] sm:items-center ${isValidDateRow(row) ? 'border-gray-200' : 'border-red-200'}`}>
|
||||||
<input className="w-full rounded border border-gray-100 p-2 text-xs font-bold outline-none focus:border-[#8bc34a]" value={row.dato} onChange={e => updateDateRow(draft.id, idx, 'dato', e.target.value)} placeholder="F.eks: 12.-14. mai" />
|
<input className={`w-full rounded border p-2 text-xs font-bold outline-none ${isValidDateRow(row) ? 'border-gray-100 focus:border-[#8bc34a]' : 'border-red-200 focus:border-red-400'}`} value={row.dato} onChange={e => updateDateRow(draft.id, idx, 'dato', e.target.value)} placeholder="F.eks: 12.-14. mai" />
|
||||||
<select className="w-full rounded border border-gray-100 p-2 text-xs outline-none focus:border-[#8bc34a] bg-white" value={row.status} onChange={e => updateDateRow(draft.id, idx, 'status', e.target.value)}>
|
<select className="w-full rounded border border-gray-100 p-2 text-xs outline-none focus:border-[#8bc34a] bg-white" value={row.status} onChange={e => updateDateRow(draft.id, idx, 'status', e.target.value)}>
|
||||||
<option value="Ledig">Ledig</option>
|
<option value="Ledig">Ledig</option>
|
||||||
<option value="Fulltegnet">Fulltegnet</option>
|
<option value="Fulltegnet">Fulltegnet</option>
|
||||||
|
|
@ -361,6 +475,11 @@ export default function VtgWasher() {
|
||||||
<option value="Få plasser">Få plasser</option>
|
<option value="Få plasser">Få plasser</option>
|
||||||
</select>
|
</select>
|
||||||
<button onClick={() => removeDateRow(draft.id, idx)} className="btn btn-sm btn-danger sm:opacity-0 sm:group-hover:opacity-100" title="Slett dato">✕</button>
|
<button onClick={() => removeDateRow(draft.id, idx)} className="btn btn-sm btn-danger sm:opacity-0 sm:group-hover:opacity-100" title="Slett dato">✕</button>
|
||||||
|
{!isValidDateRow(row) && (
|
||||||
|
<div className="sm:col-span-3 text-xs font-bold text-red-700">
|
||||||
|
Ugyldig datoformat. Bruk f.eks. `12. mai`, `12.-13. mai` eller `14.05.2026`.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import {
|
||||||
buildAbsoluteUrl,
|
buildAbsoluteUrl,
|
||||||
createBreadcrumbJsonLd,
|
createBreadcrumbJsonLd,
|
||||||
createPageMetadata,
|
createPageMetadata,
|
||||||
|
resolveSeoDescription,
|
||||||
|
resolveSeoTitle,
|
||||||
} from "@/app/seo";
|
} from "@/app/seo";
|
||||||
|
|
||||||
type CourseVisitPageProps = {
|
type CourseVisitPageProps = {
|
||||||
|
|
@ -130,9 +132,11 @@ export async function generateMetadata({ params, searchParams }: CourseVisitPage
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seoTitle = resolveSeoTitle(article.metaTitle, article.title);
|
||||||
|
const seoDescription = resolveSeoDescription(article.metaDescription, article.description);
|
||||||
const metadata = createPageMetadata({
|
const metadata = createPageMetadata({
|
||||||
title: article.title,
|
title: seoTitle,
|
||||||
description: article.description,
|
description: seoDescription,
|
||||||
path: `/banebesok/${article.slug}`,
|
path: `/banebesok/${article.slug}`,
|
||||||
image: article.heroImages[0]?.src,
|
image: article.heroImages[0]?.src,
|
||||||
type: "article",
|
type: "article",
|
||||||
|
|
@ -141,7 +145,7 @@ export async function generateMetadata({ params, searchParams }: CourseVisitPage
|
||||||
if (article.isPreview) {
|
if (article.isPreview) {
|
||||||
return {
|
return {
|
||||||
...metadata,
|
...metadata,
|
||||||
title: `${article.title} | Utkastforhåndsvisning`,
|
title: `${seoTitle} | Utkastforhåndsvisning`,
|
||||||
robots: {
|
robots: {
|
||||||
index: false,
|
index: false,
|
||||||
follow: false,
|
follow: false,
|
||||||
|
|
@ -171,7 +175,7 @@ export default async function CourseVisitPage({ params, searchParams }: CourseVi
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Article",
|
"@type": "Article",
|
||||||
headline: article.title,
|
headline: article.title,
|
||||||
description: article.description,
|
description: resolveSeoDescription(article.metaDescription, article.description, 200),
|
||||||
url: buildAbsoluteUrl(`/banebesok/${article.slug}`),
|
url: buildAbsoluteUrl(`/banebesok/${article.slug}`),
|
||||||
image: article.heroImages.map((image) => buildAbsoluteUrl(image.src)),
|
image: article.heroImages.map((image) => buildAbsoluteUrl(image.src)),
|
||||||
datePublished: article.publishedAt,
|
datePublished: article.publishedAt,
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,30 @@ import {
|
||||||
createCollectionPageJsonLd,
|
createCollectionPageJsonLd,
|
||||||
createPageMetadata,
|
createPageMetadata,
|
||||||
} from "@/app/seo";
|
} from "@/app/seo";
|
||||||
|
import { resolveSitePageSeo } from "@/app/pageSeo";
|
||||||
|
|
||||||
const pageTitle = "Banebesøk";
|
const fallbackPageTitle = "Golfreiser og banebesøk: Erfaringer fra norske golfbaner | TeeOff.no";
|
||||||
const pageDescription =
|
const fallbackPageDescription =
|
||||||
"Redaksjonelle artikler fra norske golfbaner, bygget for lange historier, sterke bilder og nyttige lenker videre til TeeOffs baneprofiler.";
|
"Bli med på tur! Vi besøker Norges vakreste golfbaner og deler ærlige reiseskildringer, spektakulære bilder og nyttige tips. Finn inspirasjon til din neste golfreise her.";
|
||||||
|
|
||||||
export const metadata = createPageMetadata({
|
|
||||||
title: pageTitle,
|
|
||||||
description: pageDescription,
|
|
||||||
path: "/banebesok",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const seo = await resolveSitePageSeo("banebesok", fallbackPageTitle, fallbackPageDescription);
|
||||||
|
return createPageMetadata({
|
||||||
|
title: seo.title,
|
||||||
|
description: seo.description,
|
||||||
|
path: "/banebesok",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default async function CourseVisitsPage() {
|
export default async function CourseVisitsPage() {
|
||||||
const articles = await getCourseVisits();
|
const articles = await getCourseVisits();
|
||||||
|
const seo = await resolveSitePageSeo("banebesok", fallbackPageTitle, fallbackPageDescription);
|
||||||
|
|
||||||
const collectionJsonLd = createCollectionPageJsonLd({
|
const collectionJsonLd = createCollectionPageJsonLd({
|
||||||
name: pageTitle,
|
name: seo.title,
|
||||||
description: pageDescription,
|
description: seo.description,
|
||||||
path: "/banebesok",
|
path: "/banebesok",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,8 @@ export async function generateMetadata({ params }: GolfCoursePageProps): Promise
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackTitle = `${facility.name}: banestatus, priser og info`;
|
const fallbackTitle = `${facility.name}: Banestatus, greenfee og baneguide | TeeOff.no`;
|
||||||
const fallbackDescription = `Se banestatus, priser, kontaktinfo, kart og praktisk informasjon for ${facility.name} på TeeOff.no.`;
|
const fallbackDescription = `Er ${facility.name} åpen? Se oppdatert banestatus, priser, vær og kart. Planlegg runden din med bilder og praktisk info på TeeOff.no!`;
|
||||||
|
|
||||||
return createPageMetadata({
|
return createPageMetadata({
|
||||||
title: resolveSeoTitle(facility.meta_title, fallbackTitle),
|
title: resolveSeoTitle(facility.meta_title, fallbackTitle),
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,23 @@ import {
|
||||||
export const revalidate = 900;
|
export const revalidate = 900;
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const fallbackPageTitle = "Golfbaner i Norge";
|
const fallbackPageTitle = "Alle norske golfbaner: Finn din neste runde på gress | TeeOff.no";
|
||||||
const fallbackPageDescription =
|
const fallbackPageDescription =
|
||||||
"Finn golfbaner i Norge og filtrer på område, banestatus, antall hull og fasiliteter i TeeOffs samlede oversikt.";
|
"Planlegg din neste golfrunde. Se komplett oversikt over alle norske golfbaner med oppdatert banestatus, greenfee-priser og kart på TeeOff.no.";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const seo = await resolveSitePageSeo("golfbaner", fallbackPageTitle, fallbackPageDescription);
|
const seo = await resolveSitePageSeo("golfbaner", fallbackPageTitle, fallbackPageDescription);
|
||||||
return createPageMetadata({
|
const metadata = createPageMetadata({
|
||||||
title: seo.title,
|
title: seo.title,
|
||||||
description: seo.description,
|
description: seo.description,
|
||||||
path: "/golfbaner",
|
path: "/golfbaner",
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
...metadata,
|
||||||
|
alternates: {
|
||||||
|
canonical: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function GolfCoursesIndexPage() {
|
export default async function GolfCoursesIndexPage() {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ import {
|
||||||
createPageMetadata,
|
createPageMetadata,
|
||||||
} from "@/app/seo";
|
} from "@/app/seo";
|
||||||
|
|
||||||
const pageTitle = "Klubbnummer i Golfbox";
|
const pageTitle = "NGF Klubbnummer: Oversikt for Golfbox og Gimmie | TeeOff.no";
|
||||||
const pageDescription =
|
const pageDescription =
|
||||||
"Sorterbar oversikt over NGF-nummer og klubbnavn for norske golfanlegg på TeeOff.";
|
"Hvilken klubb tilhører nummeret? Se komplett og sorterbar oversikt over alle norske golfklubber og deres NGF-klubbnummer for bruk i Golfbox og Gimmie.";
|
||||||
|
|
||||||
export const metadata = createPageMetadata({
|
export const metadata = createPageMetadata({
|
||||||
title: pageTitle,
|
title: pageTitle,
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@ import {
|
||||||
export const revalidate = 1800;
|
export const revalidate = 1800;
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const fallbackPageTitle = "Medlemskap i norske golfklubber";
|
const fallbackPageTitle =
|
||||||
|
"Billig golfmedlemskap? Finn og sammenlign priser på alle klubber | TeeOff.no";
|
||||||
const fallbackPageDescription =
|
const fallbackPageDescription =
|
||||||
"Sammenlign priser på medlemskap i norske golfklubber, både full spillerett og rimeligste nasjonale alternativ.";
|
"Hvor er det billigst å være medlem? Sammenlign priser på golfmedlemskap med full spillerett eller rimelige nasjonale alternativ (fjernmedlemskap) i Norge på TeeOff.no.";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const seo = await resolveSitePageSeo("medlemskap", fallbackPageTitle, fallbackPageDescription);
|
const seo = await resolveSitePageSeo("medlemskap", fallbackPageTitle, fallbackPageDescription);
|
||||||
|
|
@ -59,12 +60,41 @@ export default async function MembershipPage() {
|
||||||
Medlemskap
|
Medlemskap
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-5xl font-black text-[#112015] sm:text-6xl">
|
<h1 className="text-5xl font-black text-[#112015] sm:text-6xl">
|
||||||
Dette koster medlemskap i norske golfklubber
|
Sammenlign priser på golfmedlemskap i Norge
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-6 text-base leading-7 text-[#4F5F50] sm:text-lg">
|
<div className="mt-6 max-w-4xl space-y-5 text-base leading-7 text-[#4F5F50] sm:text-lg">
|
||||||
Velg hvilken type medlemskap du vil sammenligne under. Hver rad kan åpnes for flere
|
<p>Beløpene oppdateres fortløpende, så snart jeg oppdager endringer.</p>
|
||||||
detaljer, sist oppdatert-dato og lenke til klubbens egen innmelding.
|
<p>Jeg har stilt meg selv to spørsmål:</p>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import {
|
||||||
buildAbsoluteUrl,
|
buildAbsoluteUrl,
|
||||||
createBreadcrumbJsonLd,
|
createBreadcrumbJsonLd,
|
||||||
createPageMetadata,
|
createPageMetadata,
|
||||||
|
resolveSeoDescription,
|
||||||
|
resolveSeoTitle,
|
||||||
} from "@/app/seo";
|
} from "@/app/seo";
|
||||||
|
|
||||||
type OpinionPageProps = {
|
type OpinionPageProps = {
|
||||||
|
|
@ -130,9 +132,11 @@ export async function generateMetadata({ params, searchParams }: OpinionPageProp
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seoTitle = resolveSeoTitle(article.metaTitle, article.title);
|
||||||
|
const seoDescription = resolveSeoDescription(article.metaDescription, article.description);
|
||||||
const metadata = createPageMetadata({
|
const metadata = createPageMetadata({
|
||||||
title: article.title,
|
title: seoTitle,
|
||||||
description: article.description,
|
description: seoDescription,
|
||||||
path: `/meninger/${article.slug}`,
|
path: `/meninger/${article.slug}`,
|
||||||
image: article.heroImages[0]?.src,
|
image: article.heroImages[0]?.src,
|
||||||
type: "article",
|
type: "article",
|
||||||
|
|
@ -141,7 +145,7 @@ export async function generateMetadata({ params, searchParams }: OpinionPageProp
|
||||||
if (article.isPreview) {
|
if (article.isPreview) {
|
||||||
return {
|
return {
|
||||||
...metadata,
|
...metadata,
|
||||||
title: `${article.title} | Utkastforhåndsvisning`,
|
title: `${seoTitle} | Utkastforhåndsvisning`,
|
||||||
robots: {
|
robots: {
|
||||||
index: false,
|
index: false,
|
||||||
follow: false,
|
follow: false,
|
||||||
|
|
@ -171,7 +175,7 @@ export default async function OpinionPage({ params, searchParams }: OpinionPageP
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Article",
|
"@type": "Article",
|
||||||
headline: article.title,
|
headline: article.title,
|
||||||
description: article.description,
|
description: resolveSeoDescription(article.metaDescription, article.description, 200),
|
||||||
url: buildAbsoluteUrl(`/meninger/${article.slug}`),
|
url: buildAbsoluteUrl(`/meninger/${article.slug}`),
|
||||||
image: article.heroImages.map((image) => buildAbsoluteUrl(image.src)),
|
image: article.heroImages.map((image) => buildAbsoluteUrl(image.src)),
|
||||||
datePublished: article.publishedAt,
|
datePublished: article.publishedAt,
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,30 @@ import {
|
||||||
createCollectionPageJsonLd,
|
createCollectionPageJsonLd,
|
||||||
createPageMetadata,
|
createPageMetadata,
|
||||||
} from "@/app/seo";
|
} from "@/app/seo";
|
||||||
|
import { resolveSitePageSeo } from "@/app/pageSeo";
|
||||||
|
|
||||||
const pageTitle = "Meninger";
|
const fallbackPageTitle = "Golfblogg: Meninger, humor og skråblikk på golf-Norge | TeeOff.no";
|
||||||
const pageDescription =
|
const fallbackPageDescription =
|
||||||
"Redaksjonelle artikler, siste nytt og kommentarer fra TeeOff, samlet i én egen seksjon.";
|
"Fra frustrasjon over saktespill til gleden over en perfekt drive. Les TeeOffs egne artikler, kommentarer og ærlige skråblikk på livet som golfer i Norge.";
|
||||||
|
|
||||||
export const metadata = createPageMetadata({
|
|
||||||
title: pageTitle,
|
|
||||||
description: pageDescription,
|
|
||||||
path: "/meninger",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const seo = await resolveSitePageSeo("meninger", fallbackPageTitle, fallbackPageDescription);
|
||||||
|
return createPageMetadata({
|
||||||
|
title: seo.title,
|
||||||
|
description: seo.description,
|
||||||
|
path: "/meninger",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default async function OpinionsPage() {
|
export default async function OpinionsPage() {
|
||||||
const articles = await getOpinionArticles();
|
const articles = await getOpinionArticles();
|
||||||
|
const seo = await resolveSitePageSeo("meninger", fallbackPageTitle, fallbackPageDescription);
|
||||||
|
|
||||||
const collectionJsonLd = createCollectionPageJsonLd({
|
const collectionJsonLd = createCollectionPageJsonLd({
|
||||||
name: pageTitle,
|
name: seo.title,
|
||||||
description: pageDescription,
|
description: seo.description,
|
||||||
path: "/meninger",
|
path: "/meninger",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ import { createPageMetadata } from "@/app/seo";
|
||||||
export const revalidate = 900;
|
export const revalidate = 900;
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const metadata = createPageMetadata({
|
export const metadata = createPageMetadata({
|
||||||
title: "Komplett oversikt over ALLE norske golfbaner",
|
title: "Golfbaner i Norge: Komplett oversikt over alle baner | TeeOff.no",
|
||||||
description:
|
description:
|
||||||
"Utforsk norske golfbaner med oppdatert banestatus, kart, priser, medlemskap og Veien til Golf samlet på TeeOff.",
|
"Hvilke golfbaner i Norge er åpne nå? TeeOff gir deg komplett oversikt over alle norske golfbaner med oppdatert banestatus, kart og priser. Finn din neste runde her!",
|
||||||
path: "/",
|
path: "/",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
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",
|
type = "website",
|
||||||
}: MetadataInput): Metadata {
|
}: MetadataInput): Metadata {
|
||||||
const ogImage = resolveImageUrl(image);
|
const ogImage = resolveImageUrl(image);
|
||||||
|
const normalizedTitle = String(title || "").trim();
|
||||||
|
const metadataTitle = /\|\s*TeeOff\.no\s*$/i.test(normalizedTitle)
|
||||||
|
? { absolute: normalizedTitle }
|
||||||
|
: normalizedTitle;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title: metadataTitle,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: path,
|
canonical: path,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { existsSync, statSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
import { API_URL } from "@/config/constants";
|
import { API_URL } from "@/config/constants";
|
||||||
import { getAvailablePlaceConfigs } from "@/app/facilityData";
|
import { getAvailablePlaceConfigs } from "@/app/facilityData";
|
||||||
|
|
@ -10,78 +12,158 @@ type SitemapFacility = {
|
||||||
vtg_updated_at?: string | null;
|
vtg_updated_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SitePageSeoRecord = {
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlacePageRecord = {
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StaticRouteConfig = {
|
||||||
|
path: string;
|
||||||
|
changeFrequency: "daily" | "weekly" | "monthly";
|
||||||
|
priority: number;
|
||||||
|
sourceFiles: string[];
|
||||||
|
sitePageKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const revalidate = 3600;
|
export const revalidate = 3600;
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const staticRoutes: MetadataRoute.Sitemap = [
|
const staticRouteConfigs: StaticRouteConfig[] = [
|
||||||
{
|
{
|
||||||
url: buildAbsoluteUrl("/"),
|
path: "/",
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: "daily",
|
changeFrequency: "daily",
|
||||||
priority: 1,
|
priority: 1,
|
||||||
|
sourceFiles: ["src/app/page.tsx"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: buildAbsoluteUrl("/golfbaner"),
|
path: "/golfbaner",
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: "daily",
|
changeFrequency: "daily",
|
||||||
priority: 0.95,
|
priority: 0.95,
|
||||||
|
sourceFiles: ["src/app/golfbaner/page.tsx", "src/app/pageSeo.ts"],
|
||||||
|
sitePageKey: "golfbaner",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: buildAbsoluteUrl("/medlemskap"),
|
path: "/medlemskap",
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: "daily",
|
changeFrequency: "daily",
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
|
sourceFiles: ["src/app/medlemskap/page.tsx", "src/app/pageSeo.ts"],
|
||||||
|
sitePageKey: "medlemskap",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: buildAbsoluteUrl("/vtg"),
|
path: "/vtg",
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: "daily",
|
changeFrequency: "daily",
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
|
sourceFiles: ["src/app/vtg/page.tsx", "src/app/pageSeo.ts"],
|
||||||
|
sitePageKey: "vtg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: buildAbsoluteUrl("/banebesok"),
|
path: "/banebesok",
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly",
|
||||||
priority: 0.72,
|
priority: 0.72,
|
||||||
|
sourceFiles: ["src/app/banebesok/page.tsx", "src/app/pageSeo.ts"],
|
||||||
|
sitePageKey: "banebesok",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: buildAbsoluteUrl("/meninger"),
|
path: "/meninger",
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly",
|
||||||
priority: 0.7,
|
priority: 0.7,
|
||||||
|
sourceFiles: ["src/app/meninger/page.tsx", "src/app/pageSeo.ts"],
|
||||||
|
sitePageKey: "meninger",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: buildAbsoluteUrl("/turneringer"),
|
path: "/turneringer",
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: "daily",
|
changeFrequency: "daily",
|
||||||
priority: 0.68,
|
priority: 0.68,
|
||||||
|
sourceFiles: ["src/app/turneringer/page.tsx"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: buildAbsoluteUrl("/klubbnummer"),
|
path: "/klubbnummer",
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly",
|
||||||
priority: 0.64,
|
priority: 0.64,
|
||||||
|
sourceFiles: ["src/app/klubbnummer/page.tsx"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: buildAbsoluteUrl("/om"),
|
path: "/om",
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
priority: 0.45,
|
priority: 0.45,
|
||||||
|
sourceFiles: ["src/app/om/page.tsx"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: buildAbsoluteUrl("/kontakt"),
|
path: "/kontakt",
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
priority: 0.42,
|
priority: 0.42,
|
||||||
|
sourceFiles: ["src/app/kontakt/page.tsx"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: buildAbsoluteUrl("/personvern-og-cookies"),
|
path: "/personvern-og-cookies",
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
priority: 0.38,
|
priority: 0.38,
|
||||||
|
sourceFiles: ["src/app/personvern-og-cookies/page.tsx"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function parseDate(value: string | Date | null | undefined) {
|
||||||
|
if (!value) return null;
|
||||||
|
const candidate = value instanceof Date ? value : new Date(value);
|
||||||
|
return Number.isNaN(candidate.getTime()) ? null : candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxDate(values: Array<string | Date | null | undefined>) {
|
||||||
|
let current: Date | null = null;
|
||||||
|
for (const value of values) {
|
||||||
|
const parsed = parseDate(value);
|
||||||
|
if (!parsed) continue;
|
||||||
|
if (!current || parsed.getTime() > current.getTime()) {
|
||||||
|
current = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceLastModified(relativePaths: string[]) {
|
||||||
|
let current: Date | null = null;
|
||||||
|
|
||||||
|
for (const relativePath of relativePaths) {
|
||||||
|
const absolutePath = path.join(process.cwd(), relativePath);
|
||||||
|
if (!existsSync(absolutePath)) continue;
|
||||||
|
const stats = statSync(absolutePath);
|
||||||
|
if (!current || stats.mtime.getTime() > current.getTime()) {
|
||||||
|
current = stats.mtime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSitePageSeoUpdatedAt(pageKey: string) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/page-seo/${pageKey}`, {
|
||||||
|
next: { revalidate },
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const data = (await response.json()) as SitePageSeoRecord;
|
||||||
|
return parseDate(data.updated_at);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlacePageUpdatedAt(slug: string) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/place-pages/${slug}`, {
|
||||||
|
next: { revalidate },
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const data = (await response.json()) as PlacePageRecord;
|
||||||
|
return parseDate(data.updated_at);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
let facilities: SitemapFacility[] = [];
|
let facilities: SitemapFacility[] = [];
|
||||||
|
|
||||||
|
|
@ -97,18 +179,49 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
facilities = [];
|
facilities = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const placeRoutes = getAvailablePlaceConfigs().map((slug) => ({
|
const staticRoutes = await Promise.all(
|
||||||
url: buildAbsoluteUrl(`/sted/${slug}`),
|
staticRouteConfigs.map(async (route) => {
|
||||||
lastModified: new Date(),
|
const sitePageUpdatedAt = route.sitePageKey
|
||||||
changeFrequency: "daily" as const,
|
? await fetchSitePageSeoUpdatedAt(route.sitePageKey)
|
||||||
priority: slug === "norge" ? 0.9 : 0.75,
|
: 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
|
const facilityRoutes = facilities
|
||||||
.filter((facility) => Boolean(facility.slug))
|
.filter((facility) => Boolean(facility.slug))
|
||||||
.map((facility) => ({
|
.map((facility) => ({
|
||||||
url: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
|
url: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
|
||||||
lastModified: facility.status_updated_at || facility.vtg_updated_at || new Date(),
|
lastModified:
|
||||||
|
maxDate([
|
||||||
|
facility.status_updated_at,
|
||||||
|
facility.vtg_updated_at,
|
||||||
|
facilityPageSourceLastModified,
|
||||||
|
]) || new Date(),
|
||||||
changeFrequency: "daily" as const,
|
changeFrequency: "daily" as const,
|
||||||
priority: 0.7,
|
priority: 0.7,
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ import {
|
||||||
resolveSeoDescription,
|
resolveSeoDescription,
|
||||||
resolveSeoTitle,
|
resolveSeoTitle,
|
||||||
} from "@/app/seo";
|
} from "@/app/seo";
|
||||||
|
import {
|
||||||
|
buildDefaultPlaceMetaDescription,
|
||||||
|
buildDefaultPlaceMetaTitle,
|
||||||
|
} from "@/app/placeSeo";
|
||||||
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||||
|
|
||||||
type PlacePageData = {
|
type PlacePageData = {
|
||||||
|
|
@ -115,6 +119,8 @@ const fetchPlacePageData = cache(async (slug: string): Promise<PlacePageData | n
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fetchPlaceFacilities = cache(async () => fetchPublicFacilities<FacilityRecord>("place", revalidate));
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -131,11 +137,21 @@ export async function generateMetadata({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const facilities = await fetchPlaceFacilities();
|
||||||
const placePage = await fetchPlacePageData(slug);
|
const placePage = await fetchPlacePageData(slug);
|
||||||
const fallbackDescription = `${place.intro} TeeOff samler golfbaner i ${place.label} med oppdatert banestatus og baneprofiler.`;
|
const safeData = Array.isArray(facilities) ? facilities : [];
|
||||||
|
const enrichedFacilities = enrichFacilities(safeData);
|
||||||
|
const facilitiesInPlace = filterFacilitiesByArea(enrichedFacilities, place.areaFilter);
|
||||||
|
const placePreposition = place.slug === "norge" ? "i" : getPlacePreposition(place.label);
|
||||||
|
const fallbackTitle = buildDefaultPlaceMetaTitle(
|
||||||
|
facilitiesInPlace.length,
|
||||||
|
place.label,
|
||||||
|
placePreposition,
|
||||||
|
);
|
||||||
|
const fallbackDescription = buildDefaultPlaceMetaDescription(place.label, placePreposition);
|
||||||
|
|
||||||
return createPageMetadata({
|
return createPageMetadata({
|
||||||
title: resolveSeoTitle(placePage?.meta_title, place.title),
|
title: resolveSeoTitle(placePage?.meta_title, fallbackTitle),
|
||||||
description: resolveSeoDescription(placePage?.meta_description, fallbackDescription),
|
description: resolveSeoDescription(placePage?.meta_description, fallbackDescription),
|
||||||
path: `/sted/${slug}`,
|
path: `/sted/${slug}`,
|
||||||
});
|
});
|
||||||
|
|
@ -151,7 +167,7 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
|
||||||
|
|
||||||
let placePage: PlacePageData | null = null;
|
let placePage: PlacePageData | null = null;
|
||||||
|
|
||||||
const facilities = await fetchPublicFacilities<FacilityRecord>("place", revalidate);
|
const facilities = await fetchPlaceFacilities();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
placePage = await fetchPlacePageData(slug);
|
placePage = await fetchPlacePageData(slug);
|
||||||
|
|
@ -192,15 +208,21 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
|
||||||
? `Det korteste golfhullet ${placePreposition} ${place.label} er ${placeStats.shortestHoleMeters} meter, mens det lengste er ${placeStats.longestHoleMeters} meter.`
|
? `Det korteste golfhullet ${placePreposition} ${place.label} er ${placeStats.shortestHoleMeters} meter, mens det lengste er ${placeStats.longestHoleMeters} meter.`
|
||||||
: null;
|
: null;
|
||||||
const collectionJsonLd = createCollectionPageJsonLd({
|
const collectionJsonLd = createCollectionPageJsonLd({
|
||||||
name: resolveSeoTitle(placePage?.meta_title, place.title),
|
name: resolveSeoTitle(
|
||||||
|
placePage?.meta_title,
|
||||||
|
buildDefaultPlaceMetaTitle(placeStats.facilityCount, place.label, placePreposition),
|
||||||
|
),
|
||||||
description: resolveSeoDescription(
|
description: resolveSeoDescription(
|
||||||
placePage?.meta_description,
|
placePage?.meta_description,
|
||||||
`${place.intro} TeeOff samler golfbaner i ${place.label} med oppdatert banestatus og baneprofiler.`,
|
buildDefaultPlaceMetaDescription(place.label, placePreposition),
|
||||||
),
|
),
|
||||||
path: `/sted/${slug}`,
|
path: `/sted/${slug}`,
|
||||||
});
|
});
|
||||||
const itemListJsonLd = createItemListJsonLd({
|
const itemListJsonLd = createItemListJsonLd({
|
||||||
name: resolveSeoTitle(placePage?.meta_title, place.title),
|
name: resolveSeoTitle(
|
||||||
|
placePage?.meta_title,
|
||||||
|
buildDefaultPlaceMetaTitle(placeStats.facilityCount, place.label, placePreposition),
|
||||||
|
),
|
||||||
path: `/sted/${slug}`,
|
path: `/sted/${slug}`,
|
||||||
items: facilitiesInPlace
|
items: facilitiesInPlace
|
||||||
.filter((facility) => facility?.slug && facility?.name)
|
.filter((facility) => facility?.slug && facility?.name)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ import {
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const articleSlug = "note-to-self-lenker-til-viktige-turneringer-i-golfbox";
|
const articleSlug = "note-to-self-lenker-til-viktige-turneringer-i-golfbox";
|
||||||
const pageTitle = "Turneringer";
|
const pageTitle = "Golfturneringer i Norge: Oversikt og terminlister | TeeOff.no";
|
||||||
|
const pageDescription =
|
||||||
|
"Vanskelig å finne frem i Golfbox? Vi samler terminlister for Olyo Tour og regionale golfturneringer i hele Norge på ett sted. Finn din neste turnering her!";
|
||||||
const pageIntro = "Her er alle turneringene vi ikke vet hvordan vi skal finne i Golfbox (og andre steder). God golfsesong!";
|
const pageIntro = "Her er alle turneringene vi ikke vet hvordan vi skal finne i Golfbox (og andre steder). God golfsesong!";
|
||||||
function renderBlock(block: CourseVisitBodyBlock, index: number) {
|
function renderBlock(block: CourseVisitBodyBlock, index: number) {
|
||||||
if (block.type !== "richText") {
|
if (block.type !== "richText") {
|
||||||
|
|
@ -38,7 +40,7 @@ export async function generateMetadata() {
|
||||||
|
|
||||||
return createPageMetadata({
|
return createPageMetadata({
|
||||||
title: pageTitle,
|
title: pageTitle,
|
||||||
description: article?.description || "Viktige turneringslenker i Golfbox samlet på TeeOff.",
|
description: pageDescription,
|
||||||
path: "/turneringer",
|
path: "/turneringer",
|
||||||
image: article?.heroImages[0]?.src,
|
image: article?.heroImages[0]?.src,
|
||||||
});
|
});
|
||||||
|
|
@ -53,7 +55,7 @@ export default async function TournamentsPage() {
|
||||||
|
|
||||||
const collectionJsonLd = createCollectionPageJsonLd({
|
const collectionJsonLd = createCollectionPageJsonLd({
|
||||||
name: pageTitle,
|
name: pageTitle,
|
||||||
description: article.description,
|
description: pageDescription,
|
||||||
path: "/turneringer",
|
path: "/turneringer",
|
||||||
});
|
});
|
||||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@ import {
|
||||||
export const revalidate = 1800;
|
export const revalidate = 1800;
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const fallbackPageTitle = "Veien til Golf";
|
const fallbackPageTitle = "Veien til Golf: Finn golfkurs for nybegynnere (VTG) | TeeOff.no";
|
||||||
const fallbackPageDescription =
|
const fallbackPageDescription =
|
||||||
"Finn Veien til Golf-kurs etter område, klubb og neste kursdato i TeeOffs VTG-oversikt.";
|
"Vil du begynne med golf? Finn komplett oversikt over Veien til Golf-kurs (VTG) i hele Norge. Se kursdatoer, priser og finn din nærmeste klubb på TeeOff.no!";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const seo = await resolveSitePageSeo("vtg", fallbackPageTitle, fallbackPageDescription);
|
const seo = await resolveSitePageSeo("vtg", fallbackPageTitle, fallbackPageDescription);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { API_URL } from "@/config/constants";
|
import { API_URL } from "@/config/constants";
|
||||||
|
import importedMeninger from "@/content/importedMeninger.json";
|
||||||
|
|
||||||
export type ArticleSection = "banebesok" | "meninger";
|
export type ArticleSection = "banebesok" | "meninger";
|
||||||
|
|
||||||
|
|
@ -58,6 +59,8 @@ export type EditorialArticle = {
|
||||||
slug: string;
|
slug: string;
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
metaTitle?: string;
|
||||||
|
metaDescription?: string;
|
||||||
description: string;
|
description: string;
|
||||||
excerpt: string;
|
excerpt: string;
|
||||||
locationLabel: string;
|
locationLabel: string;
|
||||||
|
|
@ -84,6 +87,8 @@ type ArticleApiRecord = {
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
meta_title?: string | null;
|
||||||
|
meta_description?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
excerpt?: string | null;
|
excerpt?: string | null;
|
||||||
eyebrow?: string | null;
|
eyebrow?: string | null;
|
||||||
|
|
@ -106,6 +111,25 @@ type FacilityMeta = {
|
||||||
region: string;
|
region: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ImportedOpinionRecord = {
|
||||||
|
slug?: string;
|
||||||
|
status?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
excerpt?: string | null;
|
||||||
|
contentHtml?: string | null;
|
||||||
|
publishedAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
link?: string | null;
|
||||||
|
featuredImage?: {
|
||||||
|
url?: string | null;
|
||||||
|
alt?: string | null;
|
||||||
|
caption?: string | null;
|
||||||
|
} | null;
|
||||||
|
author?: {
|
||||||
|
name?: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
const facilityMetaBySlug: Record<string, FacilityMeta> = {
|
const facilityMetaBySlug: Record<string, FacilityMeta> = {
|
||||||
"lofoten-golfklubb": { name: "Lofoten Golfklubb", region: "Nordland" },
|
"lofoten-golfklubb": { name: "Lofoten Golfklubb", region: "Nordland" },
|
||||||
"kjekstad-golfklubb": { name: "Kjekstad Golfklubb", region: "Buskerud" },
|
"kjekstad-golfklubb": { name: "Kjekstad Golfklubb", region: "Buskerud" },
|
||||||
|
|
@ -135,6 +159,11 @@ function normalizeStatus(value?: string | null): "draft" | "published" {
|
||||||
return String(value || "").trim().toLowerCase() === "published" ? "published" : "draft";
|
return String(value || "").trim().toLowerCase() === "published" ? "published" : "draft";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeImportedStatus(value?: string | null): "draft" | "published" {
|
||||||
|
const normalized = String(value || "").trim().toLowerCase();
|
||||||
|
return normalized === "published" || normalized === "publish" ? "published" : "draft";
|
||||||
|
}
|
||||||
|
|
||||||
export function buildEditorialPath(section: ArticleSection, slug: string) {
|
export function buildEditorialPath(section: ArticleSection, slug: string) {
|
||||||
return `/${section}/${slug}`;
|
return `/${section}/${slug}`;
|
||||||
}
|
}
|
||||||
|
|
@ -534,6 +563,8 @@ function mapApiArticle(entry: ArticleApiRecord): EditorialArticle {
|
||||||
slug: entry.slug,
|
slug: entry.slug,
|
||||||
eyebrow: String(entry.eyebrow || "").trim() || getSectionLabel(section),
|
eyebrow: String(entry.eyebrow || "").trim() || getSectionLabel(section),
|
||||||
title: entry.title,
|
title: entry.title,
|
||||||
|
metaTitle: String(entry.meta_title || "").trim() || undefined,
|
||||||
|
metaDescription: String(entry.meta_description || "").trim() || undefined,
|
||||||
description: String(entry.description || "").trim() || excerpt,
|
description: String(entry.description || "").trim() || excerpt,
|
||||||
excerpt,
|
excerpt,
|
||||||
locationLabel,
|
locationLabel,
|
||||||
|
|
@ -572,6 +603,107 @@ function mapApiArticle(entry: ArticleApiRecord): EditorialArticle {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapImportedOpinionArticle(entry: ImportedOpinionRecord): EditorialArticle | null {
|
||||||
|
const slug = String(entry.slug || "").trim();
|
||||||
|
const title = String(entry.title || "").trim();
|
||||||
|
if (!slug || !title) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedHtml = normalizeInternalLinks(String(entry.contentHtml || ""));
|
||||||
|
const preparedHtml = prepareRichTextHtml(normalizedHtml, title);
|
||||||
|
const extractedMedia = extractMediaFromHtml(normalizedHtml, title);
|
||||||
|
const featuredSrc = normalizeMediaUrl(String(entry.featuredImage?.url || "").trim());
|
||||||
|
const featuredAlt = String(entry.featuredImage?.alt || "").trim() || title;
|
||||||
|
const featuredCaption =
|
||||||
|
String(entry.featuredImage?.caption || "").trim() || featuredAlt || title;
|
||||||
|
|
||||||
|
const fallbackMedia = featuredSrc
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: buildMediaId("image", featuredSrc),
|
||||||
|
type: "image" as const,
|
||||||
|
src: featuredSrc,
|
||||||
|
alt: featuredAlt,
|
||||||
|
caption: featuredCaption,
|
||||||
|
poster: "",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const mediaGallery = sanitizeMediaGallery([...fallbackMedia, ...extractedMedia], title).slice(0, 24);
|
||||||
|
const featuredMediaId = mediaGallery.find((item) => item.type === "image")?.id;
|
||||||
|
const heroImages = buildHeroImagesFromMedia(mediaGallery, title, featuredMediaId).slice(0, 6);
|
||||||
|
const excerpt =
|
||||||
|
String(entry.excerpt || "").trim() ||
|
||||||
|
stripHtml(normalizedHtml).slice(0, 220);
|
||||||
|
const publishedAt = String(entry.publishedAt || entry.updatedAt || "").trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
section: "meninger",
|
||||||
|
slug,
|
||||||
|
eyebrow: "Meninger",
|
||||||
|
title,
|
||||||
|
metaTitle: undefined,
|
||||||
|
metaDescription: undefined,
|
||||||
|
description: excerpt,
|
||||||
|
excerpt,
|
||||||
|
locationLabel: "Norge",
|
||||||
|
publishedAt,
|
||||||
|
updatedAt: String(entry.updatedAt || "").trim() || undefined,
|
||||||
|
readingTime: getReadingTime(preparedHtml),
|
||||||
|
heroImages:
|
||||||
|
heroImages.length > 0
|
||||||
|
? heroImages
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
src: "/Toppbilde-standard.jpg",
|
||||||
|
alt: title,
|
||||||
|
caption: title,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mediaGallery,
|
||||||
|
featuredMediaId,
|
||||||
|
quickFacts: buildQuickFacts({
|
||||||
|
publishedAt,
|
||||||
|
authorName: String(entry.author?.name || "").trim() || undefined,
|
||||||
|
}),
|
||||||
|
highlights: buildHighlights("meninger"),
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: "richText",
|
||||||
|
html: preparedHtml,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sourceUrl: String(entry.link || "").trim() || undefined,
|
||||||
|
sourceLabel: "Legacy TeeOff",
|
||||||
|
status: normalizeImportedStatus(entry.status),
|
||||||
|
isPreview: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImportedOpinionArticles() {
|
||||||
|
return (Array.isArray(importedMeninger) ? importedMeninger : [])
|
||||||
|
.map((entry) => mapImportedOpinionArticle(entry as ImportedOpinionRecord))
|
||||||
|
.filter((entry): entry is EditorialArticle => Boolean(entry && entry.status === "published"))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aTs = Date.parse(a.publishedAt || a.updatedAt || "") || 0;
|
||||||
|
const bTs = Date.parse(b.publishedAt || b.updatedAt || "") || 0;
|
||||||
|
return bTs - aTs;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImportedOpinionArticleBySlug(slug: string) {
|
||||||
|
const normalizedSlug = String(slug || "").trim();
|
||||||
|
if (!normalizedSlug) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
getImportedOpinionArticles().find((entry) => entry.slug === normalizedSlug) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchPublishedArticles(section: ArticleSection) {
|
async function fetchPublishedArticles(section: ArticleSection) {
|
||||||
const response = await fetch(`${API_URL}/articles?section=${section}`, { cache: "no-store" });
|
const response = await fetch(`${API_URL}/articles?section=${section}`, { cache: "no-store" });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -644,6 +776,10 @@ export async function getEditorialArticles(section: ArticleSection) {
|
||||||
// Returnerer tom liste dersom API-et ikke er tilgjengelig.
|
// Returnerer tom liste dersom API-et ikke er tilgjengelig.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (section === "meninger") {
|
||||||
|
return getImportedOpinionArticles();
|
||||||
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -672,6 +808,10 @@ export async function getEditorialArticleBySlug(
|
||||||
// Returnerer null dersom API-et ikke er tilgjengelig.
|
// Returnerer null dersom API-et ikke er tilgjengelig.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (section === "meninger") {
|
||||||
|
return getImportedOpinionArticleBySlug(slug);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue