Før endringer på forsiden

This commit is contained in:
Erol Haagenrud 2026-04-18 09:00:16 +02:00
parent d9c747e83d
commit 3d3470267c
13 changed files with 669 additions and 63 deletions

View file

@ -2,7 +2,9 @@ SMTP_SERVER=send.one.com
SMTP_PORT=465 SMTP_PORT=465
SMTP_USER=teeoff@example.com SMTP_USER=teeoff@example.com
SMTP_PASS=replace-with-your-smtp-password SMTP_PASS=replace-with-your-smtp-password
COMMENT_NOTIFICATION_TO_EMAIL=teeoff@example.com
EMAIL_TO=ops@example.com EMAIL_TO=ops@example.com
GEMINI_API_KEY=replace-with-your-gemini-api-key GEMINI_API_KEY=replace-with-your-gemini-api-key
DATABASE_URL=postgresql://teeoff_admin:replace-with-your-postgres-password@db:5432/teeoff DATABASE_URL=postgresql://teeoff_admin:replace-with-your-postgres-password@db:5432/teeoff
JWT_SECRET=replace-with-a-long-random-secret JWT_SECRET=replace-with-a-long-random-secret
INDEXNOW_KEY=replace-with-your-indexnow-key

View file

@ -69,6 +69,13 @@ SMTP_USER = os.getenv("SMTP_USER", "").strip()
SMTP_PASS = os.getenv("SMTP_PASS", "").strip() SMTP_PASS = os.getenv("SMTP_PASS", "").strip()
PUBLIC_FROM_EMAIL = os.getenv("PUBLIC_FROM_EMAIL", SMTP_USER).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() 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") 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: def build_public_base_url(request: Request) -> str:
configured = os.getenv("PUBLIC_BASE_URL", "").strip().rstrip("/") configured = get_configured_public_base_url()
if configured: if configured:
return 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" 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: def should_use_secure_cookies(request: Request) -> bool:
configured = os.getenv("PUBLIC_BASE_URL", "").strip().lower() configured = os.getenv("PUBLIC_BASE_URL", "").strip().lower()
if configured.startswith("https://"): if configured.startswith("https://"):
@ -298,6 +475,50 @@ async def send_contact_form_email(
await asyncio.to_thread(_send) 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: async def validate_admin_session_token(token: str) -> str:
try: try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
@ -391,6 +612,7 @@ class ArticleUpsertRequest(BaseModel):
class PublicCommentCreateRequest(BaseModel): class PublicCommentCreateRequest(BaseModel):
body: str body: str
parent_id: Optional[int] = None
class PublicMagicLinkRequest(BaseModel): 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"<img\b[^>]*\bsrc=['\"]([^'\"]+)['\"]", re.IGNORECASE) ARTICLE_IMAGE_PATTERN = re.compile(r"<img\b[^>]*\bsrc=['\"]([^'\"]+)['\"]", re.IGNORECASE)
@ -1950,33 +2188,81 @@ async def create_article_comment(
viewer = await require_authenticated_public_user(request) viewer = await require_authenticated_public_user(request)
normalized_section = normalize_article_section(section) if section else None normalized_section = normalize_article_section(section) if section else None
body = str(payload.body or "").strip() body = str(payload.body or "").strip()
parent_id = payload.parent_id
if len(body) < 3: if len(body) < 3:
raise HTTPException(status_code=400, detail="Kommentaren må være minst 3 tegn.") raise HTTPException(status_code=400, detail="Kommentaren må være minst 3 tegn.")
if len(body) > 4000: if len(body) > 4000:
raise HTTPException(status_code=400, detail="Kommentaren er for lang.") 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: async with app.state.pool.acquire() as conn:
article = await find_published_article_by_slug(conn, slug, normalized_section) article = await find_published_article_by_slug(conn, slug, normalized_section)
if not article: if not article:
raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet") 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( row = await conn.fetchrow(
""" """
INSERT INTO article_comments ( 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 ( ) VALUES (
$1, $2, $3, $4, $5, $6 $1, $2, $3, $4, $5, $6, $7
) )
RETURNING * RETURNING *
""", """,
article["id"], article["id"],
viewer["id"], viewer["id"],
parent_id,
body, body,
PUBLIC_COMMENT_DEFAULT_STATUS, PUBLIC_COMMENT_DEFAULT_STATUS,
hash_request_ip(request), hash_request_ip(request),
request.headers.get("user-agent"), 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 { return {
"status": "success", "status": "success",
"detail": ( "detail": (
@ -2028,6 +2314,7 @@ async def get_admin_article(article_id: int):
async def upsert_admin_article(request: ArticleUpsertRequest): async def upsert_admin_article(request: ArticleUpsertRequest):
section = normalize_article_section(request.section) section = normalize_article_section(request.section)
status = normalize_article_status(request.status) status = normalize_article_status(request.status)
requested_slug = request.slug.strip()
published_at = parse_optional_datetime(request.published_at) published_at = parse_optional_datetime(request.published_at)
updated_at = parse_optional_datetime(request.updated_at) or datetime.utcnow() updated_at = parse_optional_datetime(request.updated_at) or datetime.utcnow()
if status == "published" and not published_at: 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) hero_images = build_hero_images_from_media_gallery(media_gallery, fallback_hero_images, featured_media_id)
async with app.state.pool.acquire() as conn: 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(""" row = await conn.fetchrow("""
INSERT INTO articles ( INSERT INTO articles (
section, slug, title, description, excerpt, eyebrow, location_label, section, slug, title, description, excerpt, eyebrow, location_label,
@ -2073,7 +2364,7 @@ async def upsert_admin_article(request: ArticleUpsertRequest):
RETURNING * RETURNING *
""", """,
section, section,
request.slug.strip(), requested_slug,
request.title.strip(), request.title.strip(),
(request.description or "").strip() or None, (request.description or "").strip() or None,
(request.excerpt or "").strip() or None, (request.excerpt or "").strip() or None,
@ -2092,15 +2383,29 @@ async def upsert_admin_article(request: ArticleUpsertRequest):
published_at, published_at,
updated_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}") @app.delete("/api/admin/articles/{article_id}")
async def delete_admin_article(article_id: int): async def delete_admin_article(article_id: int):
async with app.state.pool.acquire() as conn: 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: if not deleted:
raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet") 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"} return {"status": "success"}
@ -2124,6 +2429,7 @@ async def seed_admin_articles_from_imported_json():
} }
upserted_count = 0 upserted_count = 0
submitted_urls: list[str] = []
async with conn.transaction(): async with conn.transaction():
for item in imported_articles: for item in imported_articles:
facility_slug = item.get("primaryFacilitySlug") or ((item.get("facilitySlugs") or [None])[0]) 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 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} return {"status": "success", "count": upserted_count}
@app.patch("/api/admin/facilities/{facility_id}/scrape-settings") @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} 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 app.state.pool.acquire() as conn:
async with conn.transaction(): # Sikrer at alt lagres samlet 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") facility_columns = await get_table_columns(conn, "facilities")
update_data = {k: v for k, v in update_data.items() if k in facility_columns} 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'), hole.get('par'), hole.get('hcp_index'),
json.dumps(hole.get('lengths') or {}), hole_id, course_id) 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."} return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."}
# --- NYTT ADMIN ENDPOINT: KJØRER SKRAPEREN FOR VALGTE IDER --- # --- 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") @app.post("/api/admin/membership/approve-bulk")
async def approve_membership_bulk(request: BulkApprovalRequest): async def approve_membership_bulk(request: BulkApprovalRequest):
"""Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet.""" """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 app.state.pool.acquire() as conn:
async with conn.transaction(): async with conn.transaction():
for approval in request.approvals: for approval in request.approvals:
@ -2519,6 +2853,11 @@ async def approve_membership_bulk(request: BulkApprovalRequest):
approval.navn_rimeligste_alternativ, approval.navn_rimeligste_alternativ,
approval.rimeligste_alternativ, approval.rimeligste_alternativ,
approval.facility_id) 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!"} return {"status": "success", "message": f"{len(request.approvals)} anlegg ble oppdatert med nye priser!"}
@app.patch("/api/admin/facilities/{facility_id}/quick-edit") @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.") raise HTTPException(status_code=400, detail="Ugyldig felt for hurtigredigering.")
async with app.state.pool.acquire() as conn: 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': if request.field == 'footnote':
normalized_value = str(request.value or '').strip() or None normalized_value = str(request.value or '').strip() or None
await conn.execute( 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 # 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", await conn.execute(f"UPDATE facilities SET {request.field} = $1 WHERE id = $2",
request.value, facility_id) 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"} return {"status": "success"}
# --- GREENFEE "VASKERI" ENDEPUNKTER --- # --- GREENFEE "VASKERI" ENDEPUNKTER ---
@ -2569,6 +2915,7 @@ class BulkGreenfeeRequest(BaseModel):
@app.post("/api/admin/greenfee/approve-bulk") @app.post("/api/admin/greenfee/approve-bulk")
async def approve_greenfee_bulk(request: BulkGreenfeeRequest): async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
"""Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet.""" """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 app.state.pool.acquire() as conn:
async with conn.transaction(): async with conn.transaction():
facility_columns = await get_table_columns(conn, "facilities") facility_columns = await get_table_columns(conn, "facilities")
@ -2608,6 +2955,11 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
greenfee_draft = NULL greenfee_draft = NULL
WHERE id = $2 WHERE id = $2
""", json.dumps(approval.greenfee), approval.facility_id) """, 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"} return {"status": "success"}
@app.post("/api/admin/run-greenfee-scraper") @app.post("/api/admin/run-greenfee-scraper")
@ -2634,6 +2986,7 @@ async def get_vtg_drafts():
@app.post("/api/admin/vtg/approve-bulk") @app.post("/api/admin/vtg/approve-bulk")
async def approve_vtg_bulk(request: BulkVtgRequest): async def approve_vtg_bulk(request: BulkVtgRequest):
"""Godkjenner AI-forslag for VTG, setter oppdatert-dato og sletter utkastet.""" """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 app.state.pool.acquire() as conn:
async with conn.transaction(): async with conn.transaction():
for approval in request.approvals: for approval in request.approvals:
@ -2647,6 +3000,11 @@ async def approve_vtg_bulk(request: BulkVtgRequest):
vtg_draft = NULL vtg_draft = NULL
WHERE id = $4 WHERE id = $4
""", approval.vtg_pris, approval.vtg_beskrivelse, datoer_json, approval.facility_id) """, 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"} return {"status": "success"}
@app.post("/api/admin/run-vtg-scraper") @app.post("/api/admin/run-vtg-scraper")
@ -2673,6 +3031,7 @@ async def get_golfpakker_drafts():
@app.post("/api/admin/golfpakker/approve-bulk") @app.post("/api/admin/golfpakker/approve-bulk")
async def approve_golfpakker_bulk(request: BulkGolfpakkerRequest): async def approve_golfpakker_bulk(request: BulkGolfpakkerRequest):
"""Godkjenner AI-forslag for golfpakker og sletter utkastet.""" """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 app.state.pool.acquire() as conn:
async with conn.transaction(): async with conn.transaction():
for approval in request.approvals: for approval in request.approvals:
@ -2683,6 +3042,11 @@ async def approve_golfpakker_bulk(request: BulkGolfpakkerRequest):
golfpakker_draft = NULL golfpakker_draft = NULL
WHERE id = $2 WHERE id = $2
""", json.dumps(approval.golfpakker), approval.facility_id) """, 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"} return {"status": "success"}

View file

@ -27,6 +27,7 @@ services:
SMTP_PASS: ${SMTP_PASS} SMTP_PASS: ${SMTP_PASS}
PUBLIC_FROM_EMAIL: ${PUBLIC_FROM_EMAIL} PUBLIC_FROM_EMAIL: ${PUBLIC_FROM_EMAIL}
PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES: ${PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES} PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES: ${PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES}
INDEXNOW_KEY: ${INDEXNOW_KEY}
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./frontend/src/content:/shared/frontend-content:ro - ./frontend/src/content:/shared/frontend-content:ro

View file

@ -29,6 +29,7 @@ services:
SMTP_PASS: ${SMTP_PASS} SMTP_PASS: ${SMTP_PASS}
PUBLIC_FROM_EMAIL: ${PUBLIC_FROM_EMAIL} PUBLIC_FROM_EMAIL: ${PUBLIC_FROM_EMAIL}
PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES: ${PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES} PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES: ${PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES}
INDEXNOW_KEY: ${INDEXNOW_KEY}
ports: ports:
- "8001:8000" - "8001:8000"
volumes: volumes:

View file

@ -0,0 +1 @@
0be05afd880c43c686473b336cab9a87

View file

@ -0,0 +1 @@
0be05afd880c43c686473b336cab9a87

View file

@ -236,6 +236,11 @@ const buildMapUrl = (lat?: number | null, lng?: number | null) => {
return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`; 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) => const escapeHtml = (value: string) =>
value value
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@ -826,7 +831,17 @@ export default function FacilitySearch({
</div> </div>
<div className="mt-auto pt-5 grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 text-sm font-bold text-[#112015]"> <div className="mt-auto pt-5 grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 text-sm font-bold text-[#112015]">
<span className="truncate text-[#617063]">{facility.phone ? facility.phone : facility.city || "Se detaljer"}</span> {facility.phone ? (
<a
href={`tel:${formatPhoneHref(facility.phone)}`}
className="truncate text-[#617063] transition hover:text-[#FF5722]"
aria-label={`Ring ${facility.name}${facility.phone}`}
>
{facility.phone}
</a>
) : (
<span className="truncate text-[#617063]">{facility.city || "Se detaljer"}</span>
)}
<div className="flex items-center justify-self-center gap-1"> <div className="flex items-center justify-self-center gap-1">
{facility.website_url && ( {facility.website_url && (
<a href={facility.website_url} target="_blank" rel="noreferrer" className={actionIconClassName} aria-label={`Besøk nettsiden til ${facility.name}`}> <a href={facility.website_url} target="_blank" rel="noreferrer" className={actionIconClassName} aria-label={`Besøk nettsiden til ${facility.name}`}>

View file

@ -293,8 +293,8 @@ export const getPlaceConfigFromSlug = (slug: string): PlaceConfig | null => {
slug, slug,
areaFilter: "", areaFilter: "",
label: option.label, label: option.label,
title: "Alle golfbaner i Norge", title: "Golfbaner i Norge",
intro: "Se alle norske golfbaner på kartet, med statusikoner og listevisning under.", 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, slug,
areaFilter: option.value, areaFilter: option.value,
label: option.label, label: option.label,
title: `Alle golfbaner i ${option.label}`, title: `Golfbaner i ${option.label}`,
intro: isRegion intro: isRegion
? `Utforsk golfbaner i ${option.label} på kartet og gå videre til hver bane under.` ? `Utforsk golfbaner i ${option.label} med oppdatert banestatus, kart og direkte lenker til baneprofilene.`
: `Utforsk golfbaner i ${option.label} på kartet og sammenlign banene i listen under.`, : `Utforsk golfbaner i ${option.label} og sammenlign banestatus, plassering og banedetaljer i listen under.`,
}; };
}; };

View file

@ -31,7 +31,8 @@ const FacilityDetailLeafletMap = dynamic(() => import("./FacilityDetailLeafletMa
const formatPhoneForUrl = (phone: string) => { const formatPhoneForUrl = (phone: string) => {
if (!phone) return ""; 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) => { const renderValue = (val: string) => {
@ -331,17 +332,43 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
<div className="lg:w-[22%] bg-white p-10 md:rounded-[3rem] shadow-sm flex flex-col order-last lg:order-none"> <div className="lg:w-[22%] bg-white p-10 md:rounded-[3rem] shadow-sm flex flex-col order-last lg:order-none">
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-10">Kontakt & Adresse</h3> <h3 className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-10">Kontakt & Adresse</h3>
<div className="flex-grow space-y-7 text-sm font-bold"> <div className="flex-grow space-y-7 text-sm font-bold">
<a href={facility.website_url} target="_blank" className={sidebarLinkClass}><Icon children={ICONS.web} /> <span className={sidebarLinkTextClass}>Besøk nettsiden</span></a> {facility.website_url ? (
<a href={facility.website_url} target="_blank" rel="noreferrer" className={sidebarLinkClass}>
<Icon children={ICONS.web} /> <span className={sidebarLinkTextClass}>Besøk nettsiden</span>
</a>
) : (
<div className="flex items-center gap-4 text-[#8A9488]">
<Icon children={ICONS.web} /> <span>Nettside ikke oppgitt</span>
</div>
)}
{facility.phone ? (
<a href={`tel:${formatPhoneForUrl(facility.phone)}`} className={sidebarLinkClass}> <a href={`tel:${formatPhoneForUrl(facility.phone)}`} className={sidebarLinkClass}>
<Icon children={ICONS.phone} /> <span className={sidebarLinkTextClass}>{facility.phone || 'Ikke oppgitt'}</span> <Icon children={ICONS.phone} /> <span className={sidebarLinkTextClass}>{facility.phone}</span>
</a> </a>
) : (
<div className="flex items-center gap-4 text-[#8A9488]">
<Icon children={ICONS.phone} /> <span>Telefon ikke oppgitt</span>
</div>
)}
{facility.email ? (
<a href={`mailto:${facility.email}`} className={sidebarLinkClass}> <a href={`mailto:${facility.email}`} className={sidebarLinkClass}>
<Icon children={ICONS.mail} /> <span className={`truncate ${sidebarLinkTextClass}`}>{facility.email || 'Ikke oppgitt'}</span> <Icon children={ICONS.mail} /> <span className={`truncate ${sidebarLinkTextClass}`}>{facility.email}</span>
</a> </a>
) : (
<div className="flex items-center gap-4 text-[#8A9488]">
<Icon children={ICONS.mail} /> <span>E-post ikke oppgitt</span>
</div>
)}
<div className="pt-2 border-t border-gray-50 mt-4"> <div className="pt-2 border-t border-gray-50 mt-4">
<a href={mapUrl || "#"} target="_blank" rel="noreferrer" className={sidebarLinkClass + " pt-4 leading-tight items-start"}> {mapUrl ? (
<a href={mapUrl} target="_blank" rel="noreferrer" className={sidebarLinkClass + " pt-4 leading-tight items-start"}>
<Icon children={ICONS.pin} /> <span className="text-gray-400 group-hover:text-[#ff5722] transition-colors">{facility.address}<br/>{facility.city}</span> <Icon children={ICONS.pin} /> <span className="text-gray-400 group-hover:text-[#ff5722] transition-colors">{facility.address}<br/>{facility.city}</span>
</a> </a>
) : (
<div className="flex items-start gap-4 pt-4 text-[#8A9488] leading-tight">
<Icon children={ICONS.pin} /> <span>{facility.address || "Adresse ikke oppgitt"}{facility.city ? <><br />{facility.city}</> : null}</span>
</div>
)}
</div> </div>
</div> </div>
@ -480,7 +507,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
<section id="weather" className="bg-white p-0 md:p-12 md:rounded-[3rem] shadow-sm border-b md:border-none overflow-hidden text-center"> <section id="weather" className="bg-white p-0 md:p-12 md:rounded-[3rem] shadow-sm border-b md:border-none overflow-hidden text-center">
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-[0.2em] py-8 md:py-0 md:mb-10 flex items-center justify-center gap-3"><Icon children={ICONS.weather} /> Vær for {facility.name}</h3> <h3 className="text-[10px] font-black text-gray-300 uppercase tracking-[0.2em] py-8 md:py-0 md:mb-10 flex items-center justify-center gap-3"><Icon children={ICONS.weather} /> Vær for {facility.name}</h3>
<div className="w-full flex justify-center px-4 md:px-0"> <div className="w-full flex justify-center px-4 md:px-0">
{facility.weather_url ? ( <img src={weatherImg} className="w-full h-auto block max-w-5xl" alt="Vær" /> ) : <p className="text-center py-24 text-gray-300 italic text-sm">Værvarsel ikke tilgjengelig</p>} {facility.weather_url ? ( <img src={weatherImg} className="w-full h-auto block max-w-5xl" alt={`Værvarsel for ${facility.name}`} loading="lazy" decoding="async" /> ) : <p className="text-center py-24 text-gray-300 italic text-sm">Værvarsel ikke tilgjengelig</p>}
</div> </div>
</section> </section>

View file

@ -3,14 +3,15 @@ import { API_URL } from "@/config/constants";
import { import {
createBreadcrumbJsonLd, createBreadcrumbJsonLd,
createCollectionPageJsonLd, createCollectionPageJsonLd,
createItemListJsonLd,
createPageMetadata, createPageMetadata,
} from "@/app/seo"; } from "@/app/seo";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const pageTitle = "Alle golfbaner i Norge"; const pageTitle = "Golfbaner i Norge";
const pageDescription = 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({ export const metadata = createPageMetadata({
title: pageTitle, title: pageTitle,
@ -43,6 +44,17 @@ export default async function GolfCoursesIndexPage() {
description: pageDescription, description: pageDescription,
path: "/golfbaner", 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([ const breadcrumbJsonLd = createBreadcrumbJsonLd([
{ name: "Hjem", path: "/" }, { name: "Hjem", path: "/" },
{ name: "Golfbaner", path: "/golfbaner" }, { name: "Golfbaner", path: "/golfbaner" },
@ -54,6 +66,10 @@ export default async function GolfCoursesIndexPage() {
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }}
/> />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListJsonLd) }}
/>
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
@ -63,8 +79,8 @@ export default async function GolfCoursesIndexPage() {
initialFacilities={safeData} initialFacilities={safeData}
variant="catalog" variant="catalog"
eyebrow="Golfbaner" eyebrow="Golfbaner"
title="Alle golfbaner samlet på ett sted" title="Golfbaner i Norge"
intro="Bruk område, banestatus og fasiliteter for å snevre inn oversikten. Her får katalogen være arbeidsflate, ikke hero." intro="Filtrer norske golfbaner etter område, banestatus, antall hull og fasiliteter, og gå videre til hver baneprofil."
/> />
</main> </main>
</> </>

View file

@ -21,6 +21,16 @@ type CollectionPageInput = {
path: string; path: string;
}; };
type ItemListInput = {
name: string;
path: string;
items: Array<{
name: string;
path: string;
description?: string | null;
}>;
};
type SocialLink = { type SocialLink = {
url?: string | null; url?: string | null;
}; };
@ -171,24 +181,59 @@ export function createCollectionPageJsonLd({ name, description, path }: Collecti
}; };
} }
export function createItemListJsonLd({ name, path, items }: ItemListInput) {
return {
"@context": "https://schema.org",
"@type": "ItemList",
name,
url: buildAbsoluteUrl(path),
numberOfItems: items.length,
itemListOrder: "https://schema.org/ItemListOrderAscending",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
url: buildAbsoluteUrl(item.path),
name: item.name,
description: item.description ? trimDescription(item.description, 200) : undefined,
})),
};
}
export function createFacilityJsonLd(facility: FacilitySeoRecord) { export function createFacilityJsonLd(facility: FacilitySeoRecord) {
const socialLinks = parseJson<SocialLink[]>(facility.social_links, []); const socialLinks = parseJson<SocialLink[]>(facility.social_links, []);
const amenities = parseJson<Record<string, unknown>>(facility.amenities, {});
const sameAs = [facility.website_url, ...socialLinks.map((entry) => entry?.url || null)].filter( const sameAs = [facility.website_url, ...socialLinks.map((entry) => entry?.url || null)].filter(
(value): value is string => Boolean(value), (value): value is string => Boolean(value),
); );
const amenityFeature = [
createAmenityFeature("Driving range", amenities.drivingrange),
createAmenityFeature("Simulator", amenities.simulator),
createAmenityFeature("Proshop", amenities.proshop),
createAmenityFeature("Kafé", amenities.kafe),
createAmenityFeature("Treningsgreen", amenities.treningsgreen),
createAmenityFeature("Kølleutleie", amenities.kolleutleie),
createAmenityFeature("Bilutleie", amenities.bilutleie),
].filter(Boolean);
const hasMap =
typeof facility.lat === "number" && typeof facility.lng === "number"
? `https://www.google.com/maps/search/?api=1&query=${facility.lat},${facility.lng}`
: undefined;
return { return {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "GolfCourse", "@type": ["GolfCourse", "SportsActivityLocation"],
"@id": buildAbsoluteUrl(`/golfbaner/${facility.slug}#golfcourse`), "@id": buildAbsoluteUrl(`/golfbaner/${facility.slug}#golfcourse`),
name: facility.name, name: facility.name,
description: description:
trimDescription(facility.description) || trimDescription(facility.description) ||
`${facility.name} er en golfbane på TeeOff med oppdatert banestatus og praktisk klubbinfo.`, `${facility.name} er en golfbane på TeeOff med oppdatert banestatus og praktisk klubbinfo.`,
url: buildAbsoluteUrl(`/golfbaner/${facility.slug}`), url: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
mainEntityOfPage: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
image: resolveImageUrl(facility.image_url), image: resolveImageUrl(facility.image_url),
telephone: facility.phone || undefined, telephone: facility.phone || undefined,
email: facility.email || undefined, email: facility.email || undefined,
sport: "Golf",
hasMap,
address: address:
facility.address || facility.city || facility.county facility.address || facility.city || facility.county
? { ? {
@ -207,6 +252,7 @@ export function createFacilityJsonLd(facility: FacilitySeoRecord) {
longitude: facility.lng, longitude: facility.lng,
} }
: undefined, : undefined,
amenityFeature: amenityFeature.length > 0 ? amenityFeature : undefined,
sameAs: sameAs.length > 0 ? sameAs : undefined, sameAs: sameAs.length > 0 ? sameAs : undefined,
isPartOf: { isPartOf: {
"@id": WEBSITE_ID, "@id": WEBSITE_ID,
@ -356,6 +402,24 @@ function parseComparableDate(raw: string) {
return Number.isNaN(parsed.getTime()) ? null : parsed; return Number.isNaN(parsed.getTime()) ? null : parsed;
} }
function createAmenityFeature(name: string, value: unknown) {
if (!hasTruthyFeature(value)) return null;
return {
"@type": "LocationFeatureSpecification",
name,
value: true,
};
}
function hasTruthyFeature(value: unknown) {
const normalized = stripHtml(String(value || ""))
.toLowerCase()
.replace(/\s+/g, " ")
.trim();
return Boolean(normalized) && !["nei", "no", "false", "0", "ingen", "--"].includes(normalized);
}
function parseJson<T>(value: unknown, fallback: T): T { function parseJson<T>(value: unknown, fallback: T): T {
if (!value) return fallback; if (!value) return fallback;
if (typeof value === "object") return value as T; if (typeof value === "object") return value as T;

View file

@ -13,6 +13,7 @@ import { API_URL } from "@/config/constants";
import { import {
createBreadcrumbJsonLd, createBreadcrumbJsonLd,
createCollectionPageJsonLd, createCollectionPageJsonLd,
createItemListJsonLd,
createPageMetadata, createPageMetadata,
} from "@/app/seo"; } from "@/app/seo";
@ -41,7 +42,7 @@ export async function generateMetadata({
return createPageMetadata({ return createPageMetadata({
title: place.title, title: place.title,
description: place.intro, description: `${place.intro} TeeOff samler golfbaner i ${place.label} med oppdatert banestatus og baneprofiler.`,
path: `/sted/${slug}`, path: `/sted/${slug}`,
}); });
} }
@ -79,6 +80,17 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
description: place.intro, description: place.intro,
path: `/sted/${slug}`, path: `/sted/${slug}`,
}); });
const itemListJsonLd = createItemListJsonLd({
name: place.title,
path: `/sted/${slug}`,
items: facilitiesInPlace
.filter((facility) => facility?.slug && facility?.name)
.map((facility) => ({
name: facility.name,
path: `/golfbaner/${facility.slug}`,
description: facility.description,
})),
});
const breadcrumbJsonLd = createBreadcrumbJsonLd([ const breadcrumbJsonLd = createBreadcrumbJsonLd([
{ name: "Hjem", path: "/" }, { name: "Hjem", path: "/" },
{ name: "Steder", path: "/sted/norge" }, { name: "Steder", path: "/sted/norge" },
@ -91,6 +103,10 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }}
/> />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListJsonLd) }}
/>
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
type ArticleCommentsProps = { type ArticleCommentsProps = {
@ -18,6 +18,7 @@ type Viewer = {
type CommentItem = { type CommentItem = {
id: number; id: number;
parent_id?: number | null;
body: string; body: string;
status: string; status: string;
created_at?: string | null; created_at?: string | null;
@ -40,6 +41,10 @@ type CommentsResponse = {
comments: CommentItem[]; comments: CommentItem[];
}; };
type CommentNode = CommentItem & {
children: CommentNode[];
};
function formatCommentDate(value?: string | null) { function formatCommentDate(value?: string | null) {
if (!value) return ""; if (!value) return "";
const date = new Date(value); const date = new Date(value);
@ -63,11 +68,39 @@ async function fetchCommentsPayload(slug: string, section: "banebesok" | "mening
return (await response.json()) as CommentsResponse; return (await response.json()) as CommentsResponse;
} }
function buildCommentTree(comments: CommentItem[]) {
const commentMap = new Map<number, CommentNode>();
const roots: CommentNode[] = [];
comments.forEach((comment) => {
commentMap.set(comment.id, {
...comment,
children: [],
});
});
comments.forEach((comment) => {
const node = commentMap.get(comment.id);
if (!node) return;
if (comment.parent_id && commentMap.has(comment.parent_id)) {
commentMap.get(comment.parent_id)?.children.push(node);
return;
}
roots.push(node);
});
return roots;
}
export default function ArticleComments({ slug, section }: ArticleCommentsProps) { export default function ArticleComments({ slug, section }: ArticleCommentsProps) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const [replyBody, setReplyBody] = useState("");
const [replyToId, setReplyToId] = useState<number | null>(null);
const [magicEmail, setMagicEmail] = useState(""); const [magicEmail, setMagicEmail] = useState("");
const [data, setData] = useState<CommentsResponse>({ const [data, setData] = useState<CommentsResponse>({
auth_configured: false, auth_configured: false,
@ -89,6 +122,7 @@ export default function ArticleComments({ slug, section }: ArticleCommentsProps)
returnToParams.delete("comment_auth"); returnToParams.delete("comment_auth");
const returnToQuery = returnToParams.toString(); const returnToQuery = returnToParams.toString();
const returnTo = `${pathname || "/"}${returnToQuery ? `?${returnToQuery}` : ""}`; const returnTo = `${pathname || "/"}${returnToQuery ? `?${returnToQuery}` : ""}`;
const commentTree = useMemo(() => buildCommentTree(data.comments), [data.comments]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -167,8 +201,9 @@ export default function ArticleComments({ slug, section }: ArticleCommentsProps)
const googleLoginHref = `/api/public/auth/google/start?return_to=${encodeURIComponent(returnTo)}`; const googleLoginHref = `/api/public/auth/google/start?return_to=${encodeURIComponent(returnTo)}`;
const handleSubmit = async () => { const handleSubmit = async (parentId: number | null = null) => {
const trimmed = body.trim(); const currentValue = parentId ? replyBody : body;
const trimmed = currentValue.trim();
if (trimmed.length < 3) { if (trimmed.length < 3) {
setFeedback("Kommentaren må være minst 3 tegn."); setFeedback("Kommentaren må være minst 3 tegn.");
return; return;
@ -184,7 +219,7 @@ export default function ArticleComments({ slug, section }: ArticleCommentsProps)
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
credentials: "include", credentials: "include",
body: JSON.stringify({ body: trimmed }), body: JSON.stringify({ body: trimmed, parent_id: parentId }),
}); });
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
@ -192,7 +227,12 @@ export default function ArticleComments({ slug, section }: ArticleCommentsProps)
throw new Error(result.detail || "Kunne ikke lagre kommentaren."); throw new Error(result.detail || "Kunne ikke lagre kommentaren.");
} }
if (parentId) {
setReplyBody("");
setReplyToId(null);
} else {
setBody(""); setBody("");
}
setFeedback(result.detail || "Kommentaren er lagret."); setFeedback(result.detail || "Kommentaren er lagret.");
setData(await fetchCommentsPayload(slug, section)); setData(await fetchCommentsPayload(slug, section));
} catch (error) { } catch (error) {
@ -251,6 +291,90 @@ export default function ArticleComments({ slug, section }: ArticleCommentsProps)
setData(await fetchCommentsPayload(slug, section)); setData(await fetchCommentsPayload(slug, section));
}; };
const renderComment = (comment: CommentNode, depth = 0) => (
<article
key={comment.id}
className={`rounded-[1.5rem] border px-4 py-4 sm:px-5 ${
comment.status === "pending"
? "border-amber-200 bg-amber-50"
: "border-[#112015]/8 bg-white"
}`}
style={depth > 0 ? { marginLeft: `${Math.min(depth, 3) * 1.25}rem` } : undefined}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-black text-[#112015]">
{comment.author?.display_name || "TeeOff-leser"}
</p>
<p className="mt-1 text-[11px] font-bold uppercase tracking-[0.16em] text-[#6A766C]">
{formatCommentDate(comment.created_at)}
</p>
</div>
<div className="flex items-center gap-2">
{data.viewer ? (
<button
type="button"
onClick={() => {
const isClosing = replyToId === comment.id;
setReplyToId(isClosing ? null : comment.id);
setReplyBody(
isClosing ? "" : `@${comment.author?.display_name || "TeeOff-leser"} `
);
}}
className="btn btn-sm btn-secondary"
>
Svar
</button>
) : null}
{comment.status === "pending" ? (
<span className="rounded-full bg-amber-100 px-3 py-2 text-[10px] font-black uppercase tracking-[0.16em] text-amber-800">
Venter kontroll
</span>
) : null}
</div>
</div>
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-[#334238]">{comment.body}</p>
{data.viewer && replyToId === comment.id ? (
<div className="mt-4 rounded-[1.2rem] border border-[#112015]/8 bg-[#F7F9F2] p-4">
<textarea
rows={4}
value={replyBody}
onChange={(event) => setReplyBody(event.target.value)}
placeholder="Skriv svaret ditt her..."
className="w-full rounded-[1.1rem] border border-[#112015]/10 bg-white px-4 py-3 text-base text-[#112015] outline-none focus:border-[#8BC34A]"
/>
<div className="mt-3 flex flex-wrap gap-3">
<button
type="button"
onClick={() => handleSubmit(comment.id)}
disabled={isSubmitting}
className="btn btn-md btn-primary disabled:opacity-50"
>
{isSubmitting ? "Publiserer..." : "Publiser svar"}
</button>
<button
type="button"
onClick={() => {
setReplyToId(null);
setReplyBody("");
}}
className="btn btn-md btn-secondary"
>
Avbryt
</button>
</div>
</div>
) : null}
{comment.children.length ? (
<div className="mt-4 space-y-4">
{comment.children.map((child) => renderComment(child, depth + 1))}
</div>
) : null}
</article>
);
return ( return (
<section className="surface-card rounded-[2rem] p-6 sm:p-8"> <section className="surface-card rounded-[2rem] p-6 sm:p-8">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
@ -296,7 +420,7 @@ export default function ArticleComments({ slug, section }: ArticleCommentsProps)
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<button <button
type="button" type="button"
onClick={handleSubmit} onClick={() => handleSubmit()}
disabled={isSubmitting} disabled={isSubmitting}
className="btn btn-md btn-primary disabled:opacity-50" className="btn btn-md btn-primary disabled:opacity-50"
> >
@ -368,33 +492,7 @@ export default function ArticleComments({ slug, section }: ArticleCommentsProps)
</div> </div>
) : null} ) : null}
{data.comments.map((comment) => ( {commentTree.map((comment) => renderComment(comment))}
<article
key={comment.id}
className={`rounded-[1.5rem] border px-4 py-4 sm:px-5 ${
comment.status === "pending"
? "border-amber-200 bg-amber-50"
: "border-[#112015]/8 bg-white"
}`}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-black text-[#112015]">
{comment.author?.display_name || "TeeOff-leser"}
</p>
<p className="mt-1 text-[11px] font-bold uppercase tracking-[0.16em] text-[#6A766C]">
{formatCommentDate(comment.created_at)}
</p>
</div>
{comment.status === "pending" ? (
<span className="rounded-full bg-amber-100 px-3 py-2 text-[10px] font-black uppercase tracking-[0.16em] text-amber-800">
Venter kontroll
</span>
) : null}
</div>
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-[#334238]">{comment.body}</p>
</article>
))}
</div> </div>
</section> </section>
); );