Endringer i Frontend med Codex
This commit is contained in:
parent
b8adf6b365
commit
2630e9a9fa
16 changed files with 1943 additions and 459 deletions
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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
73
backend/scrape_utils.py
Executable 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.")
|
||||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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
837
frontend/src/app/FacilitySearch.tsx
Normal file → Executable 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 nå.</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
219
frontend/src/app/HeroSlider.tsx
Normal file → Executable 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 nå <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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
38
frontend/src/app/golfbaner/page.tsx
Executable file
38
frontend/src/app/golfbaner/page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
30
frontend/src/app/page.tsx
Normal file → Executable 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue