Endringer i Frontend med Codex

This commit is contained in:
Erol 2026-04-12 10:11:23 +02:00
parent b8adf6b365
commit 2630e9a9fa
16 changed files with 1943 additions and 459 deletions

View file

@ -15,6 +15,7 @@ from bs4 import BeautifulSoup
from playwright.async_api import async_playwright
import google.generativeai as genai
from dotenv import load_dotenv
from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json
load_dotenv()
@ -48,7 +49,7 @@ async def fetch_page_text(url: str, browser) -> str:
print(f" ❌ Feil ved lasting av {url}: {e}")
return ""
def analyze_greenfee_with_gemini(text: str, club_name: str) -> dict:
def analyze_greenfee_with_gemini(text: str, club_name: str) -> dict | None:
print(f" 🧠 Sender {len(text)} tegn til Gemini for greenfee-analyse...")
prompt = f"""
@ -92,25 +93,20 @@ Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur:
try:
response = model.generate_content(prompt)
raw_response = response.text.strip()
if raw_response.startswith("```json"):
raw_response = raw_response[7:]
if raw_response.endswith("```"):
raw_response = raw_response[:-3]
return json.loads(raw_response.strip())
parsed = parse_llm_json(response.text)
return parsed if isinstance(parsed, dict) else None
except Exception as e:
print(f" ❌ AI-analyse feilet: {e}")
return None
async def run_greenfee_scraper(facility_ids=None):
async def run_greenfee_scraper(facility_ids=None, progress_callback: ProgressCallback | None = None):
print("🚀 Starter Greenfee-skraperen...")
conn = await asyncpg.connect(DB_URL)
facilities = []
analyzed_count = 0
saved_count = 0
skipped_count = 0
failed_count = 0
try:
query = "SELECT id, name, greenfee_url FROM facilities WHERE greenfee_url IS NOT NULL AND greenfee_url != ''"
@ -118,51 +114,151 @@ async def run_greenfee_scraper(facility_ids=None):
query += f" AND id IN ({','.join(map(str, facility_ids))})"
facilities = await conn.fetch(query)
print(f"📋 Fant {len(facilities)} anlegg å skrape.")
total_facilities = len(facilities)
print(f"📋 Fant {total_facilities} anlegg å skrape.")
await emit_progress(
progress_callback,
progress_total=total_facilities,
progress_completed=0,
progress_ok=0,
progress_failed=0,
progress_skipped=0,
event=make_progress_event(
facility_id=None,
facility_name="Greenfee",
outcome="info",
message=f"Starter greenfeeskraping for {total_facilities} anlegg.",
processed=0,
total=total_facilities,
),
)
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
for facility in facilities:
for index, facility in enumerate(facilities, start=1):
fac_id = facility['id']
name = facility['name']
urls_raw = facility['greenfee_url']
print(f"\n▶️ Behandler Greenfee for: {name} (ID: {fac_id})")
await emit_progress(
progress_callback,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="info",
message="Starter henting av greenfeesider.",
processed=index - 1,
total=total_facilities,
),
)
urls = [u.strip() for u in urls_raw.split(',')]
combined_text = ""
for idx, url in enumerate(urls, 1):
page_text = await fetch_page_text(url, browser)
if page_text:
combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
if len(combined_text) < 50:
print(" ⚠️ Fant for lite tekst, hopper over.")
skipped_count += 1
continue
try:
for idx, url in enumerate(urls, 1):
page_text = await fetch_page_text(url, browser)
if page_text:
combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
draft_data = analyze_greenfee_with_gemini(combined_text[:25000], name)
if not draft_data:
skipped_count += 1
continue
if len(combined_text) < 50:
print(" ⚠️ Fant for lite tekst, hopper over.")
skipped_count += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=saved_count,
progress_failed=failed_count,
progress_skipped=skipped_count,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="warning",
message="Hoppet over fordi det ble funnet for lite tekst på greenfeesidene.",
processed=index,
total=total_facilities,
),
)
continue
draft_data = analyze_greenfee_with_gemini(combined_text[:25000], name)
if not draft_data:
failed_count += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=saved_count,
progress_failed=failed_count,
progress_skipped=skipped_count,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="error",
message="AI-analysen ga ikke et gyldig greenfeeutkast.",
processed=index,
total=total_facilities,
),
)
continue
analyzed_count += 1
analyzed_count += 1
funnet_priser = len(draft_data.get('foreslatt_greenfee', []))
funnet_klubber = len(draft_data.get('foreslatt_avtaleklubber', []))
print(f" ✅ AI fant {funnet_priser} greenfee-varianter og {funnet_klubber} avtaleklubber.")
funnet_priser = len(draft_data.get('foreslatt_greenfee', []))
funnet_klubber = len(draft_data.get('foreslatt_avtaleklubber', []))
print(f" ✅ AI fant {funnet_priser} greenfee-varianter og {funnet_klubber} avtaleklubber.")
await conn.execute("""
UPDATE facilities
SET greenfee_draft = $1::jsonb
WHERE id = $2
""", json.dumps(draft_data), fac_id)
print(" 💾 Greenfee-utkast lagret i databasen!")
saved_count += 1
await conn.execute("""
UPDATE facilities
SET greenfee_draft = $1::jsonb
WHERE id = $2
""", json.dumps(draft_data), fac_id)
print(" 💾 Greenfee-utkast lagret i databasen!")
saved_count += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=saved_count,
progress_failed=failed_count,
progress_skipped=skipped_count,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="success",
message=f"Utkast lagret med {funnet_priser} prisvarianter og {funnet_klubber} avtaleklubber.",
processed=index,
total=total_facilities,
),
)
except Exception as e:
failed_count += 1
print(f" ❌ Uventet feil for {name}: {e}")
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=saved_count,
progress_failed=failed_count,
progress_skipped=skipped_count,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="error",
message=f"Feilet under behandling: {str(e).splitlines()[0]}",
processed=index,
total=total_facilities,
),
)
await browser.close()
@ -175,6 +271,7 @@ async def run_greenfee_scraper(facility_ids=None):
"analyzed_facilities": analyzed_count,
"saved_drafts": saved_count,
"skipped_facilities": skipped_count,
"failed_facilities": failed_count,
}
if __name__ == "__main__":

View file

@ -4,20 +4,21 @@ from scrape_greenfee import run_greenfee_scraper
from scrape_membership import run_scraper as run_membership_scraper
from scrape_status import run_daily_scraping
from scrape_vtg import run_vtg_scraper
from scrape_utils import ProgressCallback
async def run_scrape_job(job: dict[str, Any]) -> dict[str, Any]:
async def run_scrape_job(job: dict[str, Any], progress_callback: ProgressCallback | None = None) -> dict[str, Any]:
job_type = job["job_type"]
facility_ids = job.get("facility_ids") or []
if job_type == "banestatus":
result = await run_daily_scraping(facility_ids)
result = await run_daily_scraping(facility_ids, progress_callback=progress_callback)
elif job_type == "medlemskap":
result = await run_membership_scraper(facility_ids)
result = await run_membership_scraper(facility_ids, progress_callback=progress_callback)
elif job_type == "greenfee":
result = await run_greenfee_scraper(facility_ids)
result = await run_greenfee_scraper(facility_ids, progress_callback=progress_callback)
elif job_type == "vtg":
result = await run_vtg_scraper(facility_ids)
result = await run_vtg_scraper(facility_ids, progress_callback=progress_callback)
else:
raise ValueError(f"Ukjent scrape-jobbtype: {job_type}")

View file

@ -5,6 +5,7 @@ from typing import Any, Iterable
SCRAPE_JOB_TYPES = ("banestatus", "medlemskap", "greenfee", "vtg")
SCRAPE_JOB_STATUSES = ("pending", "running", "completed", "failed")
DEFAULT_MAX_ATTEMPTS = 3
DEFAULT_RECENT_EVENTS_LIMIT = 12
def normalize_facility_ids(facility_ids: Iterable[int]) -> list[int]:
@ -37,6 +38,22 @@ def _parse_json(value: Any, fallback: Any) -> Any:
return value
def _normalize_progress_event(value: Any) -> dict[str, Any] | None:
parsed = _parse_json(value, None)
if not isinstance(parsed, dict):
return None
return {
"timestamp": str(parsed.get("timestamp") or ""),
"facility_id": int(parsed["facility_id"]) if str(parsed.get("facility_id") or "").strip().isdigit() else None,
"facility_name": str(parsed.get("facility_name") or ""),
"outcome": str(parsed.get("outcome") or "info"),
"message": str(parsed.get("message") or ""),
"processed": int(parsed.get("processed") or 0),
"total": int(parsed.get("total") or 0),
}
def format_scrape_job_row(row: Any) -> dict[str, Any] | None:
if row is None:
return None
@ -54,6 +71,18 @@ def format_scrape_job_row(row: Any) -> dict[str, Any] | None:
data["result_summary"] = result_summary if isinstance(result_summary, dict) else {}
overlapping_facility_ids = _parse_json(data.get("overlapping_facility_ids"), [])
data["overlapping_facility_ids"] = _normalize_int_list(overlapping_facility_ids if isinstance(overlapping_facility_ids, list) else [])
recent_events = _parse_json(data.get("recent_events"), [])
if isinstance(recent_events, list):
data["recent_events"] = [event for event in (_normalize_progress_event(item) for item in recent_events) if event]
else:
data["recent_events"] = []
data["progress_total"] = int(data.get("progress_total") or 0)
data["progress_completed"] = int(data.get("progress_completed") or 0)
data["progress_ok"] = int(data.get("progress_ok") or 0)
data["progress_failed"] = int(data.get("progress_failed") or 0)
data["progress_skipped"] = int(data.get("progress_skipped") or 0)
data["current_facility_id"] = int(data["current_facility_id"]) if str(data.get("current_facility_id") or "").strip().isdigit() else None
data["current_facility_name"] = str(data.get("current_facility_name") or "")
data["retryable"] = bool(data.get("retryable", False))
data["attempt_count"] = int(data.get("attempt_count") or 0)
data["max_attempts"] = int(data.get("max_attempts") or DEFAULT_MAX_ATTEMPTS)
@ -115,6 +144,14 @@ async def ensure_scrape_jobs_table(conn) -> None:
error_code TEXT,
retryable BOOLEAN NOT NULL DEFAULT FALSE,
result_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
recent_events JSONB NOT NULL DEFAULT '[]'::jsonb,
progress_total INTEGER NOT NULL DEFAULT 0,
progress_completed INTEGER NOT NULL DEFAULT 0,
progress_ok INTEGER NOT NULL DEFAULT 0,
progress_failed INTEGER NOT NULL DEFAULT 0,
progress_skipped INTEGER NOT NULL DEFAULT 0,
current_facility_id INTEGER,
current_facility_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
@ -128,6 +165,14 @@ async def ensure_scrape_jobs_table(conn) -> None:
await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS max_attempts INTEGER NOT NULL DEFAULT 3")
await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS error_code TEXT")
await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS retryable BOOLEAN NOT NULL DEFAULT FALSE")
await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS recent_events JSONB NOT NULL DEFAULT '[]'::jsonb")
await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS progress_total INTEGER NOT NULL DEFAULT 0")
await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS progress_completed INTEGER NOT NULL DEFAULT 0")
await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS progress_ok INTEGER NOT NULL DEFAULT 0")
await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS progress_failed INTEGER NOT NULL DEFAULT 0")
await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS progress_skipped INTEGER NOT NULL DEFAULT 0")
await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS current_facility_id INTEGER")
await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS current_facility_name TEXT")
await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS next_retry_at TIMESTAMPTZ")
await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS last_error_at TIMESTAMPTZ")
await conn.execute(
@ -307,6 +352,13 @@ async def claim_next_scrape_job(pool, worker_name: str, stale_heartbeat_seconds:
updated_at = NOW(),
last_heartbeat_at = NOW(),
error_message = NULL,
progress_completed = 0,
progress_ok = 0,
progress_failed = 0,
progress_skipped = 0,
current_facility_id = NULL,
current_facility_name = NULL,
recent_events = '[]'::jsonb,
next_retry_at = NULL
FROM next_job
WHERE job.id = next_job.id
@ -343,6 +395,8 @@ async def complete_scrape_job(pool, job_id: int, result_summary: dict[str, Any]
error_message = NULL,
error_code = NULL,
retryable = FALSE,
current_facility_id = NULL,
current_facility_name = NULL,
next_retry_at = NULL,
result_summary = $2::jsonb
WHERE id = $1
@ -382,6 +436,8 @@ async def fail_scrape_job(
retryable = $4,
result_summary = $5::jsonb,
last_error_at = NOW(),
current_facility_id = NULL,
current_facility_name = NULL,
worker_name = CASE
WHEN $4 AND attempt_count < max_attempts THEN NULL
ELSE worker_name
@ -401,3 +457,69 @@ async def fail_scrape_job(
max(0, retry_delay_seconds),
)
return format_scrape_job_row(row)
async def update_scrape_job_progress(
pool,
job_id: int,
*,
progress_total: int | None = None,
progress_completed: int | None = None,
progress_ok: int | None = None,
progress_failed: int | None = None,
progress_skipped: int | None = None,
current_facility_id: int | None = None,
current_facility_name: str | None = None,
event: dict[str, Any] | None = None,
clear_current: bool = False,
) -> dict[str, Any] | None:
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM scrape_jobs WHERE id = $1", job_id)
current = format_scrape_job_row(row)
if not current:
return None
events = list(current.get("recent_events") or [])
normalized_event = _normalize_progress_event(event) if event else None
if normalized_event:
normalized_event["timestamp"] = datetime.now().astimezone().isoformat()
events.append(normalized_event)
events = events[-DEFAULT_RECENT_EVENTS_LIMIT:]
next_current_facility_id = current.get("current_facility_id")
next_current_facility_name = current.get("current_facility_name") or ""
if clear_current:
next_current_facility_id = None
next_current_facility_name = ""
else:
if current_facility_id is not None:
next_current_facility_id = current_facility_id
if current_facility_name is not None:
next_current_facility_name = current_facility_name
updated_row = await conn.fetchrow(
"""
UPDATE scrape_jobs
SET progress_total = $2,
progress_completed = $3,
progress_ok = $4,
progress_failed = $5,
progress_skipped = $6,
current_facility_id = $7,
current_facility_name = $8,
recent_events = $9::jsonb,
updated_at = NOW()
WHERE id = $1
RETURNING *
""",
job_id,
progress_total if progress_total is not None else current.get("progress_total", 0),
progress_completed if progress_completed is not None else current.get("progress_completed", 0),
progress_ok if progress_ok is not None else current.get("progress_ok", 0),
progress_failed if progress_failed is not None else current.get("progress_failed", 0),
progress_skipped if progress_skipped is not None else current.get("progress_skipped", 0),
next_current_facility_id,
next_current_facility_name,
json.dumps(events),
)
return format_scrape_job_row(updated_row)

View file

@ -16,6 +16,7 @@ from bs4 import BeautifulSoup
from playwright.async_api import async_playwright
import google.generativeai as genai
from dotenv import load_dotenv
from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json
load_dotenv()
@ -51,7 +52,7 @@ async def fetch_page_text(url: str, browser) -> str:
print(f" ❌ Feil ved lasting av {url}: {e}")
return ""
def analyze_with_gemini(text: str, club_name: str) -> dict:
def analyze_with_gemini(text: str, club_name: str) -> dict | None:
"""Sender den kombinerte teksten til Gemini for å trekke ut og evt. summere priser."""
print(f" 🧠 Sender {len(text)} tegn til Gemini for analyse...")
@ -87,25 +88,20 @@ Merk: Prisene SKAL være tall (integer), ikke tekst. Sett til null hvis du ikke
try:
response = model.generate_content(prompt)
raw_response = response.text.strip()
if raw_response.startswith("```json"):
raw_response = raw_response[7:]
if raw_response.endswith("```"):
raw_response = raw_response[:-3]
return json.loads(raw_response.strip())
parsed = parse_llm_json(response.text)
return parsed if isinstance(parsed, dict) else None
except Exception as e:
print(f" ❌ AI-analyse feilet: {e}")
return None
async def run_scraper(facility_ids=None):
async def run_scraper(facility_ids=None, progress_callback: ProgressCallback | None = None):
print("🚀 Starter Medlemskaps-skraperen (Støtter multi-URL)...")
conn = await asyncpg.connect(DB_URL)
facilities = []
analyzed_count = 0
saved_count = 0
skipped_count = 0
failed_count = 0
try:
query = "SELECT id, name, medlemskap_url FROM facilities WHERE medlemskap_url IS NOT NULL AND medlemskap_url != ''"
@ -113,51 +109,153 @@ async def run_scraper(facility_ids=None):
query += f" AND id IN ({','.join(map(str, facility_ids))})"
facilities = await conn.fetch(query)
print(f"📋 Fant {len(facilities)} anlegg å skrape.")
total_facilities = len(facilities)
print(f"📋 Fant {total_facilities} anlegg å skrape.")
await emit_progress(
progress_callback,
progress_total=total_facilities,
progress_completed=0,
progress_ok=0,
progress_failed=0,
progress_skipped=0,
event=make_progress_event(
facility_id=None,
facility_name="Medlemskap",
outcome="info",
message=f"Starter medlemskapsskraping for {total_facilities} anlegg.",
processed=0,
total=total_facilities,
),
)
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
for facility in facilities:
for index, facility in enumerate(facilities, start=1):
fac_id = facility['id']
name = facility['name']
urls_raw = facility['medlemskap_url']
print(f"\n▶️ Behandler: {name} (ID: {fac_id})")
await emit_progress(
progress_callback,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="info",
message="Starter henting av medlemskapssider.",
processed=index - 1,
total=total_facilities,
),
)
# Sjekker om det er flere URL-er adskilt med komma
urls = [u.strip() for u in urls_raw.split(',')]
combined_text = ""
for idx, url in enumerate(urls, 1):
page_text = await fetch_page_text(url, browser)
if page_text:
combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
if len(combined_text) < 50:
print(" ⚠️ Fant for lite tekst, hopper over.")
skipped_count += 1
continue
try:
for idx, url in enumerate(urls, 1):
page_text = await fetch_page_text(url, browser)
if page_text:
combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
# Kutter teksten for å ikke overbelaste Gemini (ca 25000 tegn maks)
draft_data = analyze_with_gemini(combined_text[:25000], name)
if not draft_data:
skipped_count += 1
continue
if len(combined_text) < 50:
print(" ⚠️ Fant for lite tekst, hopper over.")
skipped_count += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=saved_count,
progress_failed=failed_count,
progress_skipped=skipped_count,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="warning",
message="Hoppet over fordi det ble funnet for lite tekst på medlemskapssidene.",
processed=index,
total=total_facilities,
),
)
continue
draft_data = analyze_with_gemini(combined_text[:25000], name)
if not draft_data:
failed_count += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=saved_count,
progress_failed=failed_count,
progress_skipped=skipped_count,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="error",
message="AI-analysen ga ikke et gyldig medlemskapsutkast.",
processed=index,
total=total_facilities,
),
)
continue
analyzed_count += 1
analyzed_count += 1
print(f" ✅ AI foreslår: Standard: {draft_data.get('foreslatt_standard_pris')} | Rimeligste: {draft_data.get('foreslatt_rimeligste_pris')}")
print(f" ✅ AI foreslår: Standard: {draft_data.get('foreslatt_standard_pris')} | Rimeligste: {draft_data.get('foreslatt_rimeligste_pris')}")
await conn.execute("""
UPDATE facilities
SET membership_draft = $1::jsonb
WHERE id = $2
""", json.dumps(draft_data), fac_id)
print(" 💾 Utkast lagret i databasen!")
saved_count += 1
await conn.execute("""
UPDATE facilities
SET membership_draft = $1::jsonb
WHERE id = $2
""", json.dumps(draft_data), fac_id)
print(" 💾 Utkast lagret i databasen!")
saved_count += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=saved_count,
progress_failed=failed_count,
progress_skipped=skipped_count,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="success",
message=(
f"Utkast lagret. Standard: {draft_data.get('foreslatt_standard_pris') or 'ukjent'} "
f"| Rimeligste: {draft_data.get('foreslatt_rimeligste_pris') or 'ukjent'}"
),
processed=index,
total=total_facilities,
),
)
except Exception as e:
failed_count += 1
print(f" ❌ Uventet feil for {name}: {e}")
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=saved_count,
progress_failed=failed_count,
progress_skipped=skipped_count,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="error",
message=f"Feilet under behandling: {str(e).splitlines()[0]}",
processed=index,
total=total_facilities,
),
)
await browser.close()
@ -170,6 +268,7 @@ async def run_scraper(facility_ids=None):
"analyzed_facilities": analyzed_count,
"saved_drafts": saved_count,
"skipped_facilities": skipped_count,
"failed_facilities": failed_count,
}
if __name__ == "__main__":

View file

@ -15,6 +15,7 @@ except ImportError:
from google import genai
from dotenv import load_dotenv
from scrape_utils import ProgressCallback, emit_progress, make_progress_event
load_dotenv()
@ -147,7 +148,7 @@ def send_report(changes, warnings, successes):
# ==========================================
# HOVEDMOTOR
# ==========================================
async def run_daily_scraping(facility_ids=None):
async def run_daily_scraping(facility_ids=None, progress_callback: ProgressCallback | None = None):
print(f"🚀 Starter sjekk {datetime.now().strftime('%H:%M:%S')}...")
conn = await asyncpg.connect(DB_URL)
@ -171,20 +172,77 @@ async def run_daily_scraping(facility_ids=None):
"updated_courses": 0,
"warnings": 0,
"successes": 0,
"failed_facilities": 0,
"skipped_facilities": 0,
}
changes, warnings, successes = [], [], []
total_facilities = len(facilities)
ok_facilities = 0
failed_facilities = 0
skipped_facilities = 0
await emit_progress(
progress_callback,
progress_total=total_facilities,
progress_completed=0,
progress_ok=0,
progress_failed=0,
progress_skipped=0,
event=make_progress_event(
facility_id=None,
facility_name="Banestatus",
outcome="info",
message=f"Starter banestatusskraping for {total_facilities} anlegg.",
processed=0,
total=total_facilities,
),
)
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
for f in facilities:
for index, f in enumerate(facilities, start=1):
method = f.get('scrape_method') or 'css_selector'
facility_id = f['id']
facility_name = f['name']
await emit_progress(
progress_callback,
current_facility_id=facility_id,
current_facility_name=facility_name,
event=make_progress_event(
facility_id=facility_id,
facility_name=facility_name,
outcome="info",
message=f"Starter sjekk med metode {method}.",
processed=index - 1,
total=total_facilities,
),
)
if method == 'manual':
successes.append(f"⏸️ {f['name']}: Hoppet over (Manuell overstyring)")
print(f" ⏸️ Hopper over skraping av {f['name']} (Satt til Manuell)")
skipped_facilities += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=ok_facilities,
progress_failed=failed_facilities,
progress_skipped=skipped_facilities,
current_facility_id=facility_id,
current_facility_name=facility_name,
event=make_progress_event(
facility_id=facility_id,
facility_name=facility_name,
outcome="warning",
message="Hoppet over fordi anlegget er satt til manuell overstyring.",
processed=index,
total=total_facilities,
),
)
continue
page = await context.new_page()
@ -202,6 +260,24 @@ async def run_daily_scraping(facility_ids=None):
element = page.locator(f['scrape_status_selector']).first
if await element.count() == 0:
warnings.append(f"{f['name']}: Fant ikke CSS-elementet '{f['scrape_status_selector']}'")
failed_facilities += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=ok_facilities,
progress_failed=failed_facilities,
progress_skipped=skipped_facilities,
current_facility_id=facility_id,
current_facility_name=facility_name,
event=make_progress_event(
facility_id=facility_id,
facility_name=facility_name,
outcome="error",
message=f"Fant ikke CSS-elementet {f['scrape_status_selector']}.",
processed=index,
total=total_facilities,
),
)
continue
full_text = await element.inner_text()
@ -210,6 +286,24 @@ async def run_daily_scraping(facility_ids=None):
element = frame.locator(f['scrape_status_selector']).first
if await element.count() == 0:
warnings.append(f"{f['name']}: Fant ikke elementet '{f['scrape_status_selector']}' i iframen")
failed_facilities += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=ok_facilities,
progress_failed=failed_facilities,
progress_skipped=skipped_facilities,
current_facility_id=facility_id,
current_facility_name=facility_name,
event=make_progress_event(
facility_id=facility_id,
facility_name=facility_name,
outcome="error",
message=f"Fant ikke elementet {f['scrape_status_selector']} i Golfbox-iframe.",
processed=index,
total=total_facilities,
),
)
continue
full_text = await element.inner_text()
@ -217,12 +311,48 @@ async def run_daily_scraping(facility_ids=None):
parts = f['scrape_status_selector'].split('||')
if len(parts) != 2:
warnings.append(f"{f['name']}: Ugyldig selector for click_then_css (mangler ||)")
failed_facilities += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=ok_facilities,
progress_failed=failed_facilities,
progress_skipped=skipped_facilities,
current_facility_id=facility_id,
current_facility_name=facility_name,
event=make_progress_event(
facility_id=facility_id,
facility_name=facility_name,
outcome="error",
message="Ugyldig click_then_css-selector i konfigurasjonen.",
processed=index,
total=total_facilities,
),
)
continue
btn_selector, text_selector = parts
btn = page.locator(btn_selector).first
if await btn.count() == 0:
warnings.append(f"{f['name']}: Fant ikke knappen å klikke på: '{btn_selector}'")
failed_facilities += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=ok_facilities,
progress_failed=failed_facilities,
progress_skipped=skipped_facilities,
current_facility_id=facility_id,
current_facility_name=facility_name,
event=make_progress_event(
facility_id=facility_id,
facility_name=facility_name,
outcome="error",
message=f"Fant ikke knappen {btn_selector} som skulle klikkes.",
processed=index,
total=total_facilities,
),
)
continue
await btn.click(force=True)
@ -231,6 +361,24 @@ async def run_daily_scraping(facility_ids=None):
element = page.locator(text_selector).first
if await element.count() == 0:
warnings.append(f"{f['name']}: Fant ikke tekstboksen '{text_selector}' etter klikk")
failed_facilities += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=ok_facilities,
progress_failed=failed_facilities,
progress_skipped=skipped_facilities,
current_facility_id=facility_id,
current_facility_name=facility_name,
event=make_progress_event(
facility_id=facility_id,
facility_name=facility_name,
outcome="error",
message=f"Fant ikke tekstboksen {text_selector} etter klikk.",
processed=index,
total=total_facilities,
),
)
continue
full_text = await element.inner_text()
@ -259,6 +407,24 @@ async def run_daily_scraping(facility_ids=None):
element = page.locator("body").first
if await element.count() == 0:
warnings.append(f"{f['name']}: Klarte ikke å lese siden for AI-tolkning")
failed_facilities += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=ok_facilities,
progress_failed=failed_facilities,
progress_skipped=skipped_facilities,
current_facility_id=facility_id,
current_facility_name=facility_name,
event=make_progress_event(
facility_id=facility_id,
facility_name=facility_name,
outcome="error",
message="Klarte ikke å lese siden for AI-tolkning.",
processed=index,
total=total_facilities,
),
)
continue
synlig_tekst = await element.inner_text() or ""
@ -271,6 +437,24 @@ async def run_daily_scraping(facility_ids=None):
else:
warnings.append(f"⚠️ {f['name']}: Ukjent skrapemetode i databasen: '{method}'")
failed_facilities += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=ok_facilities,
progress_failed=failed_facilities,
progress_skipped=skipped_facilities,
current_facility_id=facility_id,
current_facility_name=facility_name,
event=make_progress_event(
facility_id=facility_id,
facility_name=facility_name,
outcome="error",
message=f"Ukjent skrapemetode: {method}.",
processed=index,
total=total_facilities,
),
)
continue
await conn.execute("UPDATE facilities SET status_updated_at = CURRENT_DATE WHERE id = $1", f['id'])
@ -278,6 +462,9 @@ async def run_daily_scraping(facility_ids=None):
courses = await conn.fetch("SELECT id, name, status, scrape_keyword FROM courses WHERE facility_id = $1", f['id'])
is_single_course = len(courses) == 1
facility_changed = 0
facility_confirmed = 0
facility_unresolved = 0
for c in courses:
old_status = c['status'] or "ukjent"
@ -293,6 +480,7 @@ async def run_daily_scraping(facility_ids=None):
if new_status == "NOT_FOUND":
warnings.append(f"{f['name']} ({c['name']}): Fant ikke søkeordet '{c['scrape_keyword']}' i teksten.")
facility_unresolved += 1
continue
# --- OPPDATERT LOGIKK (Fikser logg-buggen) ---
@ -300,18 +488,75 @@ async def run_daily_scraping(facility_ids=None):
# Sikkerhetsnettet slår inn: Vi beholder gammel status!
warnings.append(f"⚠️ {f['name']} ({c['name']}): Fant ikke status. Beholder '{old_status.upper()}'.")
print(f" 🟡 KONKLUSJON: Fant ikke status i teksten (Sikkerhetsnett). Beholder gammel status ({old_status.upper()}).")
facility_unresolved += 1
elif new_status != old_status:
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", new_status, c['id'])
changes.append(f"🔹 {f['name']} ({c['name']}): {old_status.upper()}{new_status.upper()}")
print(f" 🟢 KONKLUSJON: Status endret fra {old_status.upper()} til {new_status.upper()}")
facility_changed += 1
else:
successes.append(f"{f['name']} ({c['name']}): {new_status.upper()}")
print(f" ⚪ KONKLUSJON: Ingen endring. Banen er fortsatt {old_status.upper()}")
facility_confirmed += 1
# ---------------------------------------------
ok_facilities += 1
if facility_changed > 0:
facility_outcome = "success"
facility_message = (
f"{facility_changed} baner oppdatert, {facility_confirmed} bekreftet"
+ (f", {facility_unresolved} beholdt som før" if facility_unresolved > 0 else "")
+ "."
)
elif facility_unresolved > 0:
facility_outcome = "warning"
facility_message = (
f"Ingen statusendring. {facility_confirmed} baner bekreftet og "
f"{facility_unresolved} beholdt som før."
)
else:
facility_outcome = "success"
facility_message = f"Ingen endring. {facility_confirmed} baner bekreftet."
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=ok_facilities,
progress_failed=failed_facilities,
progress_skipped=skipped_facilities,
current_facility_id=facility_id,
current_facility_name=facility_name,
event=make_progress_event(
facility_id=facility_id,
facility_name=facility_name,
outcome=facility_outcome,
message=facility_message,
processed=index,
total=total_facilities,
),
)
except Exception as e:
err_msg = str(e).split('\n')[0]
warnings.append(f"🔥 {f['name']}: Feil under skraping: {err_msg}")
failed_facilities += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=ok_facilities,
progress_failed=failed_facilities,
progress_skipped=skipped_facilities,
current_facility_id=facility_id,
current_facility_name=facility_name,
event=make_progress_event(
facility_id=facility_id,
facility_name=facility_name,
outcome="error",
message=f"Feil under skraping: {err_msg}",
processed=index,
total=total_facilities,
),
)
finally:
await page.close()
@ -325,6 +570,8 @@ async def run_daily_scraping(facility_ids=None):
"updated_courses": len(changes),
"warnings": len(warnings),
"successes": len(successes),
"failed_facilities": failed_facilities,
"skipped_facilities": skipped_facilities,
}
if __name__ == "__main__":

73
backend/scrape_utils.py Executable file
View file

@ -0,0 +1,73 @@
import json
from typing import Any, Awaitable, Callable
ProgressCallback = Callable[[dict[str, Any]], Awaitable[None]]
async def emit_progress(progress_callback: ProgressCallback | None, **payload: Any) -> None:
if progress_callback is None:
return
await progress_callback(payload)
def make_progress_event(
*,
facility_id: int | None,
facility_name: str,
outcome: str,
message: str,
processed: int,
total: int,
) -> dict[str, Any]:
return {
"facility_id": facility_id,
"facility_name": facility_name,
"outcome": outcome,
"message": message,
"processed": processed,
"total": total,
}
def parse_llm_json(raw_response: str) -> Any:
text = (raw_response or "").strip()
if not text:
raise ValueError("Tomt svar fra modellen.")
if text.startswith("```"):
lines = text.splitlines()
if lines:
lines = lines[1:]
if lines and lines[-1].strip().startswith("```"):
lines = lines[:-1]
text = "\n".join(lines).strip()
candidates = [text]
first_obj = text.find("{")
last_obj = text.rfind("}")
if first_obj != -1 and last_obj != -1 and last_obj > first_obj:
candidates.append(text[first_obj:last_obj + 1])
first_arr = text.find("[")
last_arr = text.rfind("]")
if first_arr != -1 and last_arr != -1 and last_arr > first_arr:
candidates.append(text[first_arr:last_arr + 1])
seen: set[str] = set()
for candidate in candidates:
candidate = candidate.strip()
if not candidate or candidate in seen:
continue
seen.add(candidate)
try:
parsed = json.loads(candidate)
if isinstance(parsed, str):
nested = parsed.strip()
if nested.startswith("{") or nested.startswith("["):
return json.loads(nested)
return parsed
except json.JSONDecodeError:
continue
raise ValueError("Klarte ikke å tolke gyldig JSON fra modellsvaret.")

View file

@ -15,6 +15,7 @@ from bs4 import BeautifulSoup
from playwright.async_api import async_playwright
import google.generativeai as genai
from dotenv import load_dotenv
from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json
load_dotenv()
@ -48,7 +49,7 @@ async def fetch_page_text(url: str, browser) -> str:
print(f" ❌ Feil ved lasting av {url}: {e}")
return ""
def analyze_vtg_with_gemini(text: str, club_name: str) -> dict:
def analyze_vtg_with_gemini(text: str, club_name: str) -> dict | None:
print(f" 🧠 Sender {len(text)} tegn til Gemini for VTG-analyse...")
prompt = f"""
@ -82,25 +83,20 @@ Merk: Sett foreslatt_vtg_pris til null (null) hvis du ikke finner den. Hvis du i
try:
response = model.generate_content(prompt)
raw_response = response.text.strip()
if raw_response.startswith("```json"):
raw_response = raw_response[7:]
if raw_response.endswith("```"):
raw_response = raw_response[:-3]
return json.loads(raw_response.strip())
parsed = parse_llm_json(response.text)
return parsed if isinstance(parsed, dict) else None
except Exception as e:
print(f" ❌ AI-analyse feilet: {e}")
return None
async def run_vtg_scraper(facility_ids=None):
async def run_vtg_scraper(facility_ids=None, progress_callback: ProgressCallback | None = None):
print("🚀 Starter Veien til Golf (VTG) skraperen...")
conn = await asyncpg.connect(DB_URL)
facilities = []
analyzed_count = 0
saved_count = 0
skipped_count = 0
failed_count = 0
try:
query = "SELECT id, name, vtg_lenke FROM facilities WHERE vtg_lenke IS NOT NULL AND vtg_lenke != ''"
@ -108,49 +104,149 @@ async def run_vtg_scraper(facility_ids=None):
query += f" AND id IN ({','.join(map(str, facility_ids))})"
facilities = await conn.fetch(query)
print(f"📋 Fant {len(facilities)} anlegg å skrape.")
total_facilities = len(facilities)
print(f"📋 Fant {total_facilities} anlegg å skrape.")
await emit_progress(
progress_callback,
progress_total=total_facilities,
progress_completed=0,
progress_ok=0,
progress_failed=0,
progress_skipped=0,
event=make_progress_event(
facility_id=None,
facility_name="VTG",
outcome="info",
message=f"Starter VTG-skraping for {total_facilities} anlegg.",
processed=0,
total=total_facilities,
),
)
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
for facility in facilities:
for index, facility in enumerate(facilities, start=1):
fac_id = facility['id']
name = facility['name']
urls_raw = facility['vtg_lenke']
print(f"\n▶️ Behandler VTG for: {name} (ID: {fac_id})")
await emit_progress(
progress_callback,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="info",
message="Starter henting av VTG-sider.",
processed=index - 1,
total=total_facilities,
),
)
urls = [u.strip() for u in urls_raw.split(',')]
combined_text = ""
for idx, url in enumerate(urls, 1):
page_text = await fetch_page_text(url, browser)
if page_text:
combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
if len(combined_text) < 50:
print(" ⚠️ Fant for lite tekst, hopper over.")
skipped_count += 1
continue
try:
for idx, url in enumerate(urls, 1):
page_text = await fetch_page_text(url, browser)
if page_text:
combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
draft_data = analyze_vtg_with_gemini(combined_text[:25000], name)
if not draft_data:
skipped_count += 1
continue
if len(combined_text) < 50:
print(" ⚠️ Fant for lite tekst, hopper over.")
skipped_count += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=saved_count,
progress_failed=failed_count,
progress_skipped=skipped_count,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="warning",
message="Hoppet over fordi det ble funnet for lite tekst på VTG-sidene.",
processed=index,
total=total_facilities,
),
)
continue
draft_data = analyze_vtg_with_gemini(combined_text[:25000], name)
if not draft_data:
failed_count += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=saved_count,
progress_failed=failed_count,
progress_skipped=skipped_count,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="error",
message="AI-analysen ga ikke et gyldig VTG-utkast.",
processed=index,
total=total_facilities,
),
)
continue
analyzed_count += 1
analyzed_count += 1
found_dates = len(draft_data.get('foreslatt_vtg_datoer', []))
print(f" ✅ AI fant pris: {draft_data.get('foreslatt_vtg_pris')}, og {found_dates} datoer.")
print(f" ✅ AI fant pris: {draft_data.get('foreslatt_vtg_pris')}, og {len(draft_data.get('foreslatt_vtg_datoer', []))} datoer.")
await conn.execute("""
UPDATE facilities
SET vtg_draft = $1::jsonb
WHERE id = $2
""", json.dumps(draft_data), fac_id)
print(" 💾 VTG-utkast lagret i databasen!")
saved_count += 1
await conn.execute("""
UPDATE facilities
SET vtg_draft = $1::jsonb
WHERE id = $2
""", json.dumps(draft_data), fac_id)
print(" 💾 VTG-utkast lagret i databasen!")
saved_count += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=saved_count,
progress_failed=failed_count,
progress_skipped=skipped_count,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="success",
message=f"Utkast lagret med pris {draft_data.get('foreslatt_vtg_pris') or 'ukjent'} og {found_dates} kursdatoer.",
processed=index,
total=total_facilities,
),
)
except Exception as e:
failed_count += 1
print(f" ❌ Uventet feil for {name}: {e}")
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=saved_count,
progress_failed=failed_count,
progress_skipped=skipped_count,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="error",
message=f"Feilet under behandling: {str(e).splitlines()[0]}",
processed=index,
total=total_facilities,
),
)
await browser.close()
@ -163,6 +259,7 @@ async def run_vtg_scraper(facility_ids=None):
"analyzed_facilities": analyzed_count,
"saved_drafts": saved_count,
"skipped_facilities": skipped_count,
"failed_facilities": failed_count,
}
if __name__ == "__main__":

View file

@ -14,6 +14,7 @@ from scrape_jobs import (
ensure_scrape_jobs_table,
fail_scrape_job,
heartbeat_scrape_job,
update_scrape_job_progress,
)
load_dotenv()
@ -62,7 +63,10 @@ async def main() -> None:
heartbeat_task = asyncio.create_task(heartbeat_loop(pool, job_id, stop_event))
try:
result_summary = await run_scrape_job(job)
async def progress_callback(payload):
await update_scrape_job_progress(pool, job_id, **payload)
result_summary = await run_scrape_job(job, progress_callback=progress_callback)
await complete_scrape_job(pool, job_id, result_summary)
print(f"Jobb #{job_id} fullført")
except Exception as exc:

837
frontend/src/app/FacilitySearch.tsx Normal file → Executable file
View file

@ -1,217 +1,682 @@
"use client";
/**
* TEE OFF SYSTEM INSTRUCTIONS - FACILITY CARDS v3.8 (BLOB SEARCH)
* ---------------------------------------------------------------------------
* REGEL 1: Status-badge SKAL vises øverst til venstre FOR ALLE BANER.
* Bruk STATUS_MAP for tekst.
* REGEL 2: DATA-PARSING: Bruk parseJson() for 'course_statuses', 'amenities' og 'nsg_data'.
* REGEL 3: Avstand-pillen skal ha fargen #2d3319 (Mørk oliven) med hvit tekst.
* REGEL 4: NSG (Blå 'N') og Golfamore (Oransje 'G') sirkler skal ha hvit kant (border-2).
* REGEL 5: Bunnen: Antall Hull (grønn pill), Banetype (grå pill), og Ikon-sirkler.
* REGEL 6: Viser dato (f.eks "05. mars 2026") rett til høyre for øverste status-pille.
* REGEL 7: Natural Language Search bruker en "Search Blob" for å støtte delvise
* ord og skrivefeil slik at listen ikke tømmes mens brukeren skriver.
* ---------------------------------------------------------------------------
*/
import { STATUS_MAP, REGIONS } from "@/config/constants";
import { useState, useEffect, useMemo } from 'react';
import Link from 'next/link';
import { STATUS_MAP } from "@/config/constants";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
function getDistance(lat1: number, lon1: number, lat2: number, lon2: number) {
type SortMethod = "updated" | "dist" | "alpha";
type Variant = "home" | "catalog";
type CourseStatus = {
status?: string;
name?: string;
};
type Facility = {
id: number;
slug: string;
name: string;
city?: string | null;
county?: string | null;
banetype?: string | null;
image_url?: string | null;
phone?: string | null;
lat?: number | null;
lng?: number | null;
golfamore?: boolean | null;
nsg_url?: string | null;
vtg_pris?: number | null;
vtg_lenke?: string | null;
vtg_beskrivelse?: string | null;
status_updated_at?: string | null;
amenities?: unknown;
golfamore_data?: unknown;
nsg_data?: unknown;
vtg_datoer?: unknown;
course_statuses?: unknown;
};
type FacilitySearchProps = {
initialFacilities: Facility[];
variant?: Variant;
eyebrow?: string;
title?: string;
intro?: string;
};
type SpecialFlags = {
hasGolfamore: boolean;
hasNSG: boolean;
hasSimulator: boolean;
hasDrivingRange: boolean;
hasVtg: boolean;
};
const AREA_GROUPS: Record<string, string[]> = {
"nord-norge": ["finnmark", "troms", "nordland"],
"midt-norge": ["trondelag", "nord-trondelag", "sor-trondelag"],
vestlandet: ["more-og-romsdal", "sogn-og-fjordane", "hordaland", "rogaland", "vestland"],
sorlandet: ["vest-agder", "aust-agder", "agder"],
ostlandet: ["telemark", "vestfold", "ostfold", "buskerud", "hedmark", "oppland", "innlandet", "viken", "akershus", "oslo"],
"oslo-og-akershus": ["akershus", "oslo", "viken"],
};
const HIERARCHICAL_AREA_OPTIONS = [
{ value: "", label: "Hele Norge" },
{ value: "region:nord-norge", label: "Nord-Norge" },
{ value: "county:finnmark", label: "\u00A0\u00A0\u00A0Finnmark" },
{ value: "county:troms", label: "\u00A0\u00A0\u00A0Troms" },
{ value: "county:nordland", label: "\u00A0\u00A0\u00A0Nordland" },
{ value: "region:midt-norge", label: "Midt-Norge" },
{ value: "county:nord-trondelag", label: "\u00A0\u00A0\u00A0Nord-Trøndelag" },
{ value: "county:sor-trondelag", label: "\u00A0\u00A0\u00A0Sør-Trøndelag" },
{ value: "county:trondelag", label: "\u00A0\u00A0\u00A0Trøndelag" },
{ value: "region:vestlandet", label: "Vestlandet" },
{ value: "county:more-og-romsdal", label: "\u00A0\u00A0\u00A0Møre og Romsdal" },
{ value: "county:sogn-og-fjordane", label: "\u00A0\u00A0\u00A0Sogn og Fjordane" },
{ value: "county:hordaland", label: "\u00A0\u00A0\u00A0Hordaland" },
{ value: "county:rogaland", label: "\u00A0\u00A0\u00A0Rogaland" },
{ value: "county:vestland", label: "\u00A0\u00A0\u00A0Vestland" },
{ value: "region:sorlandet", label: "Sørlandet" },
{ value: "county:vest-agder", label: "\u00A0\u00A0\u00A0Vest-Agder" },
{ value: "county:aust-agder", label: "\u00A0\u00A0\u00A0Aust-Agder" },
{ value: "county:agder", label: "\u00A0\u00A0\u00A0Agder" },
{ value: "region:ostlandet", label: "Østlandet" },
{ value: "county:telemark", label: "\u00A0\u00A0\u00A0Telemark" },
{ value: "county:vestfold", label: "\u00A0\u00A0\u00A0Vestfold" },
{ value: "county:ostfold", label: "\u00A0\u00A0\u00A0Østfold" },
{ value: "county:buskerud", label: "\u00A0\u00A0\u00A0Buskerud" },
{ value: "county:hedmark", label: "\u00A0\u00A0\u00A0Hedmark" },
{ value: "county:oppland", label: "\u00A0\u00A0\u00A0Oppland" },
{ value: "region:oslo-og-akershus", label: "\u00A0\u00A0\u00A0Oslo og Akershus" },
{ value: "county:akershus", label: "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0Akershus" },
{ value: "county:oslo", label: "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0Oslo" },
{ value: "county:innlandet", label: "\u00A0\u00A0\u00A0Innlandet" },
{ value: "county:viken", label: "\u00A0\u00A0\u00A0Viken" },
];
const STATUS_ORDER = [
"aapen",
"aapen_med_vintergreener",
"stenger_snart",
"aapner_snart",
"ukjent",
"stengt",
"under_utvikling",
"nedlagt",
];
const STATUS_CLASSES: Record<string, string> = {
aapen: "bg-[#8BC34A] text-white",
aapen_med_vintergreener: "bg-[#D2A63A] text-[#112015]",
stenger_snart: "bg-[#FF5722] text-white",
aapner_snart: "bg-sky-600 text-white",
stengt: "bg-[#B6473D] text-white",
under_utvikling: "bg-slate-600 text-white",
nedlagt: "bg-[#112015] text-white",
ukjent: "bg-[#D9DED5] text-[#112015]",
};
const normalizeText = (value: unknown) =>
String(value ?? "")
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, " ")
.trim();
const normalizeStatus = (value: unknown) =>
String(value ?? "")
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/\s+/g, "_")
.replace(/[^a-z0-9_]+/g, "")
.trim();
const slugify = (value: unknown) =>
normalizeText(value)
.replace(/\s+/g, "-")
.replace(/^-+|-+$/g, "");
const parseJson = <T,>(value: unknown, fallback: T): T => {
if (!value) return fallback;
if (typeof value === "object") return value as T;
try {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon/2) * Math.sin(dLon/2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
} catch (e) { return Infinity; }
}
return JSON.parse(String(value)) as T;
} catch {
return fallback;
}
};
export default function FacilitySearch({ initialFacilities }: { initialFacilities: any[] }) {
const getDistance = (lat1: number, lon1: number, lat2: number, lon2: number) => {
try {
const r = 6371;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
return r * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
} catch {
return Number.POSITIVE_INFINITY;
}
};
const hasTruthyAmenity = (value: unknown) => {
const normalized = normalizeText(value);
return Boolean(normalized) && !["nei", "no", "false", "0", "ingen"].includes(normalized);
};
const getFacilityRegions = (county: string) => {
const countySlug = slugify(county);
return Object.entries(AREA_GROUPS)
.filter(([, counties]) => counties.includes(countySlug))
.map(([region]) => region);
};
const getPrimaryStatus = (statuses: Array<{ status?: string }>) => {
for (const candidate of STATUS_ORDER) {
if (statuses.some((status) => normalizeStatus(status.status) === candidate)) {
return candidate;
}
}
return "ukjent";
};
const formatUpdatedDate = (value: string | null | undefined) => {
if (!value) return "Ukjent";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Ukjent";
return date.toLocaleDateString("nb-NO", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
const getStatusLabel = (status: string) => STATUS_MAP[status] || "Ukjent";
const getAreaLabel = (value: string, countyOptions: Array<{ slug: string; label: string }>) => {
if (!value) return "Hele Norge";
const builtIn = HIERARCHICAL_AREA_OPTIONS.find((option) => option.value === value);
if (builtIn) return builtIn.label.trim();
if (value.startsWith("county:")) {
return countyOptions.find((option) => option.slug === value.replace("county:", ""))?.label || "Valgt fylke";
}
return "Valgt område";
};
const matchesHoleFilter = (holeValue: string, filterValue: string) => {
const normalizedHole = normalizeText(holeValue);
if (!filterValue) return true;
if (filterValue === "18-plus") return normalizedHole.includes("18");
if (filterValue === "18") return normalizedHole === "18";
if (filterValue === "9") return normalizedHole === "9" || normalizedHole === "9 9";
if (filterValue === "6-12") return normalizedHole === "6" || normalizedHole === "12";
if (filterValue === "under-utvikling") return normalizedHole.includes("utvikling");
return true;
};
const matchesSpecialFilter = (specialFilter: string, flags: SpecialFlags) => {
if (!specialFilter) return true;
if (specialFilter === "golfamore") return flags.hasGolfamore;
if (specialFilter === "nsg") return flags.hasNSG;
if (specialFilter === "simulator") return flags.hasSimulator;
if (specialFilter === "drivingrange") return flags.hasDrivingRange;
if (specialFilter === "vtg") return flags.hasVtg;
return true;
};
const getSearchShellClasses = (variant: Variant) =>
variant === "home"
? "rounded-[2rem] bg-[#39443B] px-4 py-5 text-white shadow-2xl sm:px-6 sm:py-7"
: "surface-card rounded-[2rem] px-4 py-5 text-[#112015] sm:px-6 sm:py-7";
export default function FacilitySearch({
initialFacilities,
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.",
}: FacilitySearchProps) {
const [searchQuery, setSearchQuery] = useState("");
const [userLocation, setUserLocation] = useState<{ lat: number, lng: number } | null>(null);
const [sortMethod, setSortMethod] = useState<'dist' | 'alpha'>('alpha');
const [areaFilter, setAreaFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [holeFilter, setHoleFilter] = useState("");
const [specialFilter, setSpecialFilter] = useState("");
const [sortMethod, setSortMethod] = useState<SortMethod>("updated");
const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null);
useEffect(() => {
if ("geolocation" in navigator) {
navigator.geolocation.getCurrentPosition(p => {
setUserLocation({ lat: p.coords.latitude, lng: p.coords.longitude });
setSortMethod('dist');
});
}
if (!("geolocation" in navigator)) return;
navigator.geolocation.getCurrentPosition(
(position) => {
setUserLocation({ lat: position.coords.latitude, lng: position.coords.longitude });
setSortMethod((current) => (current === "updated" ? "dist" : current));
},
() => undefined,
{
enableHighAccuracy: false,
timeout: 8000,
maximumAge: 1000 * 60 * 30,
}
);
}, []);
const processed = useMemo(() => {
const countyOptions = useMemo(() => {
const unique = new Map<string, string>();
for (const facility of Array.isArray(initialFacilities) ? initialFacilities : []) {
const label = String(facility?.county || "").trim();
const slug = slugify(label);
if (label && slug && !unique.has(slug)) unique.set(slug, label);
}
return Array.from(unique.entries())
.map(([slug, label]) => ({ slug, label }))
.sort((a, b) => a.label.localeCompare(b.label, "nb"));
}, [initialFacilities]);
const areaOptions = useMemo(() => {
const seen = new Set<string>();
const options = HIERARCHICAL_AREA_OPTIONS.filter((option) => {
if (seen.has(option.value)) return false;
seen.add(option.value);
return true;
});
for (const county of countyOptions) {
const countyValue = `county:${county.slug}`;
if (!seen.has(countyValue)) {
options.push({ value: countyValue, label: county.label });
seen.add(countyValue);
}
}
return options;
}, [countyOptions]);
const processedFacilities = useMemo(() => {
if (!Array.isArray(initialFacilities)) return [];
// Fyllord som fjernes slik at "Åpne baner i Oslo" blir til søkeordene ["åpne", "oslo"]
const stopWords = new Set(["i", "på", "for", "med", "av", "og"]);
const stopWords = new Set(["i", "pa", "for", "med", "av", "og", "de", "den", "det", "bane", "baner"]);
return initialFacilities.map(f => {
// --- ROBUST DATA-PARSING ---
const parseJson = (val: any, fallback: any) => {
if (!val) return fallback;
if (typeof val === 'object') return val;
try { return JSON.parse(val); } catch (e) { return fallback; }
};
return initialFacilities
.map((facility) => {
const amenities = parseJson<Record<string, unknown>>(facility.amenities, {});
const golfamoreData = parseJson<Record<string, unknown>>(facility.golfamore_data, {});
const nsgData = parseJson<Record<string, unknown>>(facility.nsg_data, {});
const vtgDates = parseJson<unknown[]>(facility.vtg_datoer, []);
const rawStatuses = parseJson<CourseStatus[]>(facility.course_statuses, []);
const statuses =
Array.isArray(rawStatuses) && rawStatuses.length > 0
? rawStatuses
: [{ status: "ukjent", name: "Hovedbane" }];
const rawStatuses = parseJson(f.course_statuses, []);
const sArr = Array.isArray(rawStatuses) && rawStatuses.length > 0
? rawStatuses
: [{ status: 'ukjent', name: 'Hovedbane' }];
const amenities = parseJson(f.amenities, {});
const nsgData = parseJson(f.nsg_data, {});
const countySlug = slugify(facility.county || "");
const regions = getFacilityRegions(facility.county || "");
const holeValue = String(amenities.antall_hull || "").trim();
const primaryStatus = getPrimaryStatus(statuses);
const normalizedStatuses = statuses.map((status) => normalizeStatus(status.status));
const hasGolfamore = facility.golfamore === true || Object.keys(golfamoreData).length > 0;
const hasNSG = Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0;
const hasSimulator = hasTruthyAmenity(amenities.simulator);
const hasDrivingRange = hasTruthyAmenity(amenities.drivingrange);
const hasVtg =
Boolean(facility.vtg_pris) ||
Boolean(facility.vtg_lenke) ||
Boolean(facility.vtg_beskrivelse) ||
(Array.isArray(vtgDates) && vtgDates.length > 0);
const dist = userLocation && f.lat && f.lng ? getDistance(userLocation.lat, userLocation.lng, f.lat, f.lng) : Infinity;
const hasNSG = nsgData && Object.keys(nsgData).length > 0;
const hasGolfamore = f.golfamore === true;
// --- THE SEARCH BLOB ---
// Vi starter med å legge navn, by og fylke i en stor, usynlig tekststreng
let searchableText = `${f.name} ${f.city} ${f.county}`.toLowerCase();
// 1. Injiser statuser i tekststrengen
const hasOpen = sArr.some((c: any) => (c.status || "") === 'aapen');
const hasClosed = sArr.some((c: any) => (c.status || "") === 'stengt');
const hasWinter = sArr.some((c: any) => (c.status || "") === 'aapen_med_vintergreener');
const hasNedlagt = sArr.some((c: any) => (c.status || "") === 'nedlagt');
if (hasOpen) searchableText += " åpen åpne aapen";
if (hasClosed) searchableText += " stengt stengte";
if (hasWinter) searchableText += " vinter vintergreener vinterbane";
if (hasNedlagt) searchableText += " nedlagt nedlagte";
// 2. Injiser spesial-tags
if (hasNSG) searchableText += " nsg norsk seniorgolf";
if (hasGolfamore) searchableText += " golfamore amore";
// 3. Injiser landsdel (f.eks. hvis fylket er Akershus, legger vi til "østlandet")
const fylke = (f.county || "").toLowerCase();
Object.entries(REGIONS).forEach(([regionName, counties]) => {
if (counties.includes(fylke)) {
searchableText += ` ${regionName}`;
}
const updatedTsRaw = facility.status_updated_at ? new Date(facility.status_updated_at).getTime() : 0;
const lastUpdatedTs = Number.isFinite(updatedTsRaw) ? updatedTsRaw : 0;
const distance =
userLocation && facility.lat && facility.lng
? getDistance(userLocation.lat, userLocation.lng, facility.lat, facility.lng)
: Number.POSITIVE_INFINITY;
let searchBlob = [
facility.name,
facility.city,
facility.county,
facility.banetype,
holeValue,
...statuses.map((status) => status.name),
...regions,
]
.map((value) => normalizeText(value))
.join(" ");
if (hasGolfamore) searchBlob += " golfamore";
if (hasNSG) searchBlob += " nsg seniorgolf";
if (hasSimulator) searchBlob += " simulator";
if (hasDrivingRange) searchBlob += " drivingrange range";
if (hasVtg) searchBlob += " vtg veien til golf nybegynnerkurs";
if (normalizedStatuses.includes("aapen")) searchBlob += " apen apne";
if (normalizedStatuses.includes("stengt")) searchBlob += " stengt";
if (normalizedStatuses.includes("aapen_med_vintergreener")) searchBlob += " vinter vintergreener";
const words = normalizeText(searchQuery)
.split(/\s+/)
.filter((word) => word && !stopWords.has(word));
const selectedArea = areaFilter.replace(/^(region:|county:)/, "");
const matchesSearch = words.every((word) => searchBlob.includes(word));
const matchesArea =
!areaFilter ||
(areaFilter.startsWith("region:") &&
(regions.includes(selectedArea) ||
(AREA_GROUPS[selectedArea] ? AREA_GROUPS[selectedArea].includes(countySlug) : false))) ||
(areaFilter.startsWith("county:") && countySlug === selectedArea);
const matchesStatus = !statusFilter || normalizedStatuses.includes(statusFilter);
const matchesHoles = matchesHoleFilter(holeValue, holeFilter);
const matchesSpecial = matchesSpecialFilter(specialFilter, {
hasGolfamore,
hasNSG,
hasSimulator,
hasDrivingRange,
hasVtg,
});
return {
...facility,
holeValue,
primaryStatus,
hasGolfamore,
hasNSG,
hasVtg,
distance,
lastUpdatedTs,
matchesSearch,
matchesArea,
matchesStatus,
matchesHoles,
matchesSpecial,
};
})
.filter(
(facility) =>
facility.matchesSearch &&
facility.matchesArea &&
facility.matchesStatus &&
facility.matchesHoles &&
facility.matchesSpecial
)
.sort((a, b) => {
if (sortMethod === "dist") {
if (a.distance !== b.distance) return a.distance - b.distance;
return a.name.localeCompare(b.name, "nb");
}
if (sortMethod === "updated") {
if (a.lastUpdatedTs !== b.lastUpdatedTs) return b.lastUpdatedTs - a.lastUpdatedTs;
return a.name.localeCompare(b.name, "nb");
}
return a.name.localeCompare(b.name, "nb");
});
}, [areaFilter, holeFilter, initialFacilities, searchQuery, sortMethod, specialFilter, statusFilter, userLocation]);
// Splitter brukerens søk inn i enkeltord og fjerner stopWords + ordene "bane"/"baner"
const words = searchQuery
.toLowerCase()
.trim()
.split(/\s+/)
.filter(w => w.length > 0 && !stopWords.has(w) && w !== "bane" && w !== "baner");
// Sjekker at ALLE ordene brukeren har skrevet, finnes et sted i "Search Blob"-en
const matches = words.every(w => searchableText.includes(w));
return { ...f, statuses: sArr, amenities, dist, hasNSG, hasGolfamore, matches };
})
.filter(f => f.matches)
.sort((a, b) => {
if (sortMethod === 'dist' && a.dist !== b.dist) return a.dist - b.dist;
return a.name.localeCompare(b.name, 'nb');
});
}, [searchQuery, initialFacilities, userLocation, sortMethod]);
const filtersCount = [areaFilter, statusFilter, holeFilter, specialFilter, searchQuery.trim()].filter(Boolean).length;
const summaryText = `${processedFacilities.length} baner • ${getAreaLabel(areaFilter, countyOptions)}${
filtersCount > 0 ? `${filtersCount} aktive filtre` : ""
}`;
const labelClassName = variant === "home" ? "text-white/70" : "text-[#617063]";
return (
<div className="max-w-[1400px] mx-auto px-6 py-12 relative z-40">
<div className="text-center mb-6">
<button onClick={() => setSortMethod(sortMethod === 'dist' ? 'alpha' : 'dist')} className="bg-white px-6 py-3 rounded-full shadow-md text-[10px] font-black text-[#8bc34a] uppercase tracking-widest border border-gray-100 transition-colors">
{sortMethod === 'dist' ? "📍 Nærmeste baner først" : "🔠 Alfabetisk visning"} {processed.length} baner
</button>
<section className="mx-auto max-w-[1400px] px-4 py-6 sm:px-6 sm:py-8 lg:px-8 lg:py-10">
{variant === "catalog" && (
<div className="mb-8 max-w-4xl">
<p className="mb-3 text-[11px] font-extrabold uppercase tracking-[0.3em] text-[#6FA786]">{eyebrow}</p>
<h1 className="section-title text-4xl text-[#112015] sm:text-5xl lg:text-6xl">{title}</h1>
<p className="mt-4 max-w-3xl text-base leading-8 text-[#617063]">{intro}</p>
</div>
)}
<div className={getSearchShellClasses(variant)}>
<div className="mb-5">
<h2 className="section-title text-3xl sm:text-4xl">
{variant === "home" ? "Søk golfbaner" : "Filtrer oversikten"}
</h2>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<FieldSelect label="Område" value={areaFilter} onChange={setAreaFilter} labelClassName={labelClassName}>
{areaOptions.map((option) => (
<option key={option.value || "all"} value={option.value}>
{option.label}
</option>
))}
</FieldSelect>
<FieldSelect label="Banestatus" value={statusFilter} onChange={setStatusFilter} labelClassName={labelClassName}>
<option value="">Alle statuser</option>
<option value="aapen">Åpne baner</option>
<option value="aapen_med_vintergreener">Vintergreener</option>
<option value="stenger_snart">Stenger snart</option>
<option value="aapner_snart">Åpner snart</option>
<option value="stengt">Stengt</option>
<option value="ukjent">Ukjent status</option>
</FieldSelect>
<FieldSelect label="Antall hull" value={holeFilter} onChange={setHoleFilter} labelClassName={labelClassName}>
<option value="">Alle anlegg</option>
<option value="18-plus">18 hull eller mer</option>
<option value="18">Nøyaktig 18 hull</option>
<option value="9">9 hull</option>
<option value="6-12">6 eller 12 hull</option>
<option value="under-utvikling">Under utvikling</option>
</FieldSelect>
<FieldSelect label="Ekstra" value={specialFilter} onChange={setSpecialFilter} labelClassName={labelClassName}>
<option value="">Ingen tillegg</option>
<option value="golfamore">Golfamore</option>
<option value="nsg">Seniorgolf / NSG</option>
<option value="simulator">Simulator</option>
<option value="drivingrange">Drivingrange</option>
<option value="vtg">Tilbyr VTG</option>
</FieldSelect>
</div>
<div className="mt-3 grid gap-3 lg:grid-cols-[minmax(0,1fr)_220px_auto]">
<FieldInput
label="Søk"
value={searchQuery}
placeholder='For eksempel "åpne baner i Oslo"'
onChange={setSearchQuery}
labelClassName={labelClassName}
/>
<FieldSelect
label="Sortering"
value={sortMethod}
onChange={(value) => setSortMethod(value as SortMethod)}
labelClassName={labelClassName}
>
<option value="updated">Sist oppdatert</option>
<option value="alpha">Alfabetisk</option>
<option value="dist">Nærmest deg</option>
</FieldSelect>
<button
type="button"
onClick={() => {
setSearchQuery("");
setAreaFilter("");
setStatusFilter("");
setHoleFilter("");
setSpecialFilter("");
setSortMethod(userLocation ? "dist" : "updated");
}}
className={`mt-[1.72rem] h-[52px] rounded-2xl px-5 text-[11px] font-extrabold uppercase tracking-[0.2em] transition ${
variant === "home" ? "bg-[#FF5722] text-white hover:bg-[#C94F2D]" : "bg-[#25312A] text-white hover:bg-[#39443B]"
}`}
>
Nullstill
</button>
</div>
<div
className={`mt-4 rounded-[1.2rem] px-4 py-3 text-sm font-bold ${
variant === "home" ? "bg-white/10 text-white/90" : "bg-[#F3F6EE] text-[#617063]"
}`}
>
<span>{summaryText}</span>
<span className={`ml-2 ${variant === "home" ? "text-white/65" : "text-[#839184]"}`}>
{sortMethod === "dist" && userLocation
? "Sortert etter avstand fra deg."
: sortMethod === "updated"
? "Sortert etter sist oppdatert."
: "Sortert alfabetisk."}
</span>
</div>
</div>
<input className="w-full p-8 rounded-[2.5rem] shadow-2xl mb-16 text-gray-900 border-none ring-1 ring-black/5 text-2xl outline-none focus:ring-4 focus:ring-[#8bc34a]/20 transition-all bg-white" placeholder='Søk baner, fylke, status eller spesial (f.eks "Åpne baner i Akershus" eller "NSG")...' value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
{processedFacilities.length === 0 ? (
<div className="surface-card mt-6 rounded-[2rem] px-6 py-12 text-center">
<p className="text-lg font-extrabold text-[#112015]">Ingen baner matcher filtrene akkurat .</p>
<p className="mt-2 text-sm text-[#617063]">Prøv å nullstille filtrene eller velg et større område.</p>
</div>
) : (
<div className="mt-6 grid grid-cols-1 gap-5 md:grid-cols-2 2xl:grid-cols-3">
{processedFacilities.map((facility) => (
<Link
href={`/golfbaner/${facility.slug}`}
key={facility.id}
className="surface-card group overflow-hidden rounded-[2rem] transition hover:-translate-y-1 hover:shadow-xl"
>
<div className="relative h-56 overflow-hidden bg-[#D9DED5] sm:h-60">
<Image
src={facility.image_url || "/Toppbilde-standard.jpg"}
alt={facility.name}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1536px) 50vw, 33vw"
className="object-cover transition duration-700 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#25312A]/65 via-[#25312A]/10 to-transparent" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
{processed.map((f: any) => {
const sArr = f.statuses; // Sikret via pre-prosesseringen over
// Formater datoen pent: "05. mars 2026"
const lastUpdated = f.status_updated_at
? new Date(f.status_updated_at).toLocaleDateString('nb-NO', { day: '2-digit', month: 'long', year: 'numeric' })
: 'Ukjent';
return (
<Link href={`/golfbaner/${f.slug}`} key={f.id} className="bg-white rounded-[2.5rem] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-gray-100 flex flex-col group relative">
<div className="h-64 relative overflow-hidden bg-gray-100">
<img src={f.image_url || "/Toppbilde-standard.jpg"} className="w-full h-full object-cover transition duration-1000 group-hover:scale-105" alt={f.name} />
{/* Status Badges for ALLE baner på anlegget */}
<div className="absolute top-5 left-5 flex flex-col gap-2 z-20">
{sArr.map((course: any, idx: number) => {
const rawStatus = (course.status || "ukjent").toLowerCase();
let statusColor = "bg-gray-400";
if (rawStatus === 'aapen') statusColor = "bg-[#8bc34a]";
else if (rawStatus.includes('vinter') || rawStatus === 'stenger_snart') statusColor = "bg-[#ff5722]";
else if (rawStatus === 'aapner_snart') statusColor = "bg-amber-500";
else if (rawStatus === 'stengt') statusColor = "bg-red-600";
else if (rawStatus === 'nedlagt') statusColor = "bg-black";
else if (rawStatus === 'under_utvikling') statusColor = "bg-blue-500";
return (
<div key={idx} className="flex items-center gap-3">
<div className={`${statusColor} text-white px-3 py-1.5 rounded-xl text-[9px] font-black uppercase shadow-lg backdrop-blur-sm bg-opacity-90 flex items-center gap-2 max-w-[200px]`}>
{sArr.length > 1 && (
<span className="opacity-80 border-r border-white/30 pr-2 truncate max-w-[90px]" title={course.name}>
{course.name}
</span>
)}
<span>{STATUS_MAP[rawStatus] || rawStatus}</span>
</div>
{/* Dato-pille ved siden av den øverste status-pillen */}
{idx === 0 && (
<div className="bg-white/30 backdrop-blur-sm text-[#11280f]/90 px-3 py-1.5 rounded-xl text-[11px] font-bold shadow-lg">
{lastUpdated}
</div>
)}
</div>
);
})}
<div className="absolute left-4 top-4 flex max-w-[calc(100%-2rem)] flex-wrap gap-2">
<span
className={`rounded-full px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] ${
STATUS_CLASSES[facility.primaryStatus] || STATUS_CLASSES.ukjent
}`}
>
{getStatusLabel(facility.primaryStatus)}
</span>
{facility.hasGolfamore && (
<span className="rounded-full bg-[#FF5722] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-white">
Golfamore
</span>
)}
{facility.hasNSG && (
<span className="rounded-full bg-[#2D6CB5] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-white">
NSG
</span>
)}
</div>
{/* Avstandspille */}
{f.dist !== Infinity && (
<div className="absolute bottom-5 right-5 bg-[#2d3319] text-white px-4 py-2 rounded-2xl text-[10px] font-black shadow-lg z-20">
{Math.round(f.dist)} km unna
</div>
)}
<div className="absolute bottom-4 left-4 right-4">
<p className="text-[10px] font-extrabold uppercase tracking-[0.22em] text-white/75">
{facility.city} {facility.county}
</p>
<h3 className="mt-2 text-3xl text-white">{facility.name}</h3>
</div>
</div>
<div className="p-8 flex flex-col flex-grow">
<h3 className="font-black text-3xl text-[#11280f] mb-1 group-hover:text-[#8bc34a] transition-colors leading-tight">{f.name}</h3>
<p className="text-gray-400 text-[11px] font-bold uppercase tracking-widest mb-8">{f.city} {f.county}</p>
<div className="mt-auto flex items-center justify-between">
<div className="flex items-center gap-2">
{/* Hull-pille */}
<span className="bg-[#f1f7ed] text-[#8bc34a] px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest">
{f.amenities?.antall_hull || '--'} HULL
</span>
{/* Banetype-pille */}
<span className="bg-gray-50 text-gray-400 px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-gray-100">
{f.banetype || 'SKOGSBANE'}
</span>
</div>
<div className="space-y-5 p-5">
<div className="flex flex-wrap gap-2">
<span className="rounded-full bg-[#EEF5E4] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-[#112015]">
{facility.holeValue || "--"} hull
</span>
<span className="rounded-full bg-[#F4F5F1] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-[#617063]">
{facility.banetype || "Banetype ukjent"}
</span>
{facility.hasVtg && (
<span className="rounded-full bg-[#FFF0E9] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-[#C94F2D]">
VTG
</span>
)}
</div>
{/* Sirkel-ikoner (NSG / Golfamore) */}
<div className="flex gap-2">
{f.hasNSG && (
<div className="w-9 h-9 bg-blue-600 text-white rounded-full flex items-center justify-center font-black text-sm shadow-lg border-2 border-white translate-y-1">N</div>
)}
{f.hasGolfamore && (
<div className="w-9 h-9 bg-[#ff5722] text-white rounded-full flex items-center justify-center font-black text-sm shadow-lg border-2 border-white translate-y-1">G</div>
)}
</div>
<div className="grid grid-cols-2 gap-3 rounded-[1.35rem] bg-[#F7F8F3] p-4">
<div>
<p className="text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#839184]">Oppdatert</p>
<p className="mt-1 text-sm font-bold text-[#112015]">{formatUpdatedDate(facility.status_updated_at)}</p>
</div>
<div>
<p className="text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#839184]">Sortering</p>
<p className="mt-1 text-sm font-bold text-[#112015]">
{sortMethod === "dist" && Number.isFinite(facility.distance)
? `${Math.round(facility.distance)} km unna`
: sortMethod === "updated"
? "Nyeste status"
: "Alfabetisk"}
</p>
</div>
</div>
<div className="flex items-center justify-between text-sm font-bold text-[#112015]">
<span className="text-[#617063]">{facility.phone ? facility.phone : "Se detaljer"}</span>
<span className="text-[#FF5722] transition group-hover:text-[#C94F2D]">Se anlegg </span>
</div>
</div>
</Link>
);
})}
</div>
</div>
))}
</div>
)}
</section>
);
}
}
function FieldSelect({
label,
value,
onChange,
labelClassName,
children,
}: {
label: string;
value: string;
onChange: (value: string) => void;
labelClassName: string;
children: React.ReactNode;
}) {
return (
<label className="block">
<span className={`mb-2 block text-[10px] font-extrabold uppercase tracking-[0.22em] ${labelClassName}`}>{label}</span>
<select value={value} onChange={(event) => onChange(event.target.value)} className="filter-field w-full px-4 py-3">
{children}
</select>
</label>
);
}
function FieldInput({
label,
value,
placeholder,
onChange,
labelClassName,
}: {
label: string;
value: string;
placeholder: string;
onChange: (value: string) => void;
labelClassName: string;
}) {
return (
<label className="block">
<span className={`mb-2 block text-[10px] font-extrabold uppercase tracking-[0.22em] ${labelClassName}`}>{label}</span>
<input
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="filter-field w-full px-4 py-3"
/>
</label>
);
}

219
frontend/src/app/HeroSlider.tsx Normal file → Executable file
View file

@ -1,130 +1,163 @@
"use client";
/**
* TEE OFF SYSTEM INSTRUCTIONS - HERO SLIDER v2.4
* ---------------------------------------------------------------------------
* REGEL 1: Kun baner med status 'aapen', 'aapner_snart', 'stenger_snart'
* eller 'aapen_med_vintergreener' skal prioriteres.
* REGEL 2: Baner med status 'nedlagt' eller 'under_utvikling' skal ALDRI vises.
* REGEL 3: Baner med generiske bilder (inneholder 'standard') skal ALDRI vises.
* REGEL 4: MANUELL EKSKLUDERING: Slugs i MANUAL_EXCLUSION_LIST skal aldri vises.
* REGEL 5: Slideren skal vise nøyaktig 5 baner.
* REGEL 6: Maks høyde er låst til 624px. Ingen badges.
* REGEL 7: Typografi: Nedjustert fontstørrelse (4xl mobil / 7xl desktop) for eleganse.
* REGEL 8: Utvalget skal være stabilt i én time (Hourly Seed) før det refreshes.
* ---------------------------------------------------------------------------
*/
import { useState, useEffect, useMemo } from 'react';
import Link from 'next/link';
import Image from "next/image";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
const MANUAL_EXCLUSION_LIST = [
'alsten-golfklubb', 'askim-golfklubb', 'bergen-golfklubb', 'eidskog-golfklubb',
'eiker-golfklubb', 'floro-golfklubb', 'garder-golfklubb', 'hafjell-golfklubb',
'halden-golfklubb', 'haugesund-golfklubb', 'hinnoy-golfklubb', 'hitra-golfklubb',
'hurum-golfklubb', 'imjelt-pitch-putt', 'karmoy-golfklubb', 'kristiansund-og-omegn-golfklubb',
'lommedalen-golfklubb', 'laerdal-golfklubb', 'moa-golfsenter', 'modum-golfklubb',
'nes-golfklubb-09', 'nittedal-golfklubb', 'selbu-golfklubb', 'stryn-golfklubb',
'sunnfjord-golfklubb', 'tysnes-golfklubb', 'vanylven-golfklubb', 'vesteralen-golfklubb',
'vestlia-golf'
"alsten-golfklubb",
"askim-golfklubb",
"bergen-golfklubb",
"eidskog-golfklubb",
"eiker-golfklubb",
"floro-golfklubb",
"garder-golfklubb",
"hafjell-golfklubb",
"halden-golfklubb",
"haugesund-golfklubb",
"hinnoy-golfklubb",
"hitra-golfklubb",
"hurum-golfklubb",
"imjelt-pitch-putt",
"karmoy-golfklubb",
"kristiansund-og-omegn-golfklubb",
"lommedalen-golfklubb",
"laerdal-golfklubb",
"moa-golfsenter",
"modum-golfklubb",
"nes-golfklubb-09",
"nittedal-golfklubb",
"selbu-golfklubb",
"stryn-golfklubb",
"sunnfjord-golfklubb",
"tysnes-golfklubb",
"vanylven-golfklubb",
"vesteralen-golfklubb",
"vestlia-golf",
];
export default function HeroSlider({ facilities }: { facilities: any[] }) {
const [currentIndex, setCurrentSlide] = useState(0);
const GOOD_STATUSES = ["aapen", "aapner_snart", "stenger_snart", "aapen_med_vintergreener"];
const BAD_STATUSES = ["nedlagt", "under_utvikling"];
type CourseStatus = {
status?: string;
};
type Facility = {
id: number;
slug: string;
name: string;
image_url?: string | null;
course_statuses?: CourseStatus[] | null;
};
export default function HeroSlider({ facilities }: { facilities: Facility[] }) {
const [currentIndex, setCurrentIndex] = useState(0);
const sliderItems = useMemo(() => {
if (!Array.isArray(facilities) || facilities.length === 0) return [];
const preferredStatuses = ['aapen', 'aapner_snart', 'stenger_snart', 'aapen_med_vintergreener'];
const forbiddenStatuses = ['nedlagt', 'under_utvikling'];
const validFacilities = facilities.filter((facility) => {
if (MANUAL_EXCLUSION_LIST.includes(facility.slug)) return false;
const image = String(facility.image_url || "").toLowerCase();
if (!image || image.includes("standard")) return false;
const validCandidates = facilities.filter(f => {
if (MANUAL_EXCLUSION_LIST.includes(f.slug)) return false;
const img = f.image_url || "";
if (!img || img.toLowerCase().includes('standard') || img.length < 5) return false;
const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : [];
const isForbidden = statuses.some((s: any) =>
forbiddenStatuses.includes((s.status || "").toLowerCase())
const statuses = Array.isArray(facility.course_statuses) ? facility.course_statuses : [];
const hasForbiddenStatus = statuses.some((status) =>
BAD_STATUSES.includes(String(status?.status || "").toLowerCase())
);
return !isForbidden;
return !hasForbiddenStatus;
});
const highPriority = validCandidates.filter(f => {
const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : [];
return statuses.some((s: any) => preferredStatuses.includes((s.status || "").toLowerCase()));
const priority = validFacilities.filter((facility) => {
const statuses = Array.isArray(facility.course_statuses) ? facility.course_statuses : [];
return statuses.some((status) => GOOD_STATUSES.includes(String(status?.status || "").toLowerCase()));
});
const fallbackPool = validCandidates.filter(f => !highPriority.includes(f));
const fallback = validFacilities.filter((facility) => !priority.includes(facility));
const now = new Date();
const hourlySeed = parseInt(`${now.getFullYear()}${now.getMonth()}${now.getDate()}${now.getHours()}`);
const seed = Number(`${now.getFullYear()}${now.getMonth()}${now.getDate()}${now.getHours()}`);
const seededShuffle = (arr: any[]) => {
return [...arr].sort((a, b) => ((a.id * hourlySeed) % 100) - ((b.id * hourlySeed) % 100));
};
const seededShuffle = (items: Facility[]) =>
[...items].sort((a, b) => ((a.id * seed) % 101) - ((b.id * seed) % 101));
let selection = seededShuffle(highPriority);
if (selection.length < 5) {
selection = [...selection, ...seededShuffle(fallbackPool)].slice(0, 5);
} else {
selection = selection.slice(0, 5);
}
return selection;
const selected = [...seededShuffle(priority), ...seededShuffle(fallback)].slice(0, 5);
return selected;
}, [facilities]);
useEffect(() => {
if (sliderItems.length <= 1) return;
const interval = setInterval(() => setCurrentSlide((p) => (p + 1) % sliderItems.length), 8000);
const interval = setInterval(() => {
setCurrentIndex((previous) => (previous + 1) % sliderItems.length);
}, 8000);
return () => clearInterval(interval);
}, [sliderItems.length]);
if (sliderItems.length === 0) return null;
return (
<section className="relative h-[65vh] max-h-[624px] w-full overflow-hidden bg-[#11280f]">
{sliderItems.map((f, i) => (
<div
key={f.id}
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
i === currentIndex ? 'opacity-100 z-10' : 'opacity-0 z-0'
<section className="relative min-h-[420px] overflow-hidden bg-[#25312A] sm:min-h-[520px] lg:min-h-[620px]">
{sliderItems.map((facility, index) => (
<div
key={facility.id}
className={`absolute inset-0 transition-opacity duration-1000 ${
index === currentIndex ? "opacity-100" : "pointer-events-none opacity-0"
}`}
>
<Link href={`/golfbaner/${f.slug}`} className="block h-full relative group">
<div className="absolute inset-0 bg-gradient-to-t from-[#11280f] via-[#11280f]/40 to-black/10 z-10" />
<img
src={f.image_url}
alt={f.name}
className="w-full h-full object-cover transition-transform duration-[10s] scale-100 group-hover:scale-105"
/>
<div className="absolute inset-0 z-20 flex items-center">
<div className="max-w-[1400px] mx-auto px-6 w-full">
<div className="max-w-4xl animate-in fade-in slide-in-from-bottom-8 duration-1000">
{/* FONT NEDJUSTERT FRA text-6xl md:text-9xl TIL text-4xl md:text-7xl */}
<h2 className="text-4xl md:text-7xl font-black text-white tracking-tighter drop-shadow-2xl leading-[0.9] mb-4">
{f.name}
</h2>
<p className="text-white/90 text-sm md:text-xl font-bold uppercase tracking-[0.4em] drop-shadow-md">
{f.county} <span className="text-[#8bc34a] mx-2"></span> {f.city}
</p>
</div>
</div>
</div>
</Link>
<Image
src={facility.image_url || "/Toppbilde-standard.jpg"}
alt={facility.name}
fill
priority={index === currentIndex}
sizes="100vw"
className="object-cover object-center"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#25312A]/70 via-[#25312A]/10 to-[#25312A]/20" />
</div>
))}
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-30 flex gap-4">
{sliderItems.map((_, i) => (
<button
key={i}
onClick={() => setCurrentSlide(i)}
className={`h-1 transition-all duration-500 rounded-full ${
i === currentIndex ? 'w-16 bg-[#8bc34a]' : 'w-4 bg-white/20'
}`}
/>
))}
<div className="absolute inset-0 z-10 mx-auto flex max-w-[1400px] flex-col justify-between px-4 py-5 sm:px-6 sm:py-7 lg:px-8 lg:py-10">
<div className="flex justify-end">
<div className="max-w-[14rem] text-right sm:max-w-[22rem] lg:max-w-[48rem]">
<h1 className="text-sm font-bold uppercase leading-tight tracking-tight text-white sm:text-3xl lg:text-5xl">
<span>TeeOff.no gir deg komplett oversikt over </span>
<Link
href="/golfbaner"
className="font-extrabold text-[#FF5722] transition hover:text-white"
aria-label="Se alle golfbaner"
>
ALLE
</Link>
<span> norske golfbaner!</span>
</h1>
</div>
</div>
<div className="flex flex-col items-center gap-4">
<Link
href={`/golfbaner/${sliderItems[currentIndex].slug}`}
className="rounded-full bg-white/72 px-5 py-3 text-center text-2xl font-bold uppercase tracking-tight text-[#FF5722] shadow-lg backdrop-blur-sm transition hover:bg-white sm:px-8 sm:text-4xl"
>
{sliderItems[currentIndex].name}
</Link>
<div className="flex gap-3">
{sliderItems.map((_, index) => (
<button
key={index}
type="button"
onClick={() => setCurrentIndex(index)}
aria-label={`Vis bilde ${index + 1}`}
className={`h-1.5 rounded-full transition-all ${
index === currentIndex ? "w-12 bg-[#8BC34A]" : "w-5 bg-white/35"
}`}
/>
))}
</div>
</div>
</div>
</section>
);
}
}

View file

@ -13,6 +13,16 @@ type AdminTab = 'banestatus' | 'medlemskap' | 'greenfee' | 'vtg';
type ScrapeJobStatus = 'pending' | 'running' | 'completed' | 'failed';
type ScrapeJobEvent = {
timestamp?: string;
facility_id?: number | null;
facility_name?: string;
outcome?: 'success' | 'warning' | 'error' | 'info' | string;
message?: string;
processed?: number;
total?: number;
};
type ScrapeJob = {
id: number;
job_type: AdminTab;
@ -26,6 +36,14 @@ type ScrapeJob = {
max_attempts?: number;
next_retry_at?: string | null;
last_error_at?: string | null;
progress_total?: number;
progress_completed?: number;
progress_ok?: number;
progress_failed?: number;
progress_skipped?: number;
current_facility_id?: number | null;
current_facility_name?: string | null;
recent_events?: ScrapeJobEvent[];
result_summary?: Record<string, number | string | null>;
created_at?: string | null;
started_at?: string | null;
@ -72,6 +90,13 @@ const JOB_ERROR_LABELS: Record<string, string> = {
worker_stale: 'Worker mistet heartbeat',
};
const JOB_EVENT_TONE_CLASSES: Record<string, string> = {
success: 'bg-[#edf6e3] text-[#11280f] border-[#d8e8c8]',
warning: 'bg-amber-50 text-amber-800 border-amber-200',
error: 'bg-red-50 text-red-700 border-red-200',
info: 'bg-slate-50 text-slate-700 border-slate-200',
};
const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: number, field: string, initialValue: string, onSave: (id: number, field: string, val: string) => void }) => {
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(initialValue || '');
@ -267,6 +292,18 @@ export default function AdminDashboard() {
return scrapeJobs.filter(job => job.id !== latestJob.id).slice(0, 4);
}, [latestJob, scrapeJobs]);
const latestJobProgress = useMemo(() => {
const total = latestJob?.progress_total ?? latestJob?.total_facilities ?? 0;
const completed = Math.min(latestJob?.progress_completed ?? 0, total || 0);
const percent = total > 0 ? Math.max(0, Math.min(100, Math.round((completed / total) * 100))) : 0;
return { total, completed, percent };
}, [latestJob]);
const latestJobEvents = useMemo(() => {
const events = Array.isArray(latestJob?.recent_events) ? latestJob.recent_events : [];
return [...events].reverse().slice(0, 6);
}, [latestJob]);
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) setSelectedFacilities(filteredFacilities.map(f => f.id));
else setSelectedFacilities([]);
@ -871,6 +908,88 @@ export default function AdminDashboard() {
{latestJobRetryLabel}
</p>
)}
{latestJobProgress.total > 0 && (
<div className="mt-4 space-y-4">
<div className="rounded-[1.5rem] border border-white/70 bg-white/70 p-4 shadow-sm">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Fremdrift i jobben</p>
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-black text-[#11280f]">
{latestJobProgress.completed} av {latestJobProgress.total} anlegg behandlet
</span>
<span className="inline-flex rounded-xl bg-[#f4f7ef] px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-600">
{latestJobProgress.percent}%
</span>
</div>
{latestJob.current_facility_name && latestJob.status === 'running' && (
<p className="text-xs text-gray-600">
Behandler <span className="font-black text-[#11280f]">{latestJob.current_facility_name}</span>
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<span className="inline-flex rounded-xl bg-[#edf6e3] px-3 py-1 text-[10px] font-black uppercase tracking-widest text-[#11280f]">
OK {latestJob.progress_ok ?? 0}
</span>
<span className="inline-flex rounded-xl bg-red-50 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-red-700">
Feil {latestJob.progress_failed ?? 0}
</span>
<span className="inline-flex rounded-xl bg-amber-50 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-amber-800">
Hoppet over {latestJob.progress_skipped ?? 0}
</span>
</div>
</div>
<div className="mt-4 h-3 overflow-hidden rounded-full bg-white">
<div
className="h-full rounded-full bg-[#8bc34a] transition-all duration-500"
style={{ width: `${latestJobProgress.percent}%` }}
/>
</div>
</div>
{latestJobEvents.length > 0 && (
<div className="space-y-3">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Siste hendelser</p>
<div className="grid gap-3 xl:grid-cols-2">
{latestJobEvents.map((event, index) => {
const toneClass = JOB_EVENT_TONE_CLASSES[event.outcome || 'info'] || JOB_EVENT_TONE_CLASSES.info;
const progressLabel =
event.processed && event.total
? `${event.processed} av ${event.total}`
: latestJobProgress.total > 0
? `${latestJobProgress.completed} av ${latestJobProgress.total}`
: '';
return (
<article
key={`${event.timestamp || 'event'}-${index}`}
className={`rounded-[1.25rem] border p-4 ${toneClass}`}
>
<div className="flex flex-wrap items-center gap-2">
{progressLabel && (
<span className="inline-flex rounded-xl bg-white/80 px-3 py-1 text-[10px] font-black uppercase tracking-widest">
{progressLabel}
</span>
)}
{event.facility_name && (
<span className="text-xs font-black">{event.facility_name}</span>
)}
{event.timestamp && (
<span className="text-[10px] text-gray-500">
{new Date(event.timestamp).toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
)}
</div>
<p className="mt-2 text-sm leading-relaxed">{event.message || 'Hendelse registrert.'}</p>
</article>
);
})}
</div>
</div>
)}
</div>
)}
{latestJobSummary && (
<p className="text-xs text-gray-600 leading-relaxed">{latestJobSummary}</p>
)}

View file

@ -1,26 +1,93 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #f3f6ee;
--foreground: #112015;
--color-pine-900: #25312a;
--color-pine-700: #39443b;
--color-mist-50: #f3f6ee;
--color-surface: #ffffff;
--color-ink: #112015;
--color-ink-muted: #617063;
--color-brand-green: #8bc34a;
--color-brand-orange: #ff5722;
--color-brand-orange-dark: #c94f2d;
--color-status-closed: #b6473d;
--color-status-winter: #d2a63a;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-sans: var(--font-ui);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-ui), Arial, Helvetica, sans-serif;
line-height: 1.6;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-display), Arial, Helvetica, sans-serif;
letter-spacing: -0.02em;
line-height: 0.98;
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select,
textarea {
font: inherit;
}
::selection {
background: rgba(255, 87, 34, 0.2);
color: var(--color-ink);
}
.site-shell {
background:
radial-gradient(circle at top left, rgba(139, 195, 74, 0.08), transparent 22%),
linear-gradient(180deg, #f5f7ef 0%, #f3f6ee 100%);
}
.section-title {
font-family: var(--font-display), Arial, Helvetica, sans-serif;
text-transform: uppercase;
letter-spacing: 0.01em;
}
.filter-field {
border: 1px solid rgba(17, 32, 21, 0.12);
background: #ffffff;
color: var(--color-ink);
border-radius: 1rem;
min-height: 3.25rem;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.6) inset;
}
.filter-field:focus {
outline: 2px solid rgba(139, 195, 74, 0.45);
outline-offset: 1px;
}
.surface-card {
background: var(--color-surface);
border: 1px solid rgba(17, 32, 21, 0.08);
box-shadow:
0 1px 2px rgba(17, 32, 21, 0.04),
0 10px 30px rgba(17, 32, 21, 0.05);
}

View file

@ -0,0 +1,38 @@
import FacilitySearch from "@/app/FacilitySearch";
import { API_URL } from "@/config/constants";
export const dynamic = "force-dynamic";
export default async function GolfCoursesIndexPage() {
let facilities = [];
try {
const res = await fetch(`${API_URL}/facilities`, {
next: { revalidate: 0 },
cache: "no-store",
});
if (!res.ok) {
throw new Error(`API returnerte status ${res.status}`);
}
facilities = await res.json();
} catch (error) {
console.error("Kritisk feil ved henting av golfbaner:", error);
facilities = [];
}
const safeData = Array.isArray(facilities) ? facilities : [];
return (
<main className="site-shell min-h-screen">
<FacilitySearch
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."
/>
</main>
);
}

View file

@ -1,7 +1,20 @@
import type { Metadata } from "next";
import { Mulish, Oswald } from "next/font/google";
import "./globals.css";
import Header from "@/components/Header";
const uiFont = Mulish({
subsets: ["latin"],
variable: "--font-ui",
display: "swap",
});
const displayFont = Oswald({
subsets: ["latin"],
variable: "--font-display",
display: "swap",
});
export const metadata: Metadata = {
title: "TeeOff.no - Din guide til norske golfbaner",
description: "Oppdatert banestatus, priser og informasjon om alle norske golfanlegg.",
@ -10,10 +23,10 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="nb">
<body className="antialiased bg-[#f1f7ed]">
<body className={`${uiFont.variable} ${displayFont.variable} antialiased`}>
<Header />
{children}
</body>
</html>
);
}
}

30
frontend/src/app/page.tsx Normal file → Executable file
View file

@ -1,40 +1,34 @@
import HeroSlider from './HeroSlider';
import FacilitySearch from './FacilitySearch';
import { API_URL } from '@/config/constants';
import FacilitySearch from "./FacilitySearch";
import HeroSlider from "./HeroSlider";
import { API_URL } from "@/config/constants";
export const dynamic = 'force-dynamic';
export const dynamic = "force-dynamic";
export default async function Home() {
let facilities = [];
try {
const res = await fetch(`${API_URL}/facilities`, {
const res = await fetch(`${API_URL}/facilities`, {
next: { revalidate: 0 },
cache: 'no-store'
cache: "no-store",
});
if (!res.ok) {
const errorData = await res.json();
console.error("API Error Body:", errorData);
throw new Error(`API returnerte status ${res.status}`);
}
facilities = await res.json();
} catch (error) {
console.error("Kritisk feil ved henting av data:", error);
facilities = [];
facilities = [];
}
// Sikrer at vi alltid sender en array til komponentene
const safeData = Array.isArray(facilities) ? facilities : [];
return (
<main className="min-h-screen bg-[#f1f7ed]">
{/* Wrapper slideren i en div som skjuler den på mobil (hidden) og viser den på PC (md:block) */}
<div className="hidden md:block">
<HeroSlider facilities={safeData} />
</div>
<FacilitySearch initialFacilities={safeData} />
<main className="site-shell min-h-screen">
<HeroSlider facilities={safeData} />
<FacilitySearch initialFacilities={safeData} variant="home" />
</main>
);
}
}

View file

@ -1,41 +1,56 @@
"use client";
import { useState } from 'react';
import Link from 'next/link';
import Image from "next/image";
import { useState } from "react";
import Link from "next/link";
export default function Header() {
const [isOpen, setIsOpen] = useState(false);
const navItems = [
{ href: "/", label: "Hjem" },
{ href: "/golfbaner", label: "Golfbaner" },
];
return (
<header className="sticky top-0 z-[100] bg-white/95 backdrop-blur-md border-b border-gray-100 shadow-sm">
<div className="max-w-[1400px] mx-auto px-6 h-20 flex items-center justify-between">
{/* LOGO */}
<Link href="/" className="h-10 md:h-12 transition-transform hover:scale-105 active:scale-95">
<img src="/TeeOff-logo-Retina-1.png" alt="TeeOff.no" className="h-full w-auto object-contain" />
<header className="sticky top-0 z-[100] border-b border-white/10 bg-[#25312A]/95 text-white shadow-sm backdrop-blur-md">
<div className="mx-auto flex h-20 max-w-[1400px] items-center justify-between px-4 sm:px-6 lg:px-8">
<Link href="/" className="h-10 transition-transform hover:scale-[1.02] active:scale-95 md:h-12">
<Image
src="/TeeOff-logo-Retina-1.png"
alt="TeeOff.no"
width={336}
height={102}
priority
className="h-full w-auto object-contain"
/>
</Link>
{/* DESKTOP NAV */}
<nav className="hidden md:flex items-center gap-8 text-[11px] font-black uppercase tracking-widest text-gray-500">
<Link href="/" className="hover:text-[#8bc34a]">Hjem</Link>
<Link href="/golfbaner" className="hover:text-[#8bc34a]">Finn Bane</Link>
<Link href="/medlemskap" className="hover:text-[#8bc34a]">Medlemskap</Link>
<Link href="/om-oss" className="hover:text-[#8bc34a]">Om oss</Link>
<nav className="hidden items-center gap-8 text-[12px] font-extrabold uppercase tracking-[0.14em] text-white/90 md:flex">
{navItems.map((item) => (
<Link key={item.href} href={item.href} className="transition hover:text-[#8BC34A]">
{item.label}
</Link>
))}
</nav>
{/* HAMBURGER (Mobil) */}
<button onClick={() => setIsOpen(!isOpen)} className="md:hidden p-2 text-[#11280f]">
<div className="w-6 h-0.5 bg-current mb-1.5 transition-all"></div>
<div className="w-6 h-0.5 bg-current mb-1.5"></div>
<div className="w-6 h-0.5 bg-current"></div>
<button onClick={() => setIsOpen(!isOpen)} className="p-2 text-white md:hidden" aria-label="Meny">
<div className="mb-1.5 h-0.5 w-6 bg-current transition-all"></div>
<div className="mb-1.5 h-0.5 w-6 bg-current"></div>
<div className="h-0.5 w-6 bg-current"></div>
</button>
</div>
{/* MOBIL MENY OVERLAY */}
{isOpen && (
<div className="md:hidden absolute top-20 left-0 w-full bg-white border-b border-gray-100 p-6 flex flex-col gap-6 shadow-2xl animate-in slide-in-from-top duration-300">
<Link onClick={() => setIsOpen(false)} href="/" className="text-lg font-black uppercase text-[#11280f]">Hjem</Link>
<Link onClick={() => setIsOpen(false)} href="/golfbaner" className="text-lg font-black uppercase text-[#11280f]">Finn Bane</Link>
<Link onClick={() => setIsOpen(false)} href="/medlemskap" className="text-lg font-black uppercase text-[#11280f]">Medlemskap</Link>
<div className="absolute left-0 top-20 flex w-full flex-col gap-5 border-b border-white/10 bg-[#25312A] px-6 py-6 shadow-2xl md:hidden">
{navItems.map((item) => (
<Link
key={item.href}
onClick={() => setIsOpen(false)}
href={item.href}
className="text-lg font-extrabold uppercase tracking-[0.08em] text-white"
>
{item.label}
</Link>
))}
</div>
)}
</header>