Før endringer på forsiden
This commit is contained in:
parent
d9c747e83d
commit
3d3470267c
13 changed files with 669 additions and 63 deletions
|
|
@ -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
|
||||
|
|
|
|||
376
backend/main.py
376
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"<img\b[^>]*\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"}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
1
frontend/public/0be05afd880c43c686473b336cab9a87
Normal file
1
frontend/public/0be05afd880c43c686473b336cab9a87
Normal file
|
|
@ -0,0 +1 @@
|
|||
0be05afd880c43c686473b336cab9a87
|
||||
1
frontend/public/0be05afd880c43c686473b336cab9a87.txt
Normal file
1
frontend/public/0be05afd880c43c686473b336cab9a87.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
0be05afd880c43c686473b336cab9a87
|
||||
|
|
@ -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({
|
|||
</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]">
|
||||
<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} på ${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">
|
||||
{facility.website_url && (
|
||||
<a href={facility.website_url} target="_blank" rel="noreferrer" className={actionIconClassName} aria-label={`Besøk nettsiden til ${facility.name}`}>
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<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>
|
||||
<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}>
|
||||
<Icon children={ICONS.phone} /> <span className={sidebarLinkTextClass}>{facility.phone || 'Ikke oppgitt'}</span>
|
||||
<Icon children={ICONS.phone} /> <span className={sidebarLinkTextClass}>{facility.phone}</span>
|
||||
</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}>
|
||||
<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>
|
||||
) : (
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
|
|
@ -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">
|
||||
<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">
|
||||
{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>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
|
|
@ -63,8 +79,8 @@ export default async function GolfCoursesIndexPage() {
|
|||
initialFacilities={safeData}
|
||||
variant="catalog"
|
||||
eyebrow="Golfbaner"
|
||||
title="Alle golfbaner samlet på ett sted"
|
||||
intro="Bruk område, banestatus og fasiliteter for å snevre inn oversikten. Her får katalogen være arbeidsflate, ikke hero."
|
||||
title="Golfbaner i Norge"
|
||||
intro="Filtrer norske golfbaner etter område, banestatus, antall hull og fasiliteter, og gå videre til hver baneprofil."
|
||||
/>
|
||||
</main>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,16 @@ type CollectionPageInput = {
|
|||
path: string;
|
||||
};
|
||||
|
||||
type ItemListInput = {
|
||||
name: string;
|
||||
path: string;
|
||||
items: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
description?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
type SocialLink = {
|
||||
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) {
|
||||
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(
|
||||
(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 {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "GolfCourse",
|
||||
"@type": ["GolfCourse", "SportsActivityLocation"],
|
||||
"@id": buildAbsoluteUrl(`/golfbaner/${facility.slug}#golfcourse`),
|
||||
name: facility.name,
|
||||
description:
|
||||
trimDescription(facility.description) ||
|
||||
`${facility.name} er en golfbane på TeeOff med oppdatert banestatus og praktisk klubbinfo.`,
|
||||
url: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
|
||||
mainEntityOfPage: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
|
||||
image: resolveImageUrl(facility.image_url),
|
||||
telephone: facility.phone || undefined,
|
||||
email: facility.email || undefined,
|
||||
sport: "Golf",
|
||||
hasMap,
|
||||
address:
|
||||
facility.address || facility.city || facility.county
|
||||
? {
|
||||
|
|
@ -207,6 +252,7 @@ export function createFacilityJsonLd(facility: FacilitySeoRecord) {
|
|||
longitude: facility.lng,
|
||||
}
|
||||
: undefined,
|
||||
amenityFeature: amenityFeature.length > 0 ? amenityFeature : undefined,
|
||||
sameAs: sameAs.length > 0 ? sameAs : undefined,
|
||||
isPartOf: {
|
||||
"@id": WEBSITE_ID,
|
||||
|
|
@ -356,6 +402,24 @@ function parseComparableDate(raw: string) {
|
|||
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 {
|
||||
if (!value) return fallback;
|
||||
if (typeof value === "object") return value as T;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { API_URL } from "@/config/constants";
|
|||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createCollectionPageJsonLd,
|
||||
createItemListJsonLd,
|
||||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
|
||||
|
|
@ -41,7 +42,7 @@ export async function generateMetadata({
|
|||
|
||||
return createPageMetadata({
|
||||
title: place.title,
|
||||
description: place.intro,
|
||||
description: `${place.intro} TeeOff samler golfbaner i ${place.label} med oppdatert banestatus og baneprofiler.`,
|
||||
path: `/sted/${slug}`,
|
||||
});
|
||||
}
|
||||
|
|
@ -79,6 +80,17 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
|
|||
description: place.intro,
|
||||
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([
|
||||
{ name: "Hjem", path: "/" },
|
||||
{ name: "Steder", path: "/sted/norge" },
|
||||
|
|
@ -91,6 +103,10 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
type ArticleCommentsProps = {
|
||||
|
|
@ -18,6 +18,7 @@ type Viewer = {
|
|||
|
||||
type CommentItem = {
|
||||
id: number;
|
||||
parent_id?: number | null;
|
||||
body: string;
|
||||
status: string;
|
||||
created_at?: string | null;
|
||||
|
|
@ -40,6 +41,10 @@ type CommentsResponse = {
|
|||
comments: CommentItem[];
|
||||
};
|
||||
|
||||
type CommentNode = CommentItem & {
|
||||
children: CommentNode[];
|
||||
};
|
||||
|
||||
function formatCommentDate(value?: string | null) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
|
|
@ -63,11 +68,39 @@ async function fetchCommentsPayload(slug: string, section: "banebesok" | "mening
|
|||
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) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [body, setBody] = useState("");
|
||||
const [replyBody, setReplyBody] = useState("");
|
||||
const [replyToId, setReplyToId] = useState<number | null>(null);
|
||||
const [magicEmail, setMagicEmail] = useState("");
|
||||
const [data, setData] = useState<CommentsResponse>({
|
||||
auth_configured: false,
|
||||
|
|
@ -89,6 +122,7 @@ export default function ArticleComments({ slug, section }: ArticleCommentsProps)
|
|||
returnToParams.delete("comment_auth");
|
||||
const returnToQuery = returnToParams.toString();
|
||||
const returnTo = `${pathname || "/"}${returnToQuery ? `?${returnToQuery}` : ""}`;
|
||||
const commentTree = useMemo(() => buildCommentTree(data.comments), [data.comments]);
|
||||
|
||||
useEffect(() => {
|
||||
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 handleSubmit = async () => {
|
||||
const trimmed = body.trim();
|
||||
const handleSubmit = async (parentId: number | null = null) => {
|
||||
const currentValue = parentId ? replyBody : body;
|
||||
const trimmed = currentValue.trim();
|
||||
if (trimmed.length < 3) {
|
||||
setFeedback("Kommentaren må være minst 3 tegn.");
|
||||
return;
|
||||
|
|
@ -184,7 +219,7 @@ export default function ArticleComments({ slug, section }: ArticleCommentsProps)
|
|||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ body: trimmed }),
|
||||
body: JSON.stringify({ body: trimmed, parent_id: parentId }),
|
||||
});
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
setReplyBody("");
|
||||
setReplyToId(null);
|
||||
} else {
|
||||
setBody("");
|
||||
}
|
||||
setFeedback(result.detail || "Kommentaren er lagret.");
|
||||
setData(await fetchCommentsPayload(slug, section));
|
||||
} catch (error) {
|
||||
|
|
@ -251,6 +291,90 @@ export default function ArticleComments({ slug, section }: ArticleCommentsProps)
|
|||
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 på 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 (
|
||||
<section className="surface-card rounded-[2rem] p-6 sm:p-8">
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={isSubmitting}
|
||||
className="btn btn-md btn-primary disabled:opacity-50"
|
||||
>
|
||||
|
|
@ -368,33 +492,7 @@ export default function ArticleComments({ slug, section }: ArticleCommentsProps)
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{data.comments.map((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 på kontroll
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-[#334238]">{comment.body}</p>
|
||||
</article>
|
||||
))}
|
||||
{commentTree.map((comment) => renderComment(comment))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue