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": """ +
+

Hvorfor dette nettstedet?

+

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.

+
+
+

Hvilken informasjon finnes om banene?

+ +
+
+

Hvor kommer informasjonen fra?

+

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.

+
+
+

Hvem holder TeeOff oppdatert?

+

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.

+
+
+

Hvordan bytte toppbildet på en klubbside?

+

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.

+
+
+

Koster dette noe?

+

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.

+
+
+

Drukner klubbene i spam hvis e-postadressen vises?

+

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.

+
+
+

Hva betyr TeeOff for klubbene egentlig?

+

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.

+
+
+

Sporing og analyse

+

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.

+

Les om personvern og cookies

+
+
+

Turneringer, kurs og andre tilbud

+

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.

+

Se turneringer
Se Veien til Golf
Kontakt TeeOff

+
+""".strip(), + "meta_title": "FAQ / Om TeeOff", + "meta_description": ( + "Hvorfor TeeOff finnes, hvilken informasjon som samles om norske golfanlegg, " + "og hvordan siden brukes av både golfspillere og klubber." + ), + }, + "personvern-og-cookies": { + "path": "/personvern-og-cookies", + "eyebrow": "Personvern", + "title": "Personvern og cookies", + "hero_image_url": "", + "intro_html": ( + "

Denne siden forklarer kort hvilke opplysninger TeeOff behandler, hvorfor vi " + "gjør det, og hvordan cookies brukes på nettsiden.

" + ), + "body_html": """ +
+

Hva vi lagrer

+

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.

+
+
+

Cookies

+

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.

+
+
+

Analyse med Matomo

+

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.

+
+
+

Kontakt om personvern

+

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.

+
+""".strip(), + "meta_title": "Personvern og cookies", + "meta_description": ( + "Hvordan TeeOff behandler personopplysninger, bruker cookies og måleverktøy som Matomo." + ), + }, + "kontakt": { + "path": "/kontakt", + "eyebrow": "Kontakt", + "title": "Kontakt TeeOff", + "hero_image_url": "", + "intro_html": ( + "

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() {
Innhold
+ + {isSidebarCollapsed ? 'Si' : 'Sider'} + {isSidebarCollapsed ? 'S' : 'Steder'} @@ -1555,6 +1558,12 @@ export default function AdminDashboard() { > Nytt anlegg + + Sider + ; + media_gallery?: Array<{ + type?: "image" | "video"; + src?: string; + alt?: string; + caption?: string; + }>; + updated_at?: string | null; +}; + +type ImageLibraryEntry = { + url: string; + source: "uploads" | "facility" | "article"; + title: string; + detail: string; + modified_at?: string | null; +}; + +const DEFAULT_PAGE_KEY = "golfbaner"; +const DEFAULT_SITE_PAGE_KEY = "turneringer"; +const DEFAULT_IMAGE_LIBRARY_SOURCE = "all"; + +const SITE_PAGE_OPTIONS = [ + { key: "golfbaner", label: "/golfbaner" }, + { key: "vtg", label: "/vtg" }, + { key: "medlemskap", label: "/medlemskap" }, + { key: "banebesok", label: "/banebesok" }, + { key: "meninger", label: "/meninger" }, + { key: "simulatorer", label: "/simulatorer (fremtidig)" }, +]; + +const SITE_PAGE_SEO_SUGGESTIONS: Record = { + golfbaner: { + title: "Alle norske golfbaner: Finn din neste runde på gress | TeeOff.no", + description: + "Planlegg din neste golfrunde. Se komplett oversikt over alle norske golfbaner med oppdatert banestatus, greenfee-priser og kart på TeeOff.no.", + }, + vtg: { + title: "Veien til Golf: Finn golfkurs for nybegynnere (VTG) | TeeOff.no", + description: + "Vil du begynne med golf? Finn komplett oversikt over Veien til Golf-kurs (VTG) i hele Norge. Se kursdatoer, priser og finn din nærmeste klubb på TeeOff.no!", + }, + medlemskap: { + title: "Billig golfmedlemskap? Finn og sammenlign priser på alle klubber | TeeOff.no", + description: + "Hvor er det billigst å være medlem? Sammenlign priser på golfmedlemskap med full spillerett eller rimelige nasjonale alternativ (fjernmedlemskap) i Norge på TeeOff.no.", + }, + banebesok: { + title: "Golfreiser og banebesøk: Erfaringer fra norske golfbaner | TeeOff.no", + description: + "Bli med på tur! Vi besøker Norges vakreste golfbaner og deler ærlige reiseskildringer, spektakulære bilder og nyttige tips. Finn inspirasjon til din neste golfreise her.", + }, + meninger: { + title: "Golfblogg: Meninger, humor og skråblikk på golf-Norge | TeeOff.no", + description: + "Fra frustrasjon over saktespill til gleden over en perfekt drive. Les TeeOffs egne artikler, kommentarer og ærlige skråblikk på livet som golfer i Norge.", + }, + simulatorer: { + title: "Golfsimulatorer i Norge", + description: + "Finn golfsimulatorer, indoor golf og simulatorsteder i Norge når TeeOffs simulatoroversikt lanseres.", + }, +}; + +const SITE_TEMPLATE_OPTIONS = [ + { key: "turneringer", label: "/turneringer", path: "/turneringer" }, + { key: "klubbnummer", label: "/klubbnummer", path: "/klubbnummer" }, + { key: "om", label: "/om", path: "/om" }, + { key: "personvern-og-cookies", label: "/personvern-og-cookies", path: "/personvern-og-cookies" }, + { key: "kontakt", label: "/kontakt", path: "/kontakt" }, +]; + +const SITE_TEMPLATE_SEO_SUGGESTIONS: Record = { + turneringer: { + title: "Golfturneringer i Norge: Oversikt og terminlister | TeeOff.no", + 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: { + title: "NGF Klubbnummer: Oversikt for Golfbox og Gimmie | TeeOff.no", + 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: { + title: "FAQ / Om TeeOff", + description: + "Hvorfor TeeOff finnes, hvilken informasjon som samles om norske golfanlegg, og hvordan siden brukes av både golfspillere og klubber.", + }, + "personvern-og-cookies": { + title: "Personvern og cookies", + description: + "Hvordan TeeOff behandler personopplysninger, bruker cookies og måleverktøy som Matomo.", + }, + kontakt: { + title: "Kontakt TeeOff", + description: + "Kontakt TeeOff for feil i klubbdata, redaksjonelle tips, klubbkontoer og andre henvendelser.", + }, +}; + +const formatDateTime = (value: string | null | undefined) => { + if (!value) return "Ikke lagret ennå"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Ukjent"; + return date.toLocaleString("nb-NO", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +const normalizeImageUrl = (value: unknown) => { + if (typeof value !== "string") return ""; + return value.trim(); +}; + +const extractGalleryImageUrls = (gallery: unknown): string[] => { + if (!Array.isArray(gallery)) return []; + + return gallery.flatMap((entry) => { + if (typeof entry === "string") { + const url = normalizeImageUrl(entry); + return url ? [url] : []; + } + + if (!entry || typeof entry !== "object") { + return []; + } + + const candidate = entry as Record; + const url = + normalizeImageUrl(candidate.src) || + normalizeImageUrl(candidate.url) || + normalizeImageUrl(candidate.image); + + return url ? [url] : []; + }); +}; + +export default function AdminPagesPage() { + const [selectedPageKey, setSelectedPageKey] = useState(DEFAULT_PAGE_KEY); + const [pageMetaTitle, setPageMetaTitle] = useState(""); + const [pageMetaDescription, setPageMetaDescription] = useState(""); + const [pageSeoUpdatedAt, setPageSeoUpdatedAt] = useState(null); + const [isLoadingPageSeo, setIsLoadingPageSeo] = useState(true); + const [isSavingPageSeo, setIsSavingPageSeo] = useState(false); + const [pageSeoFeedback, setPageSeoFeedback] = useState(""); + + const [selectedSitePageKey, setSelectedSitePageKey] = useState(DEFAULT_SITE_PAGE_KEY); + const [sitePageEyebrow, setSitePageEyebrow] = useState(""); + const [sitePageTitle, setSitePageTitle] = useState(""); + const [sitePageHeroImageUrl, setSitePageHeroImageUrl] = useState(""); + const [sitePageIntroHtml, setSitePageIntroHtml] = useState(""); + const [sitePageBodyHtml, setSitePageBodyHtml] = useState(""); + const [sitePageMetaTitle, setSitePageMetaTitle] = useState(""); + const [sitePageMetaDescription, setSitePageMetaDescription] = useState(""); + const [sitePageUpdatedAt, setSitePageUpdatedAt] = useState(null); + const [isLoadingSitePage, setIsLoadingSitePage] = useState(true); + const [isSavingSitePage, setIsSavingSitePage] = useState(false); + const [isUploadingSitePageHero, setIsUploadingSitePageHero] = useState(false); + const [sitePageFeedback, setSitePageFeedback] = useState(""); + const [imageLibrarySource, setImageLibrarySource] = useState< + "all" | "uploads" | "facility" | "article" + >( + DEFAULT_IMAGE_LIBRARY_SOURCE, + ); + const [imageLibrarySearch, setImageLibrarySearch] = useState(""); + const [imageLibrary, setImageLibrary] = useState([]); + const [isLoadingImageLibrary, setIsLoadingImageLibrary] = useState(true); + const sitePageHeroInputRef = useRef(null); + + const sitePageSuggestion = SITE_PAGE_SEO_SUGGESTIONS[selectedPageKey] || { + title: "", + description: "", + }; + const selectedSiteTemplate = SITE_TEMPLATE_OPTIONS.find((option) => option.key === selectedSitePageKey); + const siteTemplateSuggestion = SITE_TEMPLATE_SEO_SUGGESTIONS[selectedSitePageKey] || { + title: "", + description: "", + }; + const effectiveSitePageMetaTitle = sitePageMetaTitle.trim() || siteTemplateSuggestion.title; + const effectiveSitePageMetaDescription = + sitePageMetaDescription.trim() || siteTemplateSuggestion.description; + const visibleImageLibrary = useMemo(() => { + const query = imageLibrarySearch.trim().toLowerCase(); + + return imageLibrary.filter((entry) => { + if (imageLibrarySource !== "all" && entry.source !== imageLibrarySource) { + return false; + } + + if (!query) return true; + + return `${entry.title} ${entry.detail} ${entry.url}`.toLowerCase().includes(query); + }); + }, [imageLibrary, imageLibrarySearch, imageLibrarySource]); + + useEffect(() => { + const controller = new AbortController(); + + const loadPageSeo = async () => { + setIsLoadingPageSeo(true); + setPageSeoFeedback(""); + + try { + const response = await adminFetch(`${API_URL}/admin/page-seo/${selectedPageKey}`, { + credentials: "include", + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error("Kunne ikke hente side-SEO."); + } + + const data = (await response.json()) as SitePageSeoResponse; + setPageMetaTitle(data.meta_title || ""); + setPageMetaDescription(data.meta_description || ""); + setPageSeoUpdatedAt(data.updated_at || null); + } catch (error) { + if (controller.signal.aborted) return; + setPageMetaTitle(""); + setPageMetaDescription(""); + setPageSeoUpdatedAt(null); + setPageSeoFeedback(error instanceof Error ? error.message : "Kunne ikke hente side-SEO."); + } finally { + if (!controller.signal.aborted) { + setIsLoadingPageSeo(false); + } + } + }; + + loadPageSeo(); + + return () => controller.abort(); + }, [selectedPageKey]); + + useEffect(() => { + const controller = new AbortController(); + + const loadSitePage = async () => { + setIsLoadingSitePage(true); + setSitePageFeedback(""); + + try { + const response = await adminFetch(`${API_URL}/admin/site-pages/${selectedSitePageKey}`, { + credentials: "include", + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error("Kunne ikke hente sidemalen."); + } + + const data = (await response.json()) as SitePageResponse; + setSitePageEyebrow(data.eyebrow || ""); + setSitePageTitle(data.title || ""); + setSitePageHeroImageUrl(data.hero_image_url || ""); + setSitePageIntroHtml(data.intro_html || ""); + setSitePageBodyHtml(data.body_html || ""); + setSitePageMetaTitle(data.meta_title || ""); + setSitePageMetaDescription(data.meta_description || ""); + setSitePageUpdatedAt(data.updated_at || null); + } catch (error) { + if (controller.signal.aborted) return; + setSitePageEyebrow(""); + setSitePageTitle(""); + setSitePageHeroImageUrl(""); + setSitePageIntroHtml(""); + setSitePageBodyHtml(""); + setSitePageMetaTitle(""); + setSitePageMetaDescription(""); + setSitePageUpdatedAt(null); + setSitePageFeedback(error instanceof Error ? error.message : "Kunne ikke hente sidemalen."); + } finally { + if (!controller.signal.aborted) { + setIsLoadingSitePage(false); + } + } + }; + + loadSitePage(); + + return () => controller.abort(); + }, [selectedSitePageKey]); + + useEffect(() => { + const controller = new AbortController(); + + const loadImageLibrary = async () => { + setIsLoadingImageLibrary(true); + + try { + const [uploadsResponse, facilitiesResponse, articlesResponse] = await Promise.all([ + adminFetch("/api/admin/uploads/images?folder=all&limit=240", { + credentials: "include", + signal: controller.signal, + }), + adminFetch(`${API_URL}/admin/facilities`, { + credentials: "include", + signal: controller.signal, + }), + adminFetch(`${API_URL}/admin/articles`, { + credentials: "include", + signal: controller.signal, + }), + ]); + + if (!uploadsResponse.ok || !facilitiesResponse.ok || !articlesResponse.ok) { + throw new Error("Kunne ikke hente bildebiblioteket."); + } + + const uploadsPayload = (await uploadsResponse.json()) as { images?: UploadImageEntry[] }; + const facilitiesPayload = (await facilitiesResponse.json()) as FacilityImageSource[]; + const articlesPayload = (await articlesResponse.json()) as ArticleImageSource[]; + const byUrl = new Map(); + + for (const image of Array.isArray(uploadsPayload.images) ? uploadsPayload.images : []) { + const url = normalizeImageUrl(image.url); + if (!url || byUrl.has(url)) continue; + + byUrl.set(url, { + url, + source: "uploads", + title: url.split("/").pop() || url, + detail: image.folder === "facilities" ? "Opplastet anleggsbilde" : "Opplastet artikkelbilde", + modified_at: image.modified_at || null, + }); + } + + for (const facility of Array.isArray(facilitiesPayload) ? facilitiesPayload : []) { + const facilityName = String(facility?.name || "").trim() || "Uten navn"; + const candidates: Array<{ url: string; detail: string }> = []; + const frontImageUrl = normalizeImageUrl(facility?.front_image_url); + const imageUrl = normalizeImageUrl(facility?.image_url); + + if (frontImageUrl) { + candidates.push({ url: frontImageUrl, detail: "Anlegg: toppbilde" }); + } + if (imageUrl) { + candidates.push({ url: imageUrl, detail: "Anlegg: hovedbilde" }); + } + + extractGalleryImageUrls(facility?.gallery).forEach((url, index) => { + candidates.push({ url, detail: `Anlegg: galleri ${index + 1}` }); + }); + + for (const candidate of candidates) { + if (!candidate.url || byUrl.has(candidate.url)) continue; + + byUrl.set(candidate.url, { + url: candidate.url, + source: "facility", + title: facilityName, + detail: candidate.detail, + }); + } + } + + for (const article of Array.isArray(articlesPayload) ? articlesPayload : []) { + const articleTitle = String(article?.title || "").trim() || "Uten tittel"; + const sectionLabel = article?.section === "meninger" ? "Mening" : "Banebesøk"; + const candidates: Array<{ url: string; detail: string }> = []; + + (Array.isArray(article?.hero_images) ? article.hero_images : []).forEach((image, index) => { + const url = normalizeImageUrl(image?.src); + if (!url) return; + candidates.push({ + url, + detail: `${sectionLabel}: hero-bilde${index > 0 ? ` ${index + 1}` : ""}`, + }); + }); + + (Array.isArray(article?.media_gallery) ? article.media_gallery : []).forEach((media, index) => { + if (media?.type !== "image") return; + const url = normalizeImageUrl(media?.src); + if (!url) return; + candidates.push({ + url, + detail: `${sectionLabel}: galleri ${index + 1}`, + }); + }); + + for (const candidate of candidates) { + if (!candidate.url || byUrl.has(candidate.url)) continue; + + byUrl.set(candidate.url, { + url: candidate.url, + source: "article", + title: articleTitle, + detail: candidate.detail, + modified_at: article.updated_at || null, + }); + } + } + + const nextImageLibrary = Array.from(byUrl.values()).sort((left, right) => { + const leftTime = left.modified_at ? Date.parse(left.modified_at) : 0; + const rightTime = right.modified_at ? Date.parse(right.modified_at) : 0; + if (leftTime !== rightTime) return rightTime - leftTime; + return left.title.localeCompare(right.title, "nb"); + }); + + if (!controller.signal.aborted) { + setImageLibrary(nextImageLibrary); + } + } catch { + if (!controller.signal.aborted) { + setImageLibrary([]); + } + } finally { + if (!controller.signal.aborted) { + setIsLoadingImageLibrary(false); + } + } + }; + + loadImageLibrary(); + + return () => controller.abort(); + }, []); + + const handleSavePageSeo = async () => { + setIsSavingPageSeo(true); + setPageSeoFeedback(""); + + try { + const response = await adminFetch(`${API_URL}/admin/page-seo/${selectedPageKey}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + meta_title: pageMetaTitle, + meta_description: pageMetaDescription, + }), + }); + + if (!response.ok) { + throw new Error("Kunne ikke lagre side-SEO."); + } + + const data = (await response.json()) as SitePageSeoResponse; + setPageSeoUpdatedAt(data.updated_at || null); + setPageSeoFeedback("Lagret."); + } catch (error) { + setPageSeoFeedback(error instanceof Error ? error.message : "Kunne ikke lagre side-SEO."); + } finally { + setIsSavingPageSeo(false); + } + }; + + const uploadSitePageImage = async (file: File) => { + const payload = new FormData(); + payload.append("file", file); + + const response = await adminFetch("/api/admin/uploads/images", { + method: "POST", + body: payload, + credentials: "include", + }); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: "Kunne ikke laste opp bildet." })); + throw new Error(error.detail || "Kunne ikke laste opp bildet."); + } + + const result = (await response.json()) as { url?: string }; + if (!result.url) { + throw new Error("Uploaden returnerte ingen bildeadresse."); + } + + return result.url; + }; + + const handleSitePageHeroUpload = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + + setIsUploadingSitePageHero(true); + setSitePageFeedback(""); + + try { + const url = await uploadSitePageImage(file); + setSitePageHeroImageUrl(url); + setSitePageFeedback("Toppbildet ble lastet opp. Husk å lagre sidemalen."); + } catch (error) { + setSitePageFeedback(error instanceof Error ? error.message : "Kunne ikke laste opp bildet."); + } finally { + setIsUploadingSitePageHero(false); + } + }; + + const handleSaveSitePage = async () => { + setIsSavingSitePage(true); + setSitePageFeedback(""); + + try { + const response = await adminFetch(`${API_URL}/admin/site-pages/${selectedSitePageKey}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + eyebrow: sitePageEyebrow, + title: sitePageTitle, + hero_image_url: sitePageHeroImageUrl, + intro_html: sitePageIntroHtml, + body_html: sitePageBodyHtml, + meta_title: sitePageMetaTitle, + meta_description: sitePageMetaDescription, + }), + }); + + if (!response.ok) { + throw new Error("Kunne ikke lagre sidemalen."); + } + + const data = (await response.json()) as SitePageResponse; + setSitePageUpdatedAt(data.updated_at || null); + setSitePageFeedback("Lagret."); + } catch (error) { + setSitePageFeedback(error instanceof Error ? error.message : "Kunne ikke lagre sidemalen."); + } finally { + setIsSavingSitePage(false); + } + }; + + return ( +
+ + +
+ + ← Tilbake til oversikten + +
+
+

Sider

+

Samlesider og faste sidemaler

+

+ Her redigerer du SEO for samlesidene og innholdet på de faste sidene som /turneringer, /klubbnummer, /om, /kontakt og /personvern-og-cookies. +

+
+
+ + Gå til sted-sider + +
+
+
+ +
+
+
+

Samlesider

+

SEO for 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} +
+
+ +
+ {isLoadingPageSeo ? ( +
+ Laster side-SEO... +
+ ) : ( + + )} +
+
+
+ +
+
+
+

Sidemaler

+

Faste sider med hero og HTML-innhold

+

+ Disse sidene bruker samme mal med toppbilde, flytende overskrift og redigerbart HTML-innhold. Her vedlikeholder du blant annet /turneringer. +

+
+
+ + Åpne siden + + +
+
+ +
+
+ + +
+

Aktiv sidemal

+

{selectedSiteTemplate?.label || selectedSitePageKey}

+

+ Offentlig URL: {selectedSiteTemplate?.path || "/"} +

+
+
+

Gjeldende meta title

+

+ {effectiveSitePageMetaTitle || "Ikke tilgjengelig"} +

+
+
+

Gjeldende meta description

+

+ {effectiveSitePageMetaDescription || "Ikke tilgjengelig"} +

+
+
+ + {sitePageHeroImageUrl ? ( +
+ {sitePageTitle +
+ ) : null} + +

+ Sist lagret: {formatDateTime(sitePageUpdatedAt)} +

+ {sitePageFeedback ? ( +

{sitePageFeedback}

+ ) : null} +
+
+ +
+ {isLoadingSitePage ? ( +
+ Laster sidemal... +
+ ) : ( +
+ + +
+ + +
+ +
+
+
+

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. +

+
+
+ + setImageLibrarySearch(event.target.value)} + placeholder="Søk på navn, kilde eller filnavn" + className="min-w-[14rem] rounded-[1rem] border border-[#112015]/10 bg-white px-4 py-3 text-sm text-[#112015] outline-none focus:border-[#8BC34A]" + /> +
+
+ + {isLoadingImageLibrary ? ( +
+ Laster bilder... +
+ ) : visibleImageLibrary.length === 0 ? ( +
+ Fant ingen bilder som matcher dette filteret. +
+ ) : ( +
+ {visibleImageLibrary.map((image) => { + const isSelected = sitePageHeroImageUrl === image.url; + return ( + + ); + })} +
+ )} +
+
+ + + +
+

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. +

+
+ +
+
+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/admin/steder/page.tsx b/frontend/src/app/admin/steder/page.tsx index eafe469..bf6be65 100644 --- a/frontend/src/app/admin/steder/page.tsx +++ b/frontend/src/app/admin/steder/page.tsx @@ -28,59 +28,7 @@ type PlacePageResponse = { updated_at?: string | null; }; -type SitePageSeoResponse = { - page_key: string; - meta_title?: string | null; - meta_description?: string | null; - updated_at?: string | null; -}; - const DEFAULT_PLACE_SLUG = "norge"; -const DEFAULT_PAGE_KEY = "golfbaner"; -const SITE_PAGE_OPTIONS = [ - { key: "golfbaner", label: "/golfbaner" }, - { key: "vtg", label: "/vtg" }, - { key: "medlemskap", label: "/medlemskap" }, - { key: "banebesok", label: "/banebesok" }, - { key: "meninger", label: "/meninger" }, - { key: "simulatorer", label: "/simulatorer (fremtidig)" }, -]; - -const SITE_PAGE_SEO_SUGGESTIONS: Record< - string, - { title: string; description: string } -> = { - golfbaner: { - title: "Alle norske golfbaner: Finn din neste runde på gress | TeeOff.no", - description: - "Planlegg din neste golfrunde. Se komplett oversikt over alle norske golfbaner med oppdatert banestatus, greenfee-priser og kart på TeeOff.no.", - }, - vtg: { - title: "Veien til Golf: Finn golfkurs for nybegynnere (VTG) | TeeOff.no", - description: - "Vil du begynne med golf? Finn komplett oversikt over Veien til Golf-kurs (VTG) i hele Norge. Se kursdatoer, priser og finn din nærmeste klubb på TeeOff.no!", - }, - medlemskap: { - title: "Billig golfmedlemskap? Finn og sammenlign priser på alle klubber | TeeOff.no", - description: - "Hvor er det billigst å være medlem? Sammenlign priser på golfmedlemskap med full spillerett eller rimelige nasjonale alternativ (fjernmedlemskap) i Norge på TeeOff.no.", - }, - banebesok: { - title: "Golfreiser og banebesøk: Erfaringer fra norske golfbaner | TeeOff.no", - description: - "Bli med på tur! Vi besøker Norges vakreste golfbaner og deler ærlige reiseskildringer, spektakulære bilder og nyttige tips. Finn inspirasjon til din neste golfreise her.", - }, - meninger: { - title: "Golfblogg: Meninger, humor og skråblikk på golf-Norge | TeeOff.no", - description: - "Fra frustrasjon over saktespill til gleden over en perfekt drive. Les TeeOffs egne artikler, kommentarer og ærlige skråblikk på livet som golfer i Norge.", - }, - simulatorer: { - title: "Golfsimulatorer i Norge", - description: - "Finn golfsimulatorer, indoor golf og simulatorsteder i Norge når TeeOffs simulatoroversikt lanseres.", - }, -}; const formatDateTime = (value: string | null | undefined) => { if (!value) return "Ikke lagret ennå"; @@ -105,13 +53,6 @@ export default function AdminPlacePagesPage() { const [isSaving, setIsSaving] = useState(false); const [feedback, setFeedback] = useState(""); const [placeFacilities, setPlaceFacilities] = useState([]); - const [selectedPageKey, setSelectedPageKey] = useState(DEFAULT_PAGE_KEY); - const [pageMetaTitle, setPageMetaTitle] = useState(""); - const [pageMetaDescription, setPageMetaDescription] = useState(""); - const [pageSeoUpdatedAt, setPageSeoUpdatedAt] = useState(null); - const [isLoadingPageSeo, setIsLoadingPageSeo] = useState(true); - const [isSavingPageSeo, setIsSavingPageSeo] = useState(false); - const [pageSeoFeedback, setPageSeoFeedback] = useState(""); const selectedPlace = getPlaceConfigFromSlug(selectedSlug); const selectedPlacePreposition = selectedPlace ? getPlacePreposition(selectedPlace.label) : "i"; @@ -134,10 +75,6 @@ export default function AdminPlacePagesPage() { : ""; const effectivePlaceMetaTitle = metaTitle.trim() || placeSuggestedTitle; const effectivePlaceMetaDescription = metaDescription.trim() || placeSuggestedDescription; - const sitePageSuggestion = SITE_PAGE_SEO_SUGGESTIONS[selectedPageKey] || { - title: "", - description: "", - }; useEffect(() => { const controller = new AbortController(); @@ -210,45 +147,6 @@ export default function AdminPlacePagesPage() { return () => controller.abort(); }, [selectedSlug]); - useEffect(() => { - const controller = new AbortController(); - - const loadPageSeo = async () => { - setIsLoadingPageSeo(true); - setPageSeoFeedback(""); - - try { - const response = await adminFetch(`${API_URL}/admin/page-seo/${selectedPageKey}`, { - credentials: "include", - signal: controller.signal, - }); - - if (!response.ok) { - throw new Error("Kunne ikke hente side-SEO."); - } - - const data = (await response.json()) as SitePageSeoResponse; - setPageMetaTitle(data.meta_title || ""); - setPageMetaDescription(data.meta_description || ""); - setPageSeoUpdatedAt(data.updated_at || null); - } catch (error) { - if (controller.signal.aborted) return; - setPageMetaTitle(""); - setPageMetaDescription(""); - setPageSeoUpdatedAt(null); - setPageSeoFeedback(error instanceof Error ? error.message : "Kunne ikke hente side-SEO."); - } finally { - if (!controller.signal.aborted) { - setIsLoadingPageSeo(false); - } - } - }; - - loadPageSeo(); - - return () => controller.abort(); - }, [selectedPageKey]); - const handleSave = async () => { setIsSaving(true); setFeedback(""); @@ -279,35 +177,6 @@ export default function AdminPlacePagesPage() { } }; - const handleSavePageSeo = async () => { - setIsSavingPageSeo(true); - setPageSeoFeedback(""); - - try { - const response = await adminFetch(`${API_URL}/admin/page-seo/${selectedPageKey}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ - meta_title: pageMetaTitle, - meta_description: pageMetaDescription, - }), - }); - - if (!response.ok) { - throw new Error("Kunne ikke lagre side-SEO."); - } - - const data = (await response.json()) as SitePageSeoResponse; - setPageSeoUpdatedAt(data.updated_at || null); - setPageSeoFeedback("Lagret."); - } catch (error) { - setPageSeoFeedback(error instanceof Error ? error.message : "Kunne ikke lagre side-SEO."); - } finally { - setIsSavingPageSeo(false); - } - }; - return (
@@ -321,10 +190,13 @@ export default function AdminPlacePagesPage() {

Sted-sider

Rediger innhold over faktaboksen

- Dette innholdet vises over nøkkeltall-/faktaboksen på `/sted/[slug]`. + Dette innholdet vises over nøkkeltall-/faktaboksen på /sted/[slug].

+ + Sider + Åpne sted-siden @@ -418,76 +290,6 @@ export default function AdminPlacePagesPage() {
- -
-
-
-

Samlesider

-

SEO for 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} -
-
- -
- {isLoadingPageSeo ? ( -
- Laster side-SEO... -
- ) : ( - - )} -
-
-
); } diff --git a/frontend/src/app/api/admin/uploads/images/route.ts b/frontend/src/app/api/admin/uploads/images/route.ts index 7475e46..f6fa276 100644 --- a/frontend/src/app/api/admin/uploads/images/route.ts +++ b/frontend/src/app/api/admin/uploads/images/route.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { randomUUID } from "node:crypto"; import sharp from "sharp"; @@ -17,11 +17,27 @@ const ALLOWED_MIME_TYPES = new Set([ export const runtime = "nodejs"; +type UploadFolder = "articles" | "facilities"; + +type UploadImageEntry = { + url: string; + folder: UploadFolder; + modified_at: string | null; +}; + function resolveUploadFolder(value: FormDataEntryValue | null) { const normalized = String(value || "articles").trim().toLowerCase(); return normalized === "facilities" ? "facilities" : "articles"; } +function resolveQueryUploadFolder(value: string | null) { + const normalized = String(value || "all").trim().toLowerCase(); + if (normalized === "articles" || normalized === "facilities") { + return normalized as UploadFolder; + } + return "all"; +} + function sanitizeFilenameStem(filename: string) { const stem = path.parse(filename).name; const normalized = stem @@ -34,6 +50,80 @@ function sanitizeFilenameStem(filename: string) { return normalized || "image"; } +async function collectUploadsFromFolder(folder: UploadFolder): Promise { + const directory = path.join(process.cwd(), "public", "uploads", folder); + + const walk = async (currentDirectory: string): Promise => { + let entries; + try { + entries = await readdir(currentDirectory, { withFileTypes: true }); + } catch { + return []; + } + + const collected = await Promise.all( + entries.map(async (entry) => { + const absolutePath = path.join(currentDirectory, entry.name); + if (entry.isDirectory()) { + return await walk(absolutePath); + } + if (!entry.isFile()) { + return []; + } + + const extension = path.extname(entry.name).toLowerCase(); + if (![".avif", ".jpg", ".jpeg", ".png", ".webp", ".gif", ".tiff"].includes(extension)) { + return []; + } + + const relativePath = path.relative(path.join(process.cwd(), "public"), absolutePath); + const stats = await stat(absolutePath).catch(() => null); + return [ + { + url: `/${relativePath.replaceAll(path.sep, "/")}`, + folder, + modified_at: stats?.mtime ? stats.mtime.toISOString() : null, + }, + ]; + }), + ); + + return collected.flat(); + }; + + return await walk(directory); +} + +export async function GET(request: Request) { + const cookieStore = await cookies(); + if (!cookieStore.get("admin_session")) { + return NextResponse.json({ detail: "Admin-innlogging kreves" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const folder = resolveQueryUploadFolder(searchParams.get("folder")); + const limitRaw = Number(searchParams.get("limit") || "120"); + const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(300, Math.trunc(limitRaw))) : 120; + + const folders: UploadFolder[] = + folder === "all" ? ["articles", "facilities"] : [folder]; + + const images = ( + await Promise.all(folders.map((currentFolder) => collectUploadsFromFolder(currentFolder))) + ) + .flat() + .sort((left, right) => { + const leftTime = left.modified_at ? Date.parse(left.modified_at) : 0; + const rightTime = right.modified_at ? Date.parse(right.modified_at) : 0; + return rightTime - leftTime; + }) + .slice(0, limit); + + return NextResponse.json({ + images, + }); +} + export async function POST(request: Request) { const cookieStore = await cookies(); if (!cookieStore.get("admin_session")) { diff --git a/frontend/src/app/klubbnummer/page.tsx b/frontend/src/app/klubbnummer/page.tsx index 8a1c15b..17c35ce 100644 --- a/frontend/src/app/klubbnummer/page.tsx +++ b/frontend/src/app/klubbnummer/page.tsx @@ -1,32 +1,47 @@ import type { FacilityRecord } from "@/app/facilityData"; import { fetchPublicFacilities } from "@/app/publicFacilities"; -import InfoPageShell from "@/components/InfoPageShell"; import ClubNumbersTable from "@/components/ClubNumbersTable"; +import SitePageTemplate from "@/components/SitePageTemplate"; +import { fetchSitePage } from "@/app/sitePages"; import { createBreadcrumbJsonLd, createCollectionPageJsonLd, createPageMetadata, } from "@/app/seo"; -const pageTitle = "NGF Klubbnummer: Oversikt for Golfbox og Gimmie | TeeOff.no"; -const pageDescription = +const pageKey = "klubbnummer"; +const fallbackVisibleTitle = "Klubbnummer i Golfbox"; +const fallbackIntro = + "

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 fetchPublicFacilities("clubnumbers", revalidate); + 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; const collectionJsonLd = createCollectionPageJsonLd({ - name: pageTitle, - description: pageDescription, + name: metaTitle, + description: metaDescription, path: "/klubbnummer", }); const breadcrumbJsonLd = createBreadcrumbJsonLd([ @@ -44,13 +59,15 @@ export default async function ClubNumbersPage() { type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} /> - - + ); } diff --git a/frontend/src/app/kontakt/page.tsx b/frontend/src/app/kontakt/page.tsx index c5f1166..3256d09 100644 --- a/frontend/src/app/kontakt/page.tsx +++ b/frontend/src/app/kontakt/page.tsx @@ -1,13 +1,18 @@ import ContactForm from "@/components/ContactForm"; -import InfoPageShell from "@/components/InfoPageShell"; +import SitePageTemplate from "@/components/SitePageTemplate"; +import { fetchSitePage } from "@/app/sitePages"; import { createBreadcrumbJsonLd, createCollectionPageJsonLd, createPageMetadata, } from "@/app/seo"; -const pageTitle = "Kontakt TeeOff"; -const pageDescription = +const pageKey = "kontakt"; +const fallbackVisibleTitle = "Kontakt TeeOff"; +const fallbackIntro = + "

Bruk 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) }} /> -
@@ -111,7 +130,7 @@ export default async function ContactPage({
- + ); } diff --git a/frontend/src/app/om/page.tsx b/frontend/src/app/om/page.tsx index 0d85024..ce83f9d 100644 --- a/frontend/src/app/om/page.tsx +++ b/frontend/src/app/om/page.tsx @@ -1,107 +1,41 @@ -import Link from "next/link"; -import InfoPageShell from "@/components/InfoPageShell"; +import SitePageTemplate from "@/components/SitePageTemplate"; +import { fetchSitePage } from "@/app/sitePages"; import { createBreadcrumbJsonLd, createCollectionPageJsonLd, createPageMetadata, } from "@/app/seo"; -const pageTitle = "FAQ / Om TeeOff"; -const pageDescription = +const pageKey = "om"; +const fallbackVisibleTitle = "Hva TeeOff er, og hvorfor siden finnes"; +const fallbackIntro = + "

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) }} /> - -
- {sections.map((section) => ( -
-

- FAQ -

-

{section.title}

- - {section.body ? ( -
- {section.body.map((paragraph) => ( -

{paragraph}

- ))} -
- ) : null} - - {section.items ? ( -
    - {section.items.map((item) => ( -
  • {item}
  • - ))} -
- ) : null} - - {section.cta ? ( -
- - {section.cta.label} - -
- ) : null} - - {section.links ? ( -
- {section.links.map((link) => ( - - {link.label} - - ))} -
- ) : null} -
- ))} -
-
+ ); } diff --git a/frontend/src/app/personvern-og-cookies/page.tsx b/frontend/src/app/personvern-og-cookies/page.tsx index d68a1ce..c2ebac7 100644 --- a/frontend/src/app/personvern-og-cookies/page.tsx +++ b/frontend/src/app/personvern-og-cookies/page.tsx @@ -1,24 +1,42 @@ -import InfoPageShell from "@/components/InfoPageShell"; +import SitePageTemplate from "@/components/SitePageTemplate"; +import { fetchSitePage } from "@/app/sitePages"; import { createBreadcrumbJsonLd, createCollectionPageJsonLd, createPageMetadata, } from "@/app/seo"; -const pageTitle = "Personvern og cookies"; -const pageDescription = +const pageKey = "personvern-og-cookies"; +const fallbackVisibleTitle = "Personvern og cookies"; +const fallbackIntro = + "

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) }} /> - - -
-
-

Hva vi lagrer

-
-

- 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. -

-
-
- -
-

Cookies

-
-

TeeOff bruker cookies til noen få, konkrete formål:

-
    -
  • innlogging og sesjonshåndtering for administratorer
  • -
  • innlogging for offentlige brukerfunksjoner som kommentarer
  • -
  • måling av trafikk og bruksmønstre via Matomo
  • -
-

- Nødvendige cookies brukes for at nettsiden skal fungere. Analysecookies brukes for å - forstå hvordan nettsiden brukes og forbedre innhold og funksjonalitet. -

-
-
- -
-

Analyse med Matomo

-
-

- 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. -

-
-
- -
-

Kontakt om personvern

-
-

- 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. -

-
-
-
-
+ ); } diff --git a/frontend/src/app/sitePages.ts b/frontend/src/app/sitePages.ts new file mode 100644 index 0000000..a3360c0 --- /dev/null +++ b/frontend/src/app/sitePages.ts @@ -0,0 +1,25 @@ +import { cache } from "react"; +import { API_URL } from "@/config/constants"; + +export type SitePageRecord = { + page_key: string; + eyebrow?: string | null; + title?: string | null; + hero_image_url?: string | null; + intro_html?: string | null; + body_html?: string | null; + meta_title?: string | null; + meta_description?: string | null; + created_at?: string | null; + updated_at?: string | null; +}; + +export const fetchSitePage = cache(async (pageKey: string): Promise => { + try { + const response = await fetch(`${API_URL}/site-pages/${pageKey}`, { cache: "no-store" }); + if (!response.ok) return null; + return (await response.json()) as SitePageRecord; + } catch { + return null; + } +}); diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts index ea9a74f..0bb6ff9 100755 --- a/frontend/src/app/sitemap.ts +++ b/frontend/src/app/sitemap.ts @@ -20,12 +20,17 @@ type PlacePageRecord = { updated_at?: string | null; }; +type SitePageRecord = { + updated_at?: string | null; +}; + type StaticRouteConfig = { path: string; changeFrequency: "daily" | "weekly" | "monthly"; priority: number; sourceFiles: string[]; sitePageKey?: string; + siteTemplateKey?: string; }; export const revalidate = 3600; @@ -78,30 +83,35 @@ const staticRouteConfigs: StaticRouteConfig[] = [ changeFrequency: "daily", priority: 0.68, sourceFiles: ["src/app/turneringer/page.tsx"], + siteTemplateKey: "turneringer", }, { path: "/klubbnummer", changeFrequency: "weekly", priority: 0.64, sourceFiles: ["src/app/klubbnummer/page.tsx"], + siteTemplateKey: "klubbnummer", }, { path: "/om", changeFrequency: "monthly", priority: 0.45, sourceFiles: ["src/app/om/page.tsx"], + siteTemplateKey: "om", }, { path: "/kontakt", changeFrequency: "monthly", priority: 0.42, sourceFiles: ["src/app/kontakt/page.tsx"], + siteTemplateKey: "kontakt", }, { path: "/personvern-og-cookies", changeFrequency: "monthly", priority: 0.38, sourceFiles: ["src/app/personvern-og-cookies/page.tsx"], + siteTemplateKey: "personvern-og-cookies", }, ]; @@ -164,6 +174,19 @@ async function fetchPlacePageUpdatedAt(slug: string) { } } +async function fetchSiteTemplateUpdatedAt(pageKey: string) { + try { + const response = await fetch(`${API_URL}/site-pages/${pageKey}`, { + next: { revalidate }, + }); + if (!response.ok) return null; + const data = (await response.json()) as SitePageRecord; + return parseDate(data.updated_at); + } catch { + return null; + } +} + export default async function sitemap(): Promise { let facilities: SitemapFacility[] = []; @@ -184,10 +207,17 @@ export default async function sitemap(): Promise { const sitePageUpdatedAt = route.sitePageKey ? await fetchSitePageSeoUpdatedAt(route.sitePageKey) : null; + const siteTemplateUpdatedAt = route.siteTemplateKey + ? await fetchSiteTemplateUpdatedAt(route.siteTemplateKey) + : null; return { url: buildAbsoluteUrl(route.path), lastModified: - maxDate([sitePageUpdatedAt, getSourceLastModified(route.sourceFiles)]) || new Date(), + maxDate([ + sitePageUpdatedAt, + siteTemplateUpdatedAt, + getSourceLastModified(route.sourceFiles), + ]) || new Date(), changeFrequency: route.changeFrequency, priority: route.priority, }; diff --git a/frontend/src/app/turneringer/page.tsx b/frontend/src/app/turneringer/page.tsx index 3bfb4ff..23a6a7e 100644 --- a/frontend/src/app/turneringer/page.tsx +++ b/frontend/src/app/turneringer/page.tsx @@ -1,7 +1,5 @@ -import { notFound } from "next/navigation"; -import CourseVisitGallery from "@/components/CourseVisitGallery"; -import InfoPageShell from "@/components/InfoPageShell"; -import { getOpinionArticleBySlug, type CourseVisitBodyBlock } from "@/content/courseVisits"; +import SitePageTemplate from "@/components/SitePageTemplate"; +import { fetchSitePage } from "@/app/sitePages"; import { createBreadcrumbJsonLd, createCollectionPageJsonLd, @@ -10,52 +8,38 @@ import { export const dynamic = "force-dynamic"; -const articleSlug = "note-to-self-lenker-til-viktige-turneringer-i-golfbox"; -const pageTitle = "Golfturneringer i Norge: Oversikt og terminlister | TeeOff.no"; -const pageDescription = - "Vanskelig å finne frem i Golfbox? Vi samler terminlister for Olyo Tour og regionale golfturneringer i hele Norge på ett sted. Finn din neste turnering her!"; -const pageIntro = "Her er alle turneringene vi ikke vet hvordan vi skal finne i Golfbox (og andre steder). God golfsesong!"; -function renderBlock(block: CourseVisitBodyBlock, index: number) { - if (block.type !== "richText") { - return null; - } - - return ( -
- {block.title ? ( -

{block.title}

- ) : null} -
-
- ); -} +const pageKey = "turneringer"; +const fallbackTitle = "Lenker til viktige turneringer i Golfbox"; +const fallbackMetaTitle = "Golfturneringer i Norge: Oversikt og terminlister | TeeOff.no"; +const fallbackMetaDescription = + "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!"; +const fallbackIntro = + "

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) }} /> - -
- - {article.blocks.map((block, index) => renderBlock(block, index))} - {article.mediaGallery.length > 1 ? ( -
-
-
-

- Galleri -

-

- Bilder og video -

-
-

- {article.mediaGallery.length} elementer -

-
- -
- ) : null} -
-
+ ); } diff --git a/frontend/src/components/AdminMobileMenu.tsx b/frontend/src/components/AdminMobileMenu.tsx index 44e2ae1..5ceccf2 100755 --- a/frontend/src/components/AdminMobileMenu.tsx +++ b/frontend/src/components/AdminMobileMenu.tsx @@ -16,6 +16,7 @@ const NAV_ITEMS = [ { href: '/admin/golfpakker', label: 'Golfpakker', match: (pathname: string) => pathname.startsWith('/admin/golfpakker') }, { href: '/admin/simulatorer', label: 'Simulatorer', match: (pathname: string) => pathname.startsWith('/admin/simulatorer') }, { href: '/admin/vtg', label: 'VTG', match: (pathname: string) => pathname.startsWith('/admin/vtg') }, + { href: '/admin/sider', label: 'Sider', match: (pathname: string) => pathname.startsWith('/admin/sider') }, { href: '/admin/steder', label: 'Steder', match: (pathname: string) => pathname.startsWith('/admin/steder') }, ]; diff --git a/frontend/src/components/SitePageTemplate.tsx b/frontend/src/components/SitePageTemplate.tsx new file mode 100644 index 0000000..1e46f15 --- /dev/null +++ b/frontend/src/components/SitePageTemplate.tsx @@ -0,0 +1,79 @@ +import type { ReactNode } from "react"; +import { FALLBACK_IMAGE } from "@/config/constants"; + +type SitePageTemplateProps = { + eyebrow: string; + title: string; + introHtml?: string | null; + heroImageUrl?: string | null; + bodyHtml?: string | null; + children?: ReactNode; +}; + +export default function SitePageTemplate({ + eyebrow: _eyebrow, + title, + introHtml, + heroImageUrl, + bodyHtml, + children, +}: SitePageTemplateProps) { + const normalizedTitle = String(title || "").trim(); + const normalizedIntro = String(introHtml || "").trim(); + const normalizedBody = String(bodyHtml || "").trim(); + const heroSrc = String(heroImageUrl || "").trim() || FALLBACK_IMAGE; + + return ( +
+
+
+ {normalizedTitle +
+
+ +
+
+
+

+ {normalizedTitle} +

+
+
+
+
+
+ +
+
+ {normalizedIntro ? ( +
+
+
+ ) : null} +
+
+ {normalizedBody ? ( +
+ ) : null} + {children ? ( +
+ {children} +
+ ) : null} +
+
+
+
+
+ ); +}