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