- FAQ -
-{section.title}
- - {section.body ? ( -{paragraph}
- ))} --
- {section.items.map((item) => (
-
- {item} - ))} -
diff --git a/2026-04-28 14.52.19 teeoff.no dcb2c926e3f9.jpg b/2026-04-28 14.52.19 teeoff.no dcb2c926e3f9.jpg new file mode 100644 index 0000000..440519a Binary files /dev/null and b/2026-04-28 14.52.19 teeoff.no dcb2c926e3f9.jpg differ diff --git a/2026-04-28_142102.png b/2026-04-28_142102.png new file mode 100644 index 0000000..5bb594a Binary files /dev/null and b/2026-04-28_142102.png differ diff --git a/backend/.env.example b/backend/.env.example index 83964e2..e0481e0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,6 +3,7 @@ SMTP_PORT=465 SMTP_USER=teeoff@example.com SMTP_PASS=replace-with-your-smtp-password COMMENT_NOTIFICATION_TO_EMAIL=teeoff@example.com +FACILITY_RATING_NOTIFICATION_TO_EMAIL=teeoff@example.com EMAIL_TO=ops@example.com GEMINI_API_KEY=replace-with-your-gemini-api-key DATABASE_URL=postgresql://teeoff_admin:replace-with-your-postgres-password@db:5432/teeoff diff --git a/backend/main.py b/backend/main.py index aaaad1d..42b197c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -83,6 +83,10 @@ COMMENT_NOTIFICATION_TO_EMAIL = os.getenv( "COMMENT_NOTIFICATION_TO_EMAIL", CONTACT_FORM_TO_EMAIL, ).strip() +FACILITY_RATING_NOTIFICATION_TO_EMAIL = os.getenv( + "FACILITY_RATING_NOTIFICATION_TO_EMAIL", + COMMENT_NOTIFICATION_TO_EMAIL, +).strip() INDEXNOW_KEY = os.getenv("INDEXNOW_KEY", "").strip() INDEXNOW_KEY_LOCATION = os.getenv("INDEXNOW_KEY_LOCATION", "").strip() INDEXNOW_ENDPOINT = os.getenv("INDEXNOW_ENDPOINT", "https://api.indexnow.org/indexnow").strip() @@ -100,6 +104,7 @@ PUBLIC_FACILITIES_CACHE_TTLS = { } PUBLIC_FACILITY_DETAIL_CACHE_TTL_SECONDS = 900 PUBLIC_PLACE_PAGE_CACHE_TTL_SECONDS = 3600 +PUBLIC_SITE_PAGE_CACHE_TTL_SECONDS = 3600 pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") @@ -146,6 +151,7 @@ def initialize_public_api_caches() -> None: app.state.public_facilities_cache = {} app.state.public_facility_detail_cache = {} app.state.public_place_page_cache = {} + app.state.public_site_page_cache = {} def get_public_facilities_cache_ttl(view: str | None) -> int: @@ -183,7 +189,7 @@ def apply_public_cache_headers(response: Response, ttl_seconds: int) -> None: response.headers["Cache-Control"] = f"public, max-age=60, s-maxage={ttl}, stale-while-revalidate=60" -def invalidate_public_api_caches(*, include_place_pages: bool = False) -> None: +def invalidate_public_api_caches(*, include_place_pages: bool = False, include_site_pages: bool = False) -> None: facilities_cache = getattr(app.state, "public_facilities_cache", None) if isinstance(facilities_cache, dict): facilities_cache.clear() @@ -197,6 +203,11 @@ def invalidate_public_api_caches(*, include_place_pages: bool = False) -> None: if isinstance(place_page_cache, dict): place_page_cache.clear() + if include_site_pages: + site_page_cache = getattr(app.state, "public_site_page_cache", None) + if isinstance(site_page_cache, dict): + site_page_cache.clear() + def get_configured_public_base_url() -> str: for env_name in ("PUBLIC_BASE_URL", "NEXT_PUBLIC_SITE_URL"): @@ -616,6 +627,65 @@ async def send_comment_notification_email( await asyncio.to_thread(_send) +async def send_facility_rating_notification_email( + *, + facility_name: str, + facility_url: str, + reviewer_name: str, + reviewer_email: str | None, + quality_rating: int, + conditions_rating: int, + hospitality_rating: int, + overall_rating: float, + rating_count: int, + quality_average: float | None, + conditions_average: float | None, + hospitality_average: float | None, + overall_average: float | None, + is_new_rating: bool, + ip_hash: str | None, +) -> None: + if not (is_magic_link_configured() and FACILITY_RATING_NOTIFICATION_TO_EMAIL): + return + + subject = f"[TeeOff Vurdering] {facility_name}" + body = ( + "Ny brukervurdering på TeeOff.no\n\n" + f"Golfanlegg: {facility_name}\n" + f"Lenke: {facility_url}\n" + f"Hendelse: {'Ny vurdering' if is_new_rating else 'Oppdatert vurdering'}\n" + f"Bruker: {reviewer_name}\n" + f"E-post: {reviewer_email or 'ikke tilgjengelig'}\n" + f"IP-hash: {ip_hash or 'ukjent'}\n\n" + "Innsendt vurdering:\n" + f"Kvalitet på anlegg: {quality_rating}/5\n" + f"Forhold: {conditions_rating}/5\n" + f"Gjestfrihet: {hospitality_rating}/5\n" + f"Snitt for denne vurderingen: {overall_rating:.1f}/5\n\n" + "Oppsummert etter lagring:\n" + f"Antall vurderinger: {rating_count}\n" + f"Gjennomsnitt kvalitet på anlegg: {quality_average if quality_average is not None else 'ikke tilgjengelig'}/5\n" + f"Gjennomsnitt forhold: {conditions_average if conditions_average is not None else 'ikke tilgjengelig'}/5\n" + f"Gjennomsnitt gjestfrihet: {hospitality_average if hospitality_average is not None else 'ikke tilgjengelig'}/5\n" + f"Totalt gjennomsnitt: {overall_average if overall_average is not None else 'ikke tilgjengelig'}/5\n" + ) + + def _send() -> None: + mail = EmailMessage() + mail["From"] = PUBLIC_FROM_EMAIL + mail["To"] = FACILITY_RATING_NOTIFICATION_TO_EMAIL + if reviewer_email: + mail["Reply-To"] = reviewer_email + mail["Subject"] = subject + mail.set_content(body) + + with smtplib.SMTP_SSL(SMTP_SERVER, int(SMTP_PORT)) as server: + server.login(SMTP_USER, SMTP_PASS) + server.send_message(mail) + + await asyncio.to_thread(_send) + + async def validate_admin_session_token(token: str) -> str: try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) @@ -673,6 +743,16 @@ class SitePageSeoUpsertRequest(BaseModel): meta_description: Optional[str] = None +class SitePageUpsertRequest(BaseModel): + eyebrow: Optional[str] = None + title: Optional[str] = None + hero_image_url: Optional[str] = None + intro_html: Optional[str] = "" + body_html: Optional[str] = "" + meta_title: Optional[str] = None + meta_description: Optional[str] = None + + class SimulatorOperatorUpsertRequest(BaseModel): name: str slug: Optional[str] = None @@ -1109,6 +1189,268 @@ def format_site_page_seo_row(row): return d +LEGACY_SITE_PAGE_ARTICLE_SLUGS = { + "turneringer": "note-to-self-lenker-til-viktige-turneringer-i-golfbox", +} + +SITE_PAGE_CONFIGS: dict[str, dict[str, str]] = { + "turneringer": { + "path": "/turneringer", + "eyebrow": "Turneringer", + "title": "Lenker til viktige turneringer i Golfbox", + "hero_image_url": "", + "intro_html": ( + "
Her er alle turneringene vi ikke vet hvordan vi skal finne i Golfbox " + "(og andre steder). God golfsesong!
" + ), + "body_html": "", + "meta_title": "Golfturneringer i Norge: Oversikt og terminlister | TeeOff.no", + "meta_description": ( + "Vanskelig å finne frem i Golfbox? Vi samler terminlister for Narvesen Tour " + "og regionale golfturneringer i hele Norge på ett sted. Finn din neste turnering her!" + ), + }, + "klubbnummer": { + "path": "/klubbnummer", + "eyebrow": "Klubbnummer", + "title": "Klubbnummer i Golfbox", + "hero_image_url": "", + "intro_html": ( + "I booking-vinduet i Golfbox (eller Gimmie) er det ofte vanskelig å se " + "hvilken klubb spillere er fra. Om du ønsker kan du da bruke denne tabellen som oppslagsverk.
" + ), + "body_html": "", + "meta_title": "NGF Klubbnummer: Oversikt for Golfbox og Gimmie | TeeOff.no", + "meta_description": ( + "Hvilken klubb tilhører nummeret? Se komplett og sorterbar oversikt over alle norske " + "golfklubber og deres NGF-klubbnummer for bruk i Golfbox og Gimmie." + ), + }, + "om": { + "path": "/om", + "eyebrow": "FAQ / Om", + "title": "Hva TeeOff er, og hvorfor siden finnes", + "hero_image_url": "", + "intro_html": ( + "Kortversjonen er fortsatt den samme: TeeOff bruker mye tid på å samle, rydde " + "og presentere norske golfanlegg på en ordentlig måte, slik at det blir lettere " + "å finne ut hvor man faktisk har lyst til å spille.
" + ), + "body_html": """ +TeeOff startet i 2015 fordi det var unødvendig vanskelig å finne ut hvilke golfbaner som ligger hvor, hva de tilbyr, og hvilken bane som faktisk passer til turen du vurderer å ta. Ambisjonen var enkel: samle norske golfanlegg på ett sted og gjøre dem lettere å finne, forstå og sammenligne.
+Kjernen er den samme i dag: kjærlighet til golf, lysten til å vise hvor bra det er å spille i Norge, og behovet for en oversikt som faktisk er nyttig for greenfeespillere, klubbfolk og andre som vil oppdage nye baner.
+TeeOff er ikke laget for å erstatte klubbenes egne nettsider. Målet er å gjøre klubbene enklere å finne, og å gjøre terskelen lavere for å dra og spille et nytt sted.
+Innholdet hentes fra klubbenes egne nettsider, Golfbox, Shotzoom, sosiale medier, direkte kontakt med klubbene og i noen tilfeller egne besøk og manuell research.
+Ambisjonen er at informasjonen skal være korrekt, oppdatert og praktisk anvendelig. Når noe endrer seg, er TeeOff avhengig av gode kilder og raske tilbakemeldinger. Oppdager du feil, er det derfor bare å si fra.
+TeeOff bygger videre på et mangeårig arbeid med å samle og rydde informasjon om norske golfanlegg. Den nye løsningen er laget for å gjøre det enklere å holde flere typer klubbdata oppdatert enn tidligere.
+Har du informasjon som bør endres, bilder som bør brukes, eller tips til innhold, kan du sende det inn via kontaktsiden.
+Hvis du representerer en klubb og vil bytte hovedbildet som presenterer banen, er det bare å ta kontakt og sende over et godt bilde i bredt format. Fotograf krediteres når informasjonen følger med.
+TeeOff er gratis å bruke for både golfspillere og klubber. Det ligger mye arbeid bak å samle, rydde og presentere informasjonen, men selve synligheten på TeeOff er ikke låst bak betaling.
+Nei, det er ikke meningen. Kontaktinformasjon publiseres fordi den skal være nyttig for vanlige mennesker, samtidig som løsningene rundt kontaktsiden og systemet er laget for å redusere automatisert misbruk og spam.
+Bruksmønsteret har lenge vært ganske tydelig: mange bruker TeeOff når de faktisk vurderer å dra og spille. De sammenligner anlegg, ser på praktisk informasjon, sjekker kart, turneringer og detaljer før de bestemmer seg.
+Det er den viktigste verdien TeeOff kan gi klubbene også i dag: gjøre det lettere for flere å oppdage nye golfanlegg, finne relevant informasjon og komme seg ut på banen.
+TeeOff bruker analyseverktøy for å forstå hvilke sider som brukes, hvordan besøkende navigerer og hva som bør forbedres. I dag skjer dette med Matomo.
+Hvis du vil vite mer om personvern, cookies og analyse, finnes det en egen side for dette.
+ +Klubber har ofte behov for å løfte frem turneringer, VTG-kurs og andre tilbud. TeeOff har derfor egne flater for dette, og slike ting kan også løftes frem sammen med den enkelte klubbprofilen.
+ +Denne siden forklarer kort hvilke opplysninger TeeOff behandler, hvorfor vi " + "gjør det, og hvordan cookies brukes på nettsiden.
" + ), + "body_html": """ +TeeOff lagrer i hovedsak opplysninger som er nødvendige for å vise klubbdata, publisere innhold og håndtere henvendelser fra brukere.
+Hvis du bruker kontaktskjemaet, lagres opplysningene du selv sender inn for å kunne besvare henvendelsen. Hvis du kommenterer artikler eller bruker innloggingsfunksjoner, behandles de opplysningene som er nødvendige for å autentisere deg og vise innholdet.
+TeeOff bruker cookies til noen få, konkrete formål:
+Nødvendige cookies brukes for at nettsiden skal fungere. Analysecookies brukes for å forstå hvordan nettsiden brukes og forbedre innhold og funksjonalitet.
+TeeOff bruker Matomo for å måle trafikk på nettsiden. Formålet er å forstå hvilke sider som brukes, hvordan besøkende navigerer og hvor innhold kan forbedres.
+Matomo-instansen kjøres på analyse.envide.no. Admin-områdene spores ikke på samme måte som vanlige publikumssider.
+Hvis du har spørsmål om personvern, cookies eller ønsker innsyn knyttet til opplysninger du har sendt inn, kan du bruke kontaktsiden eller sende e-post til teeoff@teeoff.no.
+Bruk skjemaet hvis du vil melde fra om feil i klubbdata, tips om artikler, " + "spørsmål om administrasjonstilgang eller andre henvendelser.
" + ), + "body_html": ( + "Du kan bruke skjemaet under hvis du vil melde fra om feil i klubbdata, " + "sende redaksjonelle tips eller spørre om klubbkontoer og administrasjonstilgang.
" + ), + "meta_title": "Kontakt TeeOff", + "meta_description": ( + "Kontakt TeeOff for feil i klubbdata, redaksjonelle tips, klubbkontoer og andre henvendelser." + ), + }, +} + +VALID_SITE_PAGE_KEYS = set(SITE_PAGE_CONFIGS.keys()) + + +def load_legacy_site_page_article(slug: str) -> dict[str, Any] | None: + normalized_slug = str(slug or "").strip() + if not normalized_slug: + return None + + candidate_paths = [ + Path(__file__).resolve().parent.parent / "frontend" / "src" / "content" / "importedMeninger.json", + Path.cwd() / "frontend" / "src" / "content" / "importedMeninger.json", + ] + + for candidate_path in candidate_paths: + try: + if not candidate_path.exists(): + continue + payload = json.loads(candidate_path.read_text(encoding="utf-8")) + except Exception: + continue + + if not isinstance(payload, list): + continue + + for entry in payload: + if not isinstance(entry, dict): + continue + if str(entry.get("slug") or "").strip() == normalized_slug: + return entry + + return None + + +def build_default_site_page_row(page_key: str) -> dict[str, Any] | None: + normalized_key = str(page_key or "").strip().lower() + config = SITE_PAGE_CONFIGS.get(normalized_key) + if not config: + return None + + payload = { + "page_key": normalized_key, + "eyebrow": str(config.get("eyebrow") or ""), + "title": str(config.get("title") or ""), + "hero_image_url": str(config.get("hero_image_url") or ""), + "intro_html": str(config.get("intro_html") or ""), + "body_html": str(config.get("body_html") or ""), + "meta_title": str(config.get("meta_title") or ""), + "meta_description": str(config.get("meta_description") or ""), + "created_at": None, + "updated_at": None, + } + + legacy_slug = LEGACY_SITE_PAGE_ARTICLE_SLUGS.get(normalized_key) + if legacy_slug: + legacy_article = load_legacy_site_page_article(legacy_slug) + if legacy_article: + payload["title"] = str(legacy_article.get("title") or payload["title"]) + payload["body_html"] = str(legacy_article.get("contentHtml") or payload["body_html"]) + featured_image = legacy_article.get("featuredImage") if isinstance(legacy_article, dict) else None + if isinstance(featured_image, dict): + payload["hero_image_url"] = str( + featured_image.get("originalUrl") + or featured_image.get("url") + or payload["hero_image_url"] + ) + + return payload + + +def format_site_page_row(row): + if row is None: + return None + + d = dict(row) + + for key in ["created_at", "updated_at"]: + if isinstance(d.get(key), (date, datetime)): + d[key] = d[key].isoformat() + + d["page_key"] = str(d.get("page_key") or "") + d["eyebrow"] = str(d.get("eyebrow") or "") + d["title"] = str(d.get("title") or "") + d["hero_image_url"] = str(d.get("hero_image_url") or "") + d["intro_html"] = str(d.get("intro_html") or "") + d["body_html"] = str(d.get("body_html") or "") + d["meta_title"] = str(d.get("meta_title") or "") + d["meta_description"] = str(d.get("meta_description") or "") + return d + + def format_simulator_operator_row(row): if row is None: return None @@ -2487,6 +2829,59 @@ async def ensure_site_page_seo_table(conn): """) +async def ensure_site_pages_table(conn): + await conn.execute(""" + CREATE TABLE IF NOT EXISTS site_pages ( + page_key VARCHAR(255) PRIMARY KEY, + eyebrow TEXT, + title TEXT, + hero_image_url TEXT, + intro_html TEXT, + body_html TEXT, + meta_title TEXT, + meta_description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS eyebrow TEXT") + await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS title TEXT") + await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS hero_image_url TEXT") + await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS intro_html TEXT") + await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS body_html TEXT") + await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS meta_title TEXT") + await conn.execute("ALTER TABLE site_pages ADD COLUMN IF NOT EXISTS meta_description TEXT") + + for page_key in VALID_SITE_PAGE_KEYS: + default_row = build_default_site_page_row(page_key) + if not default_row: + continue + await conn.execute( + """ + INSERT INTO site_pages ( + page_key, + eyebrow, + title, + hero_image_url, + intro_html, + body_html, + meta_title, + meta_description + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (page_key) DO NOTHING + """, + default_row["page_key"], + default_row["eyebrow"], + default_row["title"], + default_row["hero_image_url"], + default_row["intro_html"], + default_row["body_html"], + default_row["meta_title"], + default_row["meta_description"], + ) + + async def ensure_articles_table(conn): await conn.execute(""" CREATE TABLE IF NOT EXISTS articles ( @@ -2749,6 +3144,7 @@ async def lifespan(app: FastAPI): await ensure_vtg_course_tables(conn) await ensure_place_pages_table(conn) await ensure_site_page_seo_table(conn) + await ensure_site_pages_table(conn) await ensure_articles_table(conn) await ensure_public_user_tables(conn) await ensure_simulator_operator_tables(conn) @@ -3393,12 +3789,25 @@ async def get_facility_ratings(request: Request, slug: str): @app.put("/api/facilities/{slug}/ratings") async def upsert_facility_rating(request: Request, payload: FacilityRatingUpsertRequest, slug: str): viewer = await require_authenticated_public_user(request) + is_new_rating = False async with app.state.pool.acquire() as conn: facility = await get_published_facility_by_slug(conn, slug) if not facility: raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet.") + existing_rating = await conn.fetchrow( + """ + SELECT 1 + FROM facility_ratings + WHERE facility_id = $1 AND user_id = $2 + LIMIT 1 + """, + int(facility["id"]), + int(viewer["id"]), + ) + is_new_rating = existing_rating is None + await conn.execute( """ INSERT INTO facility_ratings ( @@ -3426,6 +3835,33 @@ async def upsert_facility_rating(request: Request, payload: FacilityRatingUpsert int(viewer["id"]), ) + try: + facility_url = f"{build_public_base_url(request)}/golfbaner/{facility['slug']}" + summary = response_payload["summary"] + overall_rating = round( + (payload.quality_rating + payload.conditions_rating + payload.hospitality_rating) / 3, + 1, + ) + await send_facility_rating_notification_email( + facility_name=str(facility["name"] or facility["slug"]), + facility_url=facility_url, + reviewer_name=str(viewer.get("display_name") or viewer.get("full_name") or "TeeOff-leser"), + reviewer_email=(str(viewer.get("email")).strip() if viewer.get("email") else None), + quality_rating=payload.quality_rating, + conditions_rating=payload.conditions_rating, + hospitality_rating=payload.hospitality_rating, + overall_rating=overall_rating, + rating_count=int(summary["rating_count"] or 0), + quality_average=summary["quality_average"], + conditions_average=summary["conditions_average"], + hospitality_average=summary["hospitality_average"], + overall_average=summary["overall_average"], + is_new_rating=is_new_rating, + ip_hash=hash_request_ip(request), + ) + except Exception as exc: + print(f"Kunne ikke sende vurderingsvarsel: {exc}") + return { "detail": "Vurderingen er lagret.", "viewer": viewer, @@ -3901,6 +4337,38 @@ async def get_place_page(slug: str, response: Response): return payload +@app.get("/api/site-pages/{page_key}") +async def get_public_site_page(page_key: str, response: Response): + normalized_key = str(page_key or "").strip().lower() + if normalized_key not in VALID_SITE_PAGE_KEYS: + raise HTTPException(status_code=404, detail="Side ikke funnet.") + + site_page_cache: dict[str, tuple[float, Any]] = getattr(app.state, "public_site_page_cache", {}) + cached_payload = read_public_cache_entry(site_page_cache, normalized_key) + if cached_payload is not None: + apply_public_cache_headers(response, PUBLIC_SITE_PAGE_CACHE_TTL_SECONDS) + return cached_payload + + async with app.state.pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT * FROM site_pages WHERE page_key = $1", + normalized_key, + ) + + payload = format_site_page_row(row) if row else build_default_site_page_row(normalized_key) + if payload is None: + raise HTTPException(status_code=404, detail="Side ikke funnet.") + + write_public_cache_entry( + site_page_cache, + normalized_key, + payload, + PUBLIC_SITE_PAGE_CACHE_TTL_SECONDS, + ) + apply_public_cache_headers(response, PUBLIC_SITE_PAGE_CACHE_TTL_SECONDS) + return payload + + VALID_SITE_PAGE_SEO_KEYS = {"golfbaner", "vtg", "medlemskap", "banebesok", "meninger", "simulatorer"} @@ -4100,6 +4568,76 @@ async def get_admin_place_page(slug: str): return format_place_page_row(row) +@app.get("/api/admin/site-pages/{page_key}") +async def get_admin_site_page(page_key: str): + normalized_key = str(page_key or "").strip().lower() + if normalized_key not in VALID_SITE_PAGE_KEYS: + raise HTTPException(status_code=404, detail="Side ikke funnet.") + + async with app.state.pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT * FROM site_pages WHERE page_key = $1", + normalized_key, + ) + + payload = format_site_page_row(row) if row else build_default_site_page_row(normalized_key) + if payload is None: + raise HTTPException(status_code=404, detail="Side ikke funnet.") + return payload + + +@app.put("/api/admin/site-pages/{page_key}") +async def update_admin_site_page(page_key: str, request: SitePageUpsertRequest): + normalized_key = str(page_key or "").strip().lower() + if normalized_key not in VALID_SITE_PAGE_KEYS: + raise HTTPException(status_code=404, detail="Side ikke funnet.") + + async with app.state.pool.acquire() as conn: + row = await conn.fetchrow( + """ + INSERT INTO site_pages ( + page_key, + eyebrow, + title, + hero_image_url, + intro_html, + body_html, + meta_title, + meta_description + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (page_key) DO UPDATE + SET eyebrow = EXCLUDED.eyebrow, + title = EXCLUDED.title, + hero_image_url = EXCLUDED.hero_image_url, + intro_html = EXCLUDED.intro_html, + body_html = EXCLUDED.body_html, + meta_title = EXCLUDED.meta_title, + meta_description = EXCLUDED.meta_description, + updated_at = NOW() + RETURNING * + """, + normalized_key, + normalize_optional_text(request.eyebrow), + normalize_optional_text(request.title), + normalize_optional_text(request.hero_image_url), + request.intro_html or "", + request.body_html or "", + normalize_optional_text(request.meta_title), + normalize_optional_text(request.meta_description), + ) + + config = SITE_PAGE_CONFIGS.get(normalized_key) or {} + path = str(config.get("path") or "").strip() + invalidate_public_api_caches(include_site_pages=True) + if path: + schedule_indexnow_submission( + collect_page_indexnow_urls([path]), + reason="admin site page upsert", + ) + return format_site_page_row(row) + + @app.put("/api/admin/place-pages/{slug}") async def update_admin_place_page(slug: str, request: PlacePageUpsertRequest): normalized_slug = str(slug or "").strip().lower() diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index d32ae97..d832220 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1503,6 +1503,9 @@ export default function AdminDashboard() {Sider
+
+ Her redigerer du SEO for samlesidene og innholdet på de faste sidene som /turneringer, /klubbnummer, /om, /kontakt og /personvern-og-cookies.
+
Samlesider
++ Overstyr meta title og meta description på de store landingssidene uten å endre H1 eller innholdstekst. +
+Aktiv side
++ {SITE_PAGE_OPTIONS.find((option) => option.key === selectedPageKey)?.label || selectedPageKey} +
++ Sist lagret: {formatDateTime(pageSeoUpdatedAt)} +
+ {pageSeoFeedback ? ( +{pageSeoFeedback}
+ ) : null} +Sidemaler
+
+ Disse sidene bruker samme mal med toppbilde, flytende overskrift og redigerbart HTML-innhold. Her vedlikeholder du blant annet /turneringer.
+
Aktiv sidemal
+{selectedSiteTemplate?.label || selectedSitePageKey}
++ Offentlig URL: {selectedSiteTemplate?.path || "/"} +
+Gjeldende meta title
++ {effectiveSitePageMetaTitle || "Ikke tilgjengelig"} +
+Gjeldende meta description
++ {effectiveSitePageMetaDescription || "Ikke tilgjengelig"} +
++ Sist lagret: {formatDateTime(sitePageUpdatedAt)} +
+ {sitePageFeedback ? ( +{sitePageFeedback}
+ ) : null} +Toppbilde
++ Dette bildet fyller toppen av siden og får overskriften flytende over seg. +
+Eksisterende bilder
++ Velg blant eksisterende uploads, anleggsbilder og artikkelbilder som allerede brukes på TeeOff. +
+Intro over innholdet
++ Dette vises over selve innholdskortet, oppå toppbildet sammen med overskriften. +
+Hovedinnhold
+
+ HTML-innholdet som vises under toppbildet. Dette er feltet du bruker for å vedlikeholde for eksempel /turneringer.
+
Sted-sider
- Dette innholdet vises over nøkkeltall-/faktaboksen på `/sted/[slug]`.
+ Dette innholdet vises over nøkkeltall-/faktaboksen på /sted/[slug].
Samlesider
-- Her kan du overstyre meta title og meta description på de store landingssidene uten å endre H1 eller innholdstekst. -
-Aktiv side
-{SITE_PAGE_OPTIONS.find((option) => option.key === selectedPageKey)?.label || selectedPageKey}
-- Sist lagret: {formatDateTime(pageSeoUpdatedAt)} -
- {pageSeoFeedback ? ( -{pageSeoFeedback}
- ) : null} -I booking-vinduet i Golfbox (eller Gimmie) er det ofte vanskelig å se hvilken klubb spillere er fra. Om du ønsker kan du da bruke denne tabellen som oppslagsverk.
"; +const fallbackMetaTitle = "NGF Klubbnummer: Oversikt for Golfbox og Gimmie | TeeOff.no"; +const fallbackMetaDescription = "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, - description: pageDescription, - path: "/klubbnummer", -}); - export const revalidate = 3600; export const dynamic = "force-dynamic"; +export async function generateMetadata() { + const page = await fetchSitePage(pageKey); + return createPageMetadata({ + title: page?.meta_title?.trim() || fallbackMetaTitle, + description: page?.meta_description?.trim() || fallbackMetaDescription, + path: "/klubbnummer", + image: page?.hero_image_url, + }); +} + export default async function ClubNumbersPage() { const facilities = await fetchPublicFacilitiesBruk skjemaet hvis du vil melde fra om feil i klubbdata, tips om artikler, spørsmål om administrasjonstilgang eller andre henvendelser.
"; +const fallbackMetaTitle = "Kontakt TeeOff"; +const fallbackMetaDescription = "Kontakt TeeOff for feil i klubbdata, redaksjonelle tips, klubbkontoer og andre henvendelser."; const contactCards = [ @@ -28,23 +33,35 @@ const contactCards = [ }, ]; -export const metadata = createPageMetadata({ - title: pageTitle, - description: pageDescription, - path: "/kontakt", -}); +export const dynamic = "force-dynamic"; + +export async function generateMetadata() { + const page = await fetchSitePage(pageKey); + return createPageMetadata({ + title: page?.meta_title?.trim() || fallbackMetaTitle, + description: page?.meta_description?.trim() || fallbackMetaDescription, + path: "/kontakt", + image: page?.hero_image_url, + }); +} export default async function ContactPage({ searchParams, }: { searchParams?: Promise<{ topic?: string; message?: string }>; }) { + const page = await fetchSitePage(pageKey); const params = (await searchParams) || {}; const initialTopic = typeof params.topic === "string" ? params.topic : undefined; const initialMessage = typeof params.message === "string" ? params.message : undefined; + const title = page?.title?.trim() || fallbackVisibleTitle; + const introHtml = page?.intro_html?.trim() || fallbackIntro; + const bodyHtml = page?.body_html?.trim() || ""; + const metaTitle = page?.meta_title?.trim() || fallbackMetaTitle; + const metaDescription = page?.meta_description?.trim() || fallbackMetaDescription; const collectionJsonLd = createCollectionPageJsonLd({ - name: pageTitle, - description: pageDescription, + name: metaTitle, + description: metaDescription, path: "/kontakt", }); const breadcrumbJsonLd = createBreadcrumbJsonLd([ @@ -62,10 +79,12 @@ export default async function ContactPage({ type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} /> -Kortversjonen er fortsatt den samme: TeeOff bruker mye tid på å samle, rydde og presentere norske golfanlegg på en ordentlig måte, slik at det blir lettere å finne ut hvor man faktisk har lyst til å spille.
"; +const fallbackMetaTitle = "FAQ / Om TeeOff"; +const fallbackMetaDescription = "Hvorfor TeeOff finnes, hvilken informasjon som samles om norske golfanlegg, og hvordan siden brukes av både golfspillere og klubber."; +export const dynamic = "force-dynamic"; -const sections = [ - { - title: "Hvorfor dette nettstedet?", - body: [ - "TeeOff startet i 2015 fordi det var unødvendig vanskelig å finne ut hvilke golfbaner som ligger hvor, hva de tilbyr, og hvilken bane som faktisk passer til turen du vurderer å ta. Ambisjonen var enkel: samle norske golfanlegg på ett sted og gjøre dem lettere å finne, forstå og sammenligne.", - "Kjernen er den samme i dag: kjærlighet til golf, lysten til å vise hvor bra det er å spille i Norge, og behovet for en oversikt som faktisk er nyttig for greenfeespillere, klubbfolk og andre som vil oppdage nye baner.", - "TeeOff er ikke laget for å erstatte klubbenes egne nettsider. Målet er å gjøre klubbene enklere å finne, og å gjøre terskelen lavere for å dra og spille et nytt sted.", - ], - }, - { - title: "Hvilken informasjon finnes om banene?", - items: [ - "adresse og plassering på kart", - "bilder og video når det finnes", - "kontaktinformasjon, hjemmeside og sosiale medier", - "banestatus, vær, flyfoto og turneringslenker", - "banebeskrivelse og praktisk info om fasiliteter", - "head pro, greenfee, medlemskap og Veien til Golf", - "scorekort, slope og annen nyttig banedata", - "redaksjonelt innhold som banebesøk og meninger når det finnes", - ], - }, - { - title: "Hvor kommer informasjonen fra?", - body: [ - "Innholdet hentes fra klubbenes egne nettsider, Golfbox, Shotzoom, sosiale medier, direkte kontakt med klubbene og i noen tilfeller egne besøk og manuell research.", - "Ambisjonen er at informasjonen skal være korrekt, oppdatert og praktisk anvendelig. Når noe endrer seg, er TeeOff avhengig av gode kilder og raske tilbakemeldinger. Oppdager du feil, er det derfor bare å si fra.", - ], - }, - { - title: "Hvem holder TeeOff oppdatert?", - body: [ - "TeeOff bygger videre på et mangeårig arbeid med å samle og rydde informasjon om norske golfanlegg. Den nye løsningen er laget for å gjøre det enklere å holde flere typer klubbdata oppdatert enn tidligere.", - "Har du informasjon som bør endres, bilder som bør brukes, eller tips til innhold, kan du sende det inn via kontaktsiden.", - ], - }, - { - title: "Hvordan bytte toppbildet på en klubbside?", - body: [ - "Hvis du representerer en klubb og vil bytte hovedbildet som presenterer banen, er det bare å ta kontakt og sende over et godt bilde i bredt format. Fotograf krediteres når informasjonen følger med.", - ], - }, - { - title: "Koster dette noe?", - body: [ - "TeeOff er gratis å bruke for både golfspillere og klubber. Det ligger mye arbeid bak å samle, rydde og presentere informasjonen, men selve synligheten på TeeOff er ikke låst bak betaling.", - ], - }, - { - title: "Drukner klubbene i spam hvis e-postadressen vises?", - body: [ - "Nei, det er ikke meningen. Kontaktinformasjon publiseres fordi den skal være nyttig for vanlige mennesker, samtidig som løsningene rundt kontaktsiden og systemet er laget for å redusere automatisert misbruk og spam.", - ], - }, - { - title: "Hva betyr TeeOff for klubbene egentlig?", - body: [ - "Bruksmønsteret har lenge vært ganske tydelig: mange bruker TeeOff når de faktisk vurderer å dra og spille. De sammenligner anlegg, ser på praktisk informasjon, sjekker kart, turneringer og detaljer før de bestemmer seg.", - "Det er den viktigste verdien TeeOff kan gi klubbene også i dag: gjøre det lettere for flere å oppdage nye golfanlegg, finne relevant informasjon og komme seg ut på banen.", - ], - }, - { - title: "Sporing og analyse", - body: [ - "TeeOff bruker analyseverktøy for å forstå hvilke sider som brukes, hvordan besøkende navigerer og hva som bør forbedres. I dag skjer dette med Matomo.", - "Hvis du vil vite mer om personvern, cookies og analyse, finnes det en egen side for dette.", - ], - cta: { href: "/personvern-og-cookies", label: "Les om personvern og cookies" }, - }, - { - title: "Turneringer, kurs og andre tilbud", - body: [ - "Klubber har ofte behov for å løfte frem turneringer, VTG-kurs og andre tilbud. TeeOff har derfor egne flater for dette, og slike ting kan også løftes frem sammen med den enkelte klubbprofilen.", - ], - links: [ - { href: "/turneringer", label: "Se turneringer" }, - { href: "/vtg", label: "Se Veien til Golf" }, - { href: "/kontakt", label: "Kontakt TeeOff" }, - ], - }, -]; +export async function generateMetadata() { + const page = await fetchSitePage(pageKey); + return createPageMetadata({ + title: page?.meta_title?.trim() || fallbackMetaTitle, + description: page?.meta_description?.trim() || fallbackMetaDescription, + path: "/om", + image: page?.hero_image_url, + }); +} -export const metadata = createPageMetadata({ - title: pageTitle, - description: pageDescription, - path: "/om", -}); +export default async function AboutPage() { + const page = await fetchSitePage(pageKey); + const title = page?.title?.trim() || fallbackVisibleTitle; + const introHtml = page?.intro_html?.trim() || fallbackIntro; + const bodyHtml = page?.body_html?.trim() || ""; + const metaTitle = page?.meta_title?.trim() || fallbackMetaTitle; + const metaDescription = page?.meta_description?.trim() || fallbackMetaDescription; -export default function AboutPage() { const collectionJsonLd = createCollectionPageJsonLd({ - name: pageTitle, - description: pageDescription, + name: metaTitle, + description: metaDescription, path: "/om", }); const breadcrumbJsonLd = createBreadcrumbJsonLd([ @@ -119,56 +53,13 @@ export default function AboutPage() { type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} /> -- FAQ -
-{paragraph}
- ))} -Denne siden forklarer kort hvilke opplysninger TeeOff behandler, hvorfor vi gjør det, og hvordan cookies brukes på nettsiden.
"; +const fallbackMetaTitle = "Personvern og cookies"; +const fallbackMetaDescription = "Hvordan TeeOff behandler personopplysninger, bruker cookies og måleverktøy som Matomo."; -export const metadata = createPageMetadata({ - title: pageTitle, - description: pageDescription, - path: "/personvern-og-cookies", -}); +export const dynamic = "force-dynamic"; + +export async function generateMetadata() { + const page = await fetchSitePage(pageKey); + return createPageMetadata({ + title: page?.meta_title?.trim() || fallbackMetaTitle, + description: page?.meta_description?.trim() || fallbackMetaDescription, + path: "/personvern-og-cookies", + image: page?.hero_image_url, + }); +} + +export default async function PrivacyAndCookiesPage() { + const page = await fetchSitePage(pageKey); + const title = page?.title?.trim() || fallbackVisibleTitle; + const introHtml = page?.intro_html?.trim() || fallbackIntro; + const bodyHtml = page?.body_html?.trim() || ""; + const metaTitle = page?.meta_title?.trim() || fallbackMetaTitle; + const metaDescription = page?.meta_description?.trim() || fallbackMetaDescription; -export default function PrivacyAndCookiesPage() { const collectionJsonLd = createCollectionPageJsonLd({ - name: pageTitle, - description: pageDescription, + name: metaTitle, + description: metaDescription, path: "/personvern-og-cookies", }); const breadcrumbJsonLd = createBreadcrumbJsonLd([ @@ -36,70 +54,13 @@ export default function PrivacyAndCookiesPage() { type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} /> - -- TeeOff lagrer i hovedsak opplysninger som er nødvendige for å vise klubbdata, - publisere innhold og håndtere henvendelser fra brukere. -
-- Hvis du bruker kontaktskjemaet, lagres opplysningene du selv sender inn for å kunne - besvare henvendelsen. Hvis du kommenterer artikler eller bruker innloggingsfunksjoner, - behandles de opplysningene som er nødvendige for å autentisere deg og vise innholdet. -
-TeeOff bruker cookies til noen få, konkrete formål:
-- Nødvendige cookies brukes for at nettsiden skal fungere. Analysecookies brukes for å - forstå hvordan nettsiden brukes og forbedre innhold og funksjonalitet. -
-- TeeOff bruker Matomo for å måle trafikk på nettsiden. Formålet er å forstå hvilke - sider som brukes, hvordan besøkende navigerer og hvor innhold kan forbedres. -
-- Matomo-instansen kjøres på analyse.envide.no. Admin-områdene spores - ikke på samme måte som vanlige publikumssider. -
-- Hvis du har spørsmål om personvern, cookies eller ønsker innsyn knyttet til opplysninger - du har sendt inn, kan du bruke kontaktsiden eller sende e-post til{" "} - teeoff@teeoff.no. -
-Her er alle turneringene vi ikke vet hvordan vi skal finne i Golfbox (og andre steder). God golfsesong!
"; export async function generateMetadata() { - const article = await getOpinionArticleBySlug(articleSlug); + const page = await fetchSitePage(pageKey); + const title = page?.meta_title?.trim() || fallbackMetaTitle; + const description = page?.meta_description?.trim() || fallbackMetaDescription; return createPageMetadata({ - title: pageTitle, - description: pageDescription, + title, + description, path: "/turneringer", - image: article?.heroImages[0]?.src, + image: page?.hero_image_url, }); } export default async function TournamentsPage() { - const article = await getOpinionArticleBySlug(articleSlug); - - if (!article) { - notFound(); - } + const page = await fetchSitePage(pageKey); + const visibleTitle = page?.title?.trim() || fallbackTitle; + const introHtml = page?.intro_html?.trim() || fallbackIntro; + const bodyHtml = page?.body_html?.trim() || ""; + const metaTitle = page?.meta_title?.trim() || fallbackMetaTitle; + const metaDescription = page?.meta_description?.trim() || fallbackMetaDescription; const collectionJsonLd = createCollectionPageJsonLd({ - name: pageTitle, - description: pageDescription, + name: metaTitle, + description: metaDescription, path: "/turneringer", }); const breadcrumbJsonLd = createBreadcrumbJsonLd([ @@ -73,30 +57,13 @@ export default async function TournamentsPage() { type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} /> -- Galleri -
-- {article.mediaGallery.length} elementer -
-