diff --git a/backend/.env.example b/backend/.env.example index 6ef4061..83964e2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,7 +2,9 @@ SMTP_SERVER=send.one.com SMTP_PORT=465 SMTP_USER=teeoff@example.com SMTP_PASS=replace-with-your-smtp-password +COMMENT_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 JWT_SECRET=replace-with-a-long-random-secret +INDEXNOW_KEY=replace-with-your-indexnow-key diff --git a/backend/main.py b/backend/main.py index 158c01e..f0ea418 100644 --- a/backend/main.py +++ b/backend/main.py @@ -69,6 +69,13 @@ SMTP_USER = os.getenv("SMTP_USER", "").strip() SMTP_PASS = os.getenv("SMTP_PASS", "").strip() PUBLIC_FROM_EMAIL = os.getenv("PUBLIC_FROM_EMAIL", SMTP_USER).strip() CONTACT_FORM_TO_EMAIL = os.getenv("CONTACT_FORM_TO_EMAIL", "teeoff@teeoff.no").strip() +COMMENT_NOTIFICATION_TO_EMAIL = os.getenv( + "COMMENT_NOTIFICATION_TO_EMAIL", + CONTACT_FORM_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() pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") @@ -140,8 +147,16 @@ def get_public_auth_config() -> dict[str, Any]: } +def get_configured_public_base_url() -> str: + for env_name in ("PUBLIC_BASE_URL", "NEXT_PUBLIC_SITE_URL"): + configured = os.getenv(env_name, "").strip().rstrip("/") + if configured: + return configured + return "" + + def build_public_base_url(request: Request) -> str: - configured = os.getenv("PUBLIC_BASE_URL", "").strip().rstrip("/") + configured = get_configured_public_base_url() if configured: return configured @@ -157,6 +172,168 @@ def build_google_redirect_uri(request: Request) -> str: return f"{build_public_base_url(request)}/api/public/auth/google/callback" +def build_absolute_public_url(path: str) -> str | None: + base_url = get_configured_public_base_url() + if not base_url: + return None + if not path: + return base_url + return f"{base_url}{path if path.startswith('/') else f'/{path}'}" + + +def dedupe_strings(values: list[str]) -> list[str]: + deduped: list[str] = [] + seen: set[str] = set() + for value in values: + normalized = str(value or "").strip() + if not normalized or normalized in seen: + continue + deduped.append(normalized) + seen.add(normalized) + return deduped + + +def build_article_public_url(section: str | None, slug: str | None) -> str | None: + normalized_section = str(section or "").strip().lower() + normalized_slug = str(slug or "").strip() + if normalized_section not in {"banebesok", "meninger"} or not normalized_slug: + return None + return build_absolute_public_url(f"/{normalized_section}/{normalized_slug}") + + +def build_facility_public_url(slug: str | None) -> str | None: + normalized_slug = str(slug or "").strip() + if not normalized_slug: + return None + return build_absolute_public_url(f"/golfbaner/{normalized_slug}") + + +def collect_article_indexnow_urls( + previous_article: dict[str, Any] | None = None, + current_article: dict[str, Any] | None = None, +) -> list[str]: + urls: list[str] = [] + + def add_section_url(section: str | None) -> None: + normalized_section = str(section or "").strip().lower() + if normalized_section not in {"banebesok", "meninger"}: + return + section_url = build_absolute_public_url(f"/{normalized_section}") + if section_url: + urls.append(section_url) + + def add_article_url(article: dict[str, Any] | None, include_when_status: str) -> None: + if not article: + return + if str(article.get("status") or "").strip().lower() != include_when_status: + return + article_url = build_article_public_url(article.get("section"), article.get("slug")) + if article_url: + urls.append(article_url) + add_section_url(article.get("section")) + + add_article_url(previous_article, "published") + add_article_url(current_article, "published") + return dedupe_strings(urls) + + +def collect_facility_indexnow_urls(slugs: list[str], extra_paths: list[str] | None = None) -> list[str]: + urls: list[str] = [] + for slug in slugs: + facility_url = build_facility_public_url(slug) + if facility_url: + urls.append(facility_url) + + for path in extra_paths or []: + listing_url = build_absolute_public_url(path) + if listing_url: + urls.append(listing_url) + + return dedupe_strings(urls) + + +def get_indexnow_key_location() -> str | None: + configured = INDEXNOW_KEY_LOCATION.strip() + if configured: + return configured + if not INDEXNOW_KEY: + return None + return build_absolute_public_url(f"/{INDEXNOW_KEY}.txt") + + +def normalize_indexnow_urls(urls: list[str]) -> list[str]: + base_url = get_configured_public_base_url() + if not base_url: + return [] + + site = urlsplit(base_url) + allowed_host = site.netloc.strip().lower() + if not allowed_host: + return [] + + normalized_urls: list[str] = [] + seen: set[str] = set() + + for value in urls: + candidate = str(value or "").strip() + if not candidate: + continue + parsed = urlsplit(candidate) + if parsed.scheme not in {"http", "https"}: + continue + if parsed.netloc.strip().lower() != allowed_host: + continue + normalized = urlunsplit((parsed.scheme, parsed.netloc, parsed.path or "/", parsed.query, "")) + if normalized in seen: + continue + seen.add(normalized) + normalized_urls.append(normalized) + + return normalized_urls + + +async def submit_indexnow_urls(urls: list[str], reason: str) -> None: + if not INDEXNOW_KEY: + return + + normalized_urls = normalize_indexnow_urls(urls) + base_url = get_configured_public_base_url() + key_location = get_indexnow_key_location() + host = urlsplit(base_url).netloc.strip() if base_url else "" + if not normalized_urls or not host or not key_location: + return + + payload = { + "host": host, + "key": INDEXNOW_KEY, + "keyLocation": key_location, + "urlList": normalized_urls, + } + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post(INDEXNOW_ENDPOINT, json=payload) + except Exception as exc: + print(f"IndexNow-feil ({reason}): {exc}") + return + + if response.status_code >= 400: + body_preview = response.text.strip().replace("\n", " ") + print(f"IndexNow avvist ({reason}): HTTP {response.status_code} {body_preview[:300]}") + return + + print(f"IndexNow sendt ({reason}): {len(normalized_urls)} URL-er, HTTP {response.status_code}") + + +def schedule_indexnow_submission(urls: list[str], reason: str) -> None: + if not INDEXNOW_KEY: + return + normalized_urls = normalize_indexnow_urls(urls) + if not normalized_urls: + return + asyncio.create_task(submit_indexnow_urls(normalized_urls, reason)) + + def should_use_secure_cookies(request: Request) -> bool: configured = os.getenv("PUBLIC_BASE_URL", "").strip().lower() if configured.startswith("https://"): @@ -298,6 +475,50 @@ async def send_contact_form_email( await asyncio.to_thread(_send) +async def send_comment_notification_email( + *, + article_title: str, + article_url: str, + article_section: str, + comment_body: str, + comment_status: str, + commenter_name: str, + commenter_email: str | None, + parent_author_name: str | None = None, +) -> None: + if not (is_magic_link_configured() and COMMENT_NOTIFICATION_TO_EMAIL): + return + + subject = f"[TeeOff Kommentar] {article_title}" + body = ( + "Ny kommentar på TeeOff.no\n\n" + f"Seksjon: {article_section}\n" + f"Artikkel: {article_title}\n" + f"Lenke: {article_url}\n" + f"Status: {comment_status}\n" + f"Forfatter: {commenter_name}\n" + f"E-post: {commenter_email or 'ikke tilgjengelig'}\n" + f"Svar på kommentar fra: {parent_author_name or 'ingen, dette er toppnivå'}\n\n" + "Kommentar:\n" + f"{comment_body.strip()}\n" + ) + + def _send() -> None: + mail = EmailMessage() + mail["From"] = PUBLIC_FROM_EMAIL + mail["To"] = COMMENT_NOTIFICATION_TO_EMAIL + if commenter_email: + mail["Reply-To"] = commenter_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]) @@ -391,6 +612,7 @@ class ArticleUpsertRequest(BaseModel): class PublicCommentCreateRequest(BaseModel): body: str + parent_id: Optional[int] = None class PublicMagicLinkRequest(BaseModel): @@ -1056,6 +1278,22 @@ async def find_published_article_by_slug(conn, slug: str, section: str | None = ) +async def fetch_facility_slugs(conn, facility_ids: list[int]) -> list[str]: + unique_ids = sorted({int(facility_id) for facility_id in facility_ids if facility_id}) + if not unique_ids: + return [] + + rows = await conn.fetch( + "SELECT slug FROM facilities WHERE id = ANY($1::int[])", + unique_ids, + ) + return [ + str(row["slug"]).strip() + for row in rows + if row.get("slug") and str(row["slug"]).strip() + ] + + ARTICLE_IMAGE_PATTERN = re.compile(r"]*\bsrc=['\"]([^'\"]+)['\"]", re.IGNORECASE) @@ -1950,33 +2188,81 @@ async def create_article_comment( viewer = await require_authenticated_public_user(request) normalized_section = normalize_article_section(section) if section else None body = str(payload.body or "").strip() + parent_id = payload.parent_id if len(body) < 3: raise HTTPException(status_code=400, detail="Kommentaren må være minst 3 tegn.") if len(body) > 4000: raise HTTPException(status_code=400, detail="Kommentaren er for lang.") + if parent_id is not None and parent_id <= 0: + raise HTTPException(status_code=400, detail="Ugyldig kommentar å svare på.") + + section_label = "Meninger" + parent_author_name: str | None = None async with app.state.pool.acquire() as conn: article = await find_published_article_by_slug(conn, slug, normalized_section) if not article: raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet") + section_label = "Banebesøk" if article["section"] == "banebesok" else "Meninger" + + if parent_id is not None: + parent_row = await conn.fetchrow( + """ + SELECT + c.*, + u.display_name, + u.full_name + FROM article_comments c + JOIN public_users u ON u.id = c.user_id + WHERE c.id = $1 + AND c.article_id = $2 + AND c.status != 'deleted' + LIMIT 1 + """, + parent_id, + article["id"], + ) + if not parent_row: + raise HTTPException(status_code=404, detail="Fant ikke kommentaren du prøver å svare på.") + parent_author_name = ( + parent_row.get("display_name") + or parent_row.get("full_name") + or "TeeOff-leser" + ) row = await conn.fetchrow( """ INSERT INTO article_comments ( - article_id, user_id, body, status, ip_hash, user_agent + article_id, user_id, parent_id, body, status, ip_hash, user_agent ) VALUES ( - $1, $2, $3, $4, $5, $6 + $1, $2, $3, $4, $5, $6, $7 ) RETURNING * """, article["id"], viewer["id"], + parent_id, body, PUBLIC_COMMENT_DEFAULT_STATUS, hash_request_ip(request), request.headers.get("user-agent"), ) + try: + article_url = f"{build_public_base_url(request)}/{article['section']}/{article['slug']}" + await send_comment_notification_email( + article_title=str(article["title"] or article["slug"]), + article_url=article_url, + article_section=section_label, + comment_body=body, + comment_status=PUBLIC_COMMENT_DEFAULT_STATUS, + commenter_name=str(viewer.get("display_name") or viewer.get("full_name") or "TeeOff-leser"), + commenter_email=(str(viewer.get("email")).strip() if viewer.get("email") else None), + parent_author_name=parent_author_name, + ) + except Exception as exc: + print(f"Kunne ikke sende kommentarvarsel: {exc}") + return { "status": "success", "detail": ( @@ -2028,6 +2314,7 @@ async def get_admin_article(article_id: int): async def upsert_admin_article(request: ArticleUpsertRequest): section = normalize_article_section(request.section) status = normalize_article_status(request.status) + requested_slug = request.slug.strip() published_at = parse_optional_datetime(request.published_at) updated_at = parse_optional_datetime(request.updated_at) or datetime.utcnow() if status == "published" and not published_at: @@ -2041,6 +2328,10 @@ async def upsert_admin_article(request: ArticleUpsertRequest): hero_images = build_hero_images_from_media_gallery(media_gallery, fallback_hero_images, featured_media_id) async with app.state.pool.acquire() as conn: + previous_row = await conn.fetchrow( + "SELECT slug, section, status FROM articles WHERE slug = $1", + requested_slug, + ) row = await conn.fetchrow(""" INSERT INTO articles ( section, slug, title, description, excerpt, eyebrow, location_label, @@ -2073,7 +2364,7 @@ async def upsert_admin_article(request: ArticleUpsertRequest): RETURNING * """, section, - request.slug.strip(), + requested_slug, request.title.strip(), (request.description or "").strip() or None, (request.excerpt or "").strip() or None, @@ -2092,15 +2383,29 @@ async def upsert_admin_article(request: ArticleUpsertRequest): published_at, updated_at, ) - return format_article_row(row) + saved_article = format_article_row(row) + previous_article = format_article_row(previous_row) if previous_row else None + schedule_indexnow_submission( + collect_article_indexnow_urls(previous_article=previous_article, current_article=saved_article), + reason="admin article upsert", + ) + return saved_article @app.delete("/api/admin/articles/{article_id}") async def delete_admin_article(article_id: int): async with app.state.pool.acquire() as conn: - deleted = await conn.fetchrow("DELETE FROM articles WHERE id = $1 RETURNING id", article_id) + deleted = await conn.fetchrow( + "DELETE FROM articles WHERE id = $1 RETURNING slug, section, status", + article_id, + ) if not deleted: raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet") + deleted_article = format_article_row(deleted) + schedule_indexnow_submission( + collect_article_indexnow_urls(previous_article=deleted_article), + reason="admin article delete", + ) return {"status": "success"} @@ -2124,6 +2429,7 @@ async def seed_admin_articles_from_imported_json(): } upserted_count = 0 + submitted_urls: list[str] = [] async with conn.transaction(): for item in imported_articles: facility_slug = item.get("primaryFacilitySlug") or ((item.get("facilitySlugs") or [None])[0]) @@ -2225,6 +2531,14 @@ async def seed_admin_articles_from_imported_json(): ) upserted_count += 1 + article_url = build_article_public_url(section, str(item.get("slug") or "").strip()) + if article_url: + submitted_urls.append(article_url) + section_url = build_absolute_public_url(f"/{section}") + if section_url: + submitted_urls.append(section_url) + + schedule_indexnow_submission(dedupe_strings(submitted_urls), reason="admin article seed import") return {"status": "success", "count": upserted_count} @app.patch("/api/admin/facilities/{facility_id}/scrape-settings") @@ -2297,9 +2611,19 @@ async def update_facility_full(facility_id: int, request: Request): ] update_data = {k: v for k, v in data.items() if k in allowed_fields} + membership_fields = { + 'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer', + 'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url', 'membership_updated_at' + } + vtg_fields = {'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer', 'vtg_updated_at'} + changed_field_names = set(update_data.keys()) + facility_slug = "" async with app.state.pool.acquire() as conn: async with conn.transaction(): # Sikrer at alt lagres samlet + facility_slug = str( + await conn.fetchval("SELECT slug FROM facilities WHERE id = $1", facility_id) or "" + ).strip() facility_columns = await get_table_columns(conn, "facilities") update_data = {k: v for k, v in update_data.items() if k in facility_columns} @@ -2400,6 +2724,15 @@ async def update_facility_full(facility_id: int, request: Request): hole.get('par'), hole.get('hcp_index'), json.dumps(hole.get('lengths') or {}), hole_id, course_id) + extra_paths = ["/golfbaner"] + if changed_field_names & membership_fields: + extra_paths.append("/medlemskap") + if changed_field_names & vtg_fields: + extra_paths.append("/vtg") + schedule_indexnow_submission( + collect_facility_indexnow_urls([facility_slug], extra_paths=extra_paths), + reason="facility full update", + ) return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."} # --- NYTT ADMIN ENDPOINT: KJØRER SKRAPEREN FOR VALGTE IDER --- @@ -2499,6 +2832,7 @@ async def get_membership_drafts(): @app.post("/api/admin/membership/approve-bulk") async def approve_membership_bulk(request: BulkApprovalRequest): """Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet.""" + facility_ids = [approval.facility_id for approval in request.approvals] async with app.state.pool.acquire() as conn: async with conn.transaction(): for approval in request.approvals: @@ -2519,6 +2853,11 @@ async def approve_membership_bulk(request: BulkApprovalRequest): approval.navn_rimeligste_alternativ, approval.rimeligste_alternativ, approval.facility_id) + facility_slugs = await fetch_facility_slugs(conn, facility_ids) + schedule_indexnow_submission( + collect_facility_indexnow_urls(facility_slugs, extra_paths=["/medlemskap", "/golfbaner"]), + reason="membership bulk approval", + ) return {"status": "success", "message": f"{len(request.approvals)} anlegg ble oppdatert med nye priser!"} @app.patch("/api/admin/facilities/{facility_id}/quick-edit") @@ -2530,6 +2869,9 @@ async def quick_edit_facility(facility_id: int, request: QuickEditRequest): raise HTTPException(status_code=400, detail="Ugyldig felt for hurtigredigering.") async with app.state.pool.acquire() as conn: + facility_slug = str( + await conn.fetchval("SELECT slug FROM facilities WHERE id = $1", facility_id) or "" + ).strip() if request.field == 'footnote': normalized_value = str(request.value or '').strip() or None await conn.execute( @@ -2546,6 +2888,10 @@ async def quick_edit_facility(facility_id: int, request: QuickEditRequest): # F-string her er trygt fordi request.field er sjekket mot allowed_fields-listen await conn.execute(f"UPDATE facilities SET {request.field} = $1 WHERE id = $2", request.value, facility_id) + schedule_indexnow_submission( + collect_facility_indexnow_urls([facility_slug], extra_paths=["/golfbaner"]), + reason=f"facility quick edit ({request.field})", + ) return {"status": "success"} # --- GREENFEE "VASKERI" ENDEPUNKTER --- @@ -2569,6 +2915,7 @@ class BulkGreenfeeRequest(BaseModel): @app.post("/api/admin/greenfee/approve-bulk") async def approve_greenfee_bulk(request: BulkGreenfeeRequest): """Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet.""" + facility_ids = [approval.facility_id for approval in request.approvals] async with app.state.pool.acquire() as conn: async with conn.transaction(): facility_columns = await get_table_columns(conn, "facilities") @@ -2608,6 +2955,11 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest): greenfee_draft = NULL WHERE id = $2 """, json.dumps(approval.greenfee), approval.facility_id) + facility_slugs = await fetch_facility_slugs(conn, facility_ids) + schedule_indexnow_submission( + collect_facility_indexnow_urls(facility_slugs, extra_paths=["/golfbaner"]), + reason="greenfee bulk approval", + ) return {"status": "success"} @app.post("/api/admin/run-greenfee-scraper") @@ -2634,6 +2986,7 @@ async def get_vtg_drafts(): @app.post("/api/admin/vtg/approve-bulk") async def approve_vtg_bulk(request: BulkVtgRequest): """Godkjenner AI-forslag for VTG, setter oppdatert-dato og sletter utkastet.""" + facility_ids = [approval.facility_id for approval in request.approvals] async with app.state.pool.acquire() as conn: async with conn.transaction(): for approval in request.approvals: @@ -2647,6 +3000,11 @@ async def approve_vtg_bulk(request: BulkVtgRequest): vtg_draft = NULL WHERE id = $4 """, approval.vtg_pris, approval.vtg_beskrivelse, datoer_json, approval.facility_id) + facility_slugs = await fetch_facility_slugs(conn, facility_ids) + schedule_indexnow_submission( + collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]), + reason="vtg bulk approval", + ) return {"status": "success"} @app.post("/api/admin/run-vtg-scraper") @@ -2673,6 +3031,7 @@ async def get_golfpakker_drafts(): @app.post("/api/admin/golfpakker/approve-bulk") async def approve_golfpakker_bulk(request: BulkGolfpakkerRequest): """Godkjenner AI-forslag for golfpakker og sletter utkastet.""" + facility_ids = [approval.facility_id for approval in request.approvals] async with app.state.pool.acquire() as conn: async with conn.transaction(): for approval in request.approvals: @@ -2683,6 +3042,11 @@ async def approve_golfpakker_bulk(request: BulkGolfpakkerRequest): golfpakker_draft = NULL WHERE id = $2 """, json.dumps(approval.golfpakker), approval.facility_id) + facility_slugs = await fetch_facility_slugs(conn, facility_ids) + schedule_indexnow_submission( + collect_facility_indexnow_urls(facility_slugs, extra_paths=["/golfbaner"]), + reason="golfpakker bulk approval", + ) return {"status": "success"} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 98ccb5e..2e47f62 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -27,6 +27,7 @@ services: SMTP_PASS: ${SMTP_PASS} PUBLIC_FROM_EMAIL: ${PUBLIC_FROM_EMAIL} PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES: ${PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES} + INDEXNOW_KEY: ${INDEXNOW_KEY} volumes: - ./backend:/app - ./frontend/src/content:/shared/frontend-content:ro diff --git a/docker-compose.yml b/docker-compose.yml index 1276055..cda89d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: SMTP_PASS: ${SMTP_PASS} PUBLIC_FROM_EMAIL: ${PUBLIC_FROM_EMAIL} PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES: ${PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES} + INDEXNOW_KEY: ${INDEXNOW_KEY} ports: - "8001:8000" volumes: diff --git a/frontend/public/0be05afd880c43c686473b336cab9a87 b/frontend/public/0be05afd880c43c686473b336cab9a87 new file mode 100644 index 0000000..46c87d8 --- /dev/null +++ b/frontend/public/0be05afd880c43c686473b336cab9a87 @@ -0,0 +1 @@ +0be05afd880c43c686473b336cab9a87 diff --git a/frontend/public/0be05afd880c43c686473b336cab9a87.txt b/frontend/public/0be05afd880c43c686473b336cab9a87.txt new file mode 100644 index 0000000..46c87d8 --- /dev/null +++ b/frontend/public/0be05afd880c43c686473b336cab9a87.txt @@ -0,0 +1 @@ +0be05afd880c43c686473b336cab9a87 diff --git a/frontend/src/app/FacilitySearch.tsx b/frontend/src/app/FacilitySearch.tsx index 466ef69..9b398ea 100755 --- a/frontend/src/app/FacilitySearch.tsx +++ b/frontend/src/app/FacilitySearch.tsx @@ -236,6 +236,11 @@ const buildMapUrl = (lat?: number | null, lng?: number | null) => { return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`; }; +const formatPhoneHref = (phone: string) => { + const normalized = phone.replace(/[^\d+]/g, ""); + return normalized.startsWith("00") ? `+${normalized.slice(2)}` : normalized; +}; + const escapeHtml = (value: string) => value .replace(/&/g, "&") @@ -826,7 +831,17 @@ export default function FacilitySearch({
- {facility.phone ? facility.phone : facility.city || "Se detaljer"} + {facility.phone ? ( + + {facility.phone} + + ) : ( + {facility.city || "Se detaljer"} + )}
{facility.website_url && ( diff --git a/frontend/src/app/facilityData.ts b/frontend/src/app/facilityData.ts index 3c233f1..0595b23 100755 --- a/frontend/src/app/facilityData.ts +++ b/frontend/src/app/facilityData.ts @@ -293,8 +293,8 @@ export const getPlaceConfigFromSlug = (slug: string): PlaceConfig | null => { slug, areaFilter: "", label: option.label, - title: "Alle golfbaner i Norge", - intro: "Se alle norske golfbaner på kartet, med statusikoner og listevisning under.", + title: "Golfbaner i Norge", + intro: "Se alle norske golfbaner samlet med banestatus, kart og lenker videre til hver baneprofil.", }; } @@ -303,10 +303,10 @@ export const getPlaceConfigFromSlug = (slug: string): PlaceConfig | null => { slug, areaFilter: option.value, label: option.label, - title: `Alle golfbaner i ${option.label}`, + title: `Golfbaner i ${option.label}`, intro: isRegion - ? `Utforsk golfbaner i ${option.label} på kartet og gå videre til hver bane under.` - : `Utforsk golfbaner i ${option.label} på kartet og sammenlign banene i listen under.`, + ? `Utforsk golfbaner i ${option.label} med oppdatert banestatus, kart og direkte lenker til baneprofilene.` + : `Utforsk golfbaner i ${option.label} og sammenlign banestatus, plassering og banedetaljer i listen under.`, }; }; diff --git a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx index bde7532..8d13b79 100644 --- a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx +++ b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx @@ -31,7 +31,8 @@ const FacilityDetailLeafletMap = dynamic(() => import("./FacilityDetailLeafletMa const formatPhoneForUrl = (phone: string) => { if (!phone) return ""; - return phone.replace('+', '00').replace(/\s/g, ''); + const normalized = phone.replace(/[^\d+]/g, ""); + return normalized.startsWith("00") ? `+${normalized.slice(2)}` : normalized; }; const renderValue = (val: string) => { @@ -331,17 +332,43 @@ export default function FacilityDetailView({ facility }: { facility: any }) {

Kontakt & Adresse

- Besøk nettsiden - - {facility.phone || 'Ikke oppgitt'} - - - {facility.email || 'Ikke oppgitt'} - + {facility.website_url ? ( + + Besøk nettsiden + + ) : ( +
+ Nettside ikke oppgitt +
+ )} + {facility.phone ? ( + + {facility.phone} + + ) : ( +
+ Telefon ikke oppgitt +
+ )} + {facility.email ? ( + + {facility.email} + + ) : ( +
+ E-post ikke oppgitt +
+ )}
- - {facility.address}
{facility.city}
-
+ {mapUrl ? ( + + {facility.address}
{facility.city}
+
+ ) : ( +
+ {facility.address || "Adresse ikke oppgitt"}{facility.city ? <>
{facility.city} : null}
+
+ )}
@@ -480,7 +507,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {

Vær for {facility.name}

- {facility.weather_url ? ( Vær ) :

Værvarsel ikke tilgjengelig

} + {facility.weather_url ? ( {`Værvarsel ) :

Værvarsel ikke tilgjengelig

}
diff --git a/frontend/src/app/golfbaner/page.tsx b/frontend/src/app/golfbaner/page.tsx index 819bef9..a06fbf4 100755 --- a/frontend/src/app/golfbaner/page.tsx +++ b/frontend/src/app/golfbaner/page.tsx @@ -3,14 +3,15 @@ import { API_URL } from "@/config/constants"; import { createBreadcrumbJsonLd, createCollectionPageJsonLd, + createItemListJsonLd, createPageMetadata, } from "@/app/seo"; export const dynamic = "force-dynamic"; -const pageTitle = "Alle golfbaner i Norge"; +const pageTitle = "Golfbaner i Norge"; const pageDescription = - "Filtrer norske golfbaner etter område, banestatus, antall hull og fasiliteter i TeeOffs samlede oversikt."; + "Finn golfbaner i Norge og filtrer på område, banestatus, antall hull og fasiliteter i TeeOffs samlede oversikt."; export const metadata = createPageMetadata({ title: pageTitle, @@ -43,6 +44,17 @@ export default async function GolfCoursesIndexPage() { description: pageDescription, path: "/golfbaner", }); + const itemListJsonLd = createItemListJsonLd({ + name: pageTitle, + path: "/golfbaner", + items: safeData + .filter((facility) => facility?.slug && facility?.name) + .map((facility) => ({ + name: facility.name, + path: `/golfbaner/${facility.slug}`, + description: facility.description, + })), + }); const breadcrumbJsonLd = createBreadcrumbJsonLd([ { name: "Hjem", path: "/" }, { name: "Golfbaner", path: "/golfbaner" }, @@ -54,6 +66,10 @@ export default async function GolfCoursesIndexPage() { type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }} /> +