diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..29ef6e0 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret +PUBLIC_BASE_URL=https://teeoff.no +NEXT_PUBLIC_SITE_URL=https://teeoff.no +PUBLIC_SESSION_SECRET=replace-with-a-long-random-secret +JWT_SECRET=replace-with-a-separate-long-random-secret +PUBLIC_COMMENT_DEFAULT_STATUS=pending +SMTP_SERVER=send.one.com +SMTP_PORT=465 +SMTP_USER=comment@example.com +SMTP_PASS=replace-with-your-smtp-password +PUBLIC_FROM_EMAIL=TeeOff kommentarer +PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES=20 +POSTGRES_USER=teeoff_admin +POSTGRES_PASSWORD=replace-with-your-postgres-password +POSTGRES_DB=teeoff +DATABASE_URL=postgresql://teeoff_admin:replace-with-your-postgres-password@db:5432/teeoff diff --git a/.gitignore b/.gitignore index 9460c99..6420ef0 100755 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,10 @@ __pycache__/ *.pyc *.pyo .env +backend/.env +.env.* +!.env.example +!backend/.env.example +*.dump +*_dump.txt +kode_eksport_*/ diff --git a/backend/.env b/backend/.env deleted file mode 100644 index 5cd6024..0000000 --- a/backend/.env +++ /dev/null @@ -1,6 +0,0 @@ -SMTP_SERVER=send.one.com -SMTP_PORT=465 -SMTP_USER=teeoff@teeoff.no -SMTP_PASS=Shallot Distress43, Serving Smog Hangnail Shower -EMAIL_TO=erol.haagenrud@teeoff.no -GEMINI_API_KEY=AIzaSyDX_WCvZcH3Z8xRpH-XWaoeVYWuE0Wrlog \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..6ef4061 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,8 @@ +SMTP_SERVER=send.one.com +SMTP_PORT=465 +SMTP_USER=teeoff@example.com +SMTP_PASS=replace-with-your-smtp-password +EMAIL_TO=ops@example.com +GEMINI_API_KEY=replace-with-your-gemini-api-key +DATABASE_URL=postgresql://teeoff_admin:replace-with-your-postgres-password@db:5432/teeoff +JWT_SECRET=replace-with-a-long-random-secret diff --git a/backend/env_config.py b/backend/env_config.py new file mode 100644 index 0000000..98b8857 --- /dev/null +++ b/backend/env_config.py @@ -0,0 +1,37 @@ +import os +from pathlib import Path +from urllib.parse import quote + +from dotenv import load_dotenv + +BACKEND_DIR = Path(__file__).resolve().parent +PROJECT_ROOT = BACKEND_DIR.parent + +load_dotenv(PROJECT_ROOT / ".env", override=False) +load_dotenv(BACKEND_DIR / ".env", override=True) + + +def get_required_env(name: str) -> str: + value = str(os.getenv(name, "")).strip() + if not value: + raise RuntimeError(f"Missing required environment variable: {name}") + return value + + +def get_database_url() -> str: + direct = str(os.getenv("DATABASE_URL", "")).strip() + if direct: + return direct + + user = str(os.getenv("POSTGRES_USER", "teeoff_admin")).strip() + password = str(os.getenv("POSTGRES_PASSWORD", "")).strip() + database = str(os.getenv("POSTGRES_DB", "teeoff")).strip() + host = str(os.getenv("POSTGRES_HOST", "db")).strip() + port = str(os.getenv("POSTGRES_PORT", "5432")).strip() + + if not password: + raise RuntimeError( + "Missing database credentials. Set DATABASE_URL or POSTGRES_PASSWORD in your environment." + ) + + return f"postgresql://{user}:{quote(password)}@{host}:{port}/{database}" diff --git a/backend/import_gallery.py b/backend/import_gallery.py index 116aa85..75c5905 100644 --- a/backend/import_gallery.py +++ b/backend/import_gallery.py @@ -2,8 +2,9 @@ import asyncio import asyncpg import urllib.request import json +from env_config import get_database_url -DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff" +DB_URL = get_database_url() async def fetch_json(url): """Hjelpefunksjon for å hente JSON fra en URL""" diff --git a/backend/import_nye_felter.py b/backend/import_nye_felter.py index 51a9ac3..bfdb373 100644 --- a/backend/import_nye_felter.py +++ b/backend/import_nye_felter.py @@ -6,10 +6,11 @@ import os import re from datetime import datetime from dotenv import load_dotenv +from env_config import get_database_url # Laster miljøvariabler load_dotenv() -DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") +DB_URL = get_database_url() # Grunn-URL uten page-parameter WP_API_BASE_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100" @@ -147,4 +148,4 @@ async def run_import(): print(f"\n🎉 Kjøring fullført! Målrettet import for {success_count} anlegg er lagret.") if __name__ == "__main__": - asyncio.run(run_import()) \ No newline at end of file + asyncio.run(run_import()) diff --git a/backend/import_urls.py b/backend/import_urls.py index f0f5ad0..6b6602b 100644 --- a/backend/import_urls.py +++ b/backend/import_urls.py @@ -13,7 +13,9 @@ import asyncpg import os from urllib.parse import urlparse -DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") +from env_config import get_database_url + +DB_URL = get_database_url() # Hvilke filer vi skal lese, og hvilke databasefelt de tilhører FILES_TO_IMPORT = { @@ -100,4 +102,4 @@ async def run_import(): print("\n🏁 Import fullført!") if __name__ == "__main__": - asyncio.run(run_import()) \ No newline at end of file + asyncio.run(run_import()) diff --git a/backend/import_wp.py b/backend/import_wp.py index 3eac73e..78c74cb 100644 --- a/backend/import_wp.py +++ b/backend/import_wp.py @@ -1,7 +1,8 @@ import asyncio, asyncpg, urllib.request, json, re, os, requests +from env_config import get_database_url # --- KONFIGURASJON --- -DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff" +DB_URL = get_database_url() WP_API_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100&_embed" MEDIA_ENDPOINT = "https://teeoff.no/wp-json/wp/v2/media" MEDIA_DIR = "./public/media" @@ -154,4 +155,4 @@ async def run_master_import(): print("✅ IMPORT FERDIG!") if __name__ == "__main__": - asyncio.run(run_master_import()) \ No newline at end of file + asyncio.run(run_master_import()) diff --git a/backend/main.py b/backend/main.py index 57ca05c..43c5c4c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -27,7 +27,6 @@ from email.message import EmailMessage from pathlib import Path from jose import jwt, JWTError from passlib.context import CryptContext -from dotenv import load_dotenv import qrcode import qrcode.image.svg import httpx @@ -42,12 +41,11 @@ from scrape_jobs import ( ensure_scrape_jobs_table, list_scrape_jobs, ) - -load_dotenv() +from env_config import get_database_url, get_required_env # --- KONFIGURASJON --- -DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") -SECRET_KEY = os.getenv("JWT_SECRET", "super_secret_change_this_in_production") +DB_URL = get_database_url() +SECRET_KEY = get_required_env("JWT_SECRET") ALGORITHM = "HS256" PUBLIC_SESSION_SECRET = os.getenv("PUBLIC_SESSION_SECRET", SECRET_KEY) PUBLIC_SESSION_COOKIE = "teeoff_user_session" @@ -992,7 +990,7 @@ async def ensure_public_user_tables(conn): async def lifespan(app: FastAPI): # Opprett database-pool ved start try: - print(f"📡 Forsøker å koble til database på: {DB_URL}") + print("📡 Forsøker å koble til database") app.state.pool = await asyncpg.create_pool( DB_URL, min_size=5, @@ -1049,8 +1047,6 @@ async def require_admin_session_for_admin_routes(request: Request, call_next): @app.post("/api/auth/login") async def login(data: dict): """Steg 1: Sjekk passord og returner temp_token for 2FA.""" - print(f"🔐 Loggin-forsøk for: {data.get('username')}") - async with app.state.pool.acquire() as conn: admin = await conn.fetchrow( "SELECT * FROM admins WHERE username = $1 OR email = $1", @@ -1058,27 +1054,21 @@ async def login(data: dict): ) if not admin: - print(" - ❌ Bruker ikke funnet i databasen") raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord") - - h = admin['password_hash'] - print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)") - + try: - is_valid = pwd_context.verify(data.get('password'), h) + is_valid = pwd_context.verify(data.get('password'), admin['password_hash']) except Exception as e: - print(f" - 🔥 FEIL VED LESING AV HASH: {e}") + print("❌ Kunne ikke verifisere admin-passord") raise HTTPException(status_code=500, detail="Internt problem med passord-format") if not is_valid: - print(" - ❌ Passordet samsvarer ikke med hashen") raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord") temp_token = jwt.encode( {"sub": admin['username'], "partial": True, "exp": datetime.utcnow() + timedelta(minutes=5)}, SECRET_KEY, algorithm=ALGORITHM ) - print(" - ✅ Steg 1 fullført. Temp-token generert.") return {"step": "2fa", "temp_token": temp_token} @app.post("/api/auth/verify-2fa") @@ -1097,7 +1087,7 @@ async def verify_2fa(data: dict, response: Response): totp = pyotp.TOTP(admin['otp_secret']) if not totp.verify(data.get('code')): - print(f" - ❌ Feil 2FA-kode oppgitt for {username}") + print("❌ Ugyldig 2FA-kode ved admin-innlogging") raise HTTPException(status_code=401, detail="Feil 2FA-kode") final_token = jwt.encode( @@ -1899,7 +1889,7 @@ async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdat except Exception as e: if isinstance(e, HTTPException): raise e - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="Kunne ikke oppdatere skrapeinnstillingene") # --- NYTT ADMIN ENDPOINT FOR FULL OPPDATERING (JSON-EDITOR) --- @app.put("/api/admin/facilities/{facility_id}/full") @@ -2110,7 +2100,8 @@ async def health_check(): await conn.execute("SELECT 1") return {"status": "healthy", "database": "connected"} except Exception as e: - return {"status": "unhealthy", "error": str(e)} + print(f"❌ Health check feilet: {type(e).__name__}") + return {"status": "unhealthy", "error": "database_unavailable"} # --- MEDLEMSKAP "VASKERI" ENDEPUNKTER --- diff --git a/backend/scrape_golfamore1.3.py b/backend/scrape_golfamore1.3.py index fd418e0..4b16e5e 100644 --- a/backend/scrape_golfamore1.3.py +++ b/backend/scrape_golfamore1.3.py @@ -2,8 +2,9 @@ import asyncio import asyncpg import json import re +from env_config import get_database_url -DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff" +DB_URL = get_database_url() # Data hentet direkte fra bildet du sendte GOLFAMORE_DATA = { diff --git a/backend/scrape_golfpakker.py b/backend/scrape_golfpakker.py index 6142a5d..4cd3cec 100644 --- a/backend/scrape_golfpakker.py +++ b/backend/scrape_golfpakker.py @@ -16,13 +16,14 @@ import asyncpg import google.generativeai as genai from bs4 import BeautifulSoup from dotenv import load_dotenv +from env_config import get_database_url from playwright.async_api import async_playwright from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json load_dotenv() -DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") +DB_URL = get_database_url() GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") if not GEMINI_API_KEY: diff --git a/backend/scrape_greenfee.py b/backend/scrape_greenfee.py index 6c58818..aca917d 100644 --- a/backend/scrape_greenfee.py +++ b/backend/scrape_greenfee.py @@ -15,11 +15,12 @@ from bs4 import BeautifulSoup from playwright.async_api import async_playwright import google.generativeai as genai from dotenv import load_dotenv +from env_config import get_database_url from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json load_dotenv() -DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") +DB_URL = get_database_url() GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") if not GEMINI_API_KEY: diff --git a/backend/scrape_membership.py b/backend/scrape_membership.py index 7761fe8..5aba13b 100644 --- a/backend/scrape_membership.py +++ b/backend/scrape_membership.py @@ -16,11 +16,12 @@ from bs4 import BeautifulSoup from playwright.async_api import async_playwright import google.generativeai as genai from dotenv import load_dotenv +from env_config import get_database_url from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json load_dotenv() -DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") +DB_URL = get_database_url() GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") if not GEMINI_API_KEY: diff --git a/backend/scrape_nsg_3.py b/backend/scrape_nsg_3.py index 190b12d..a4332ec 100644 --- a/backend/scrape_nsg_3.py +++ b/backend/scrape_nsg_3.py @@ -4,8 +4,9 @@ import httpx from bs4 import BeautifulSoup import re import json +from env_config import get_database_url -DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff" +DB_URL = get_database_url() def clean_name(text): if not text: return "" diff --git a/backend/scrape_status.py b/backend/scrape_status.py index 2ab626f..d731549 100644 --- a/backend/scrape_status.py +++ b/backend/scrape_status.py @@ -15,11 +15,12 @@ except ImportError: from google import genai from dotenv import load_dotenv +from env_config import get_database_url from scrape_utils import ProgressCallback, emit_progress, make_progress_event load_dotenv() -DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") +DB_URL = get_database_url() # ========================================== # KONFIGURERER GEMINI AI (NY SDK) diff --git a/backend/scrape_vtg.py b/backend/scrape_vtg.py index 6e5e8dc..3c172c5 100644 --- a/backend/scrape_vtg.py +++ b/backend/scrape_vtg.py @@ -15,11 +15,12 @@ from bs4 import BeautifulSoup from playwright.async_api import async_playwright import google.generativeai as genai from dotenv import load_dotenv +from env_config import get_database_url from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json load_dotenv() -DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") +DB_URL = get_database_url() GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") if not GEMINI_API_KEY: diff --git a/backend/sync_greenfee.py b/backend/sync_greenfee.py index 745a80e..13313a9 100644 --- a/backend/sync_greenfee.py +++ b/backend/sync_greenfee.py @@ -1,6 +1,7 @@ import asyncio, asyncpg, urllib.request, json +from env_config import get_database_url -DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff" +DB_URL = get_database_url() # Vi fjerner acf_format=standard da rå-feltnavnene er tryggere her WP_API_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100" @@ -76,4 +77,4 @@ async def run_greenfee_sync(): print(f"\n✨ Ferdig! Oppdaterte priser for {total_updated} anlegg.") if __name__ == "__main__": - asyncio.run(run_greenfee_sync()) \ No newline at end of file + asyncio.run(run_greenfee_sync()) diff --git a/backend/test_login.py b/backend/test_login.py index f026cbf..93606f9 100644 --- a/backend/test_login.py +++ b/backend/test_login.py @@ -2,8 +2,9 @@ import asyncio import asyncpg import os from passlib.context import CryptContext +from env_config import get_database_url, get_required_env -DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") +DB_URL = get_database_url() # Vi setter opp passord-sjekkeren AKKURAT slik main.py gjør det pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") @@ -13,8 +14,8 @@ async def test_sannheten(): print(" 🔍 TEE OFF SANNHETSSERUM") print("="*50) - username = "Envide Webutvikling" - test_password = "Solveig Vilde Ingvild Gina" # Sørg for at dette er det du satte sist! + username = os.getenv("TEST_ADMIN_USERNAME", "Envide Webutvikling").strip() + test_password = get_required_env("TEST_ADMIN_PASSWORD") try: conn = await asyncpg.connect(DB_URL) @@ -44,4 +45,4 @@ async def test_sannheten(): await conn.close() if __name__ == "__main__": - asyncio.run(test_sannheten()) \ No newline at end of file + asyncio.run(test_sannheten()) diff --git a/backend/update_admin.py b/backend/update_admin.py index 4883f01..6b4d697 100644 --- a/backend/update_admin.py +++ b/backend/update_admin.py @@ -12,9 +12,10 @@ import os import sys import getpass from passlib.hash import pbkdf2_sha256 +from env_config import get_database_url # Henter database-URL fra miljøvariabler (samme metode som backenden din bruker) -DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") +DB_URL = get_database_url() async def update_admin_password(): print("\n" + "="*50) @@ -82,4 +83,4 @@ if __name__ == "__main__": asyncio.run(update_admin_password()) except KeyboardInterrupt: print("\nAvbrutt.") - sys.exit(0) \ No newline at end of file + sys.exit(0) diff --git a/backend/worker.py b/backend/worker.py index 49ea47d..d510b0b 100755 --- a/backend/worker.py +++ b/backend/worker.py @@ -3,8 +3,8 @@ import os import traceback import asyncpg -from dotenv import load_dotenv +from env_config import get_database_url from scrape_job_runner import run_scrape_job from scrape_jobs import ( classify_scrape_error, @@ -17,9 +17,7 @@ from scrape_jobs import ( update_scrape_job_progress, ) -load_dotenv() - -DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") +DB_URL = get_database_url() WORKER_NAME = os.getenv("SCRAPE_WORKER_NAME", f"scrape-worker-{os.getpid()}") POLL_INTERVAL_SECONDS = int(os.getenv("SCRAPE_WORKER_POLL_INTERVAL", "5")) HEARTBEAT_INTERVAL_SECONDS = int(os.getenv("SCRAPE_WORKER_HEARTBEAT_INTERVAL", "15")) diff --git a/docker-compose.yml b/docker-compose.yml index d83ede0..d050618 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,9 +3,9 @@ services: image: postgis/postgis:15-3.4 container_name: teeoff_db environment: - POSTGRES_USER: teeoff_admin - POSTGRES_PASSWORD: teeoff_secret_password - POSTGRES_DB: teeoff + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} ports: - "5433:5432" volumes: @@ -16,6 +16,8 @@ services: build: ./backend container_name: teeoff_api environment: + DATABASE_URL: ${DATABASE_URL} + JWT_SECRET: ${JWT_SECRET} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} PUBLIC_BASE_URL: ${PUBLIC_BASE_URL} @@ -42,6 +44,8 @@ services: build: ./backend container_name: teeoff_worker command: python worker.py + environment: + DATABASE_URL: ${DATABASE_URL} volumes: - ./backend:/app depends_on: diff --git a/frontend/src/app/FacilitySearch.tsx b/frontend/src/app/FacilitySearch.tsx index 8b4bfdf..59dcd68 100755 --- a/frontend/src/app/FacilitySearch.tsx +++ b/frontend/src/app/FacilitySearch.tsx @@ -17,6 +17,7 @@ type Facility = { id: number; slug: string; name: string; + architect?: string | null; description?: string | null; city?: string | null; county?: string | null; @@ -58,10 +59,10 @@ type SpecialFlags = { hasGolfamore: boolean; hasNSG: boolean; hasSimulator: boolean; - hasDrivingRange: boolean; - hasVtg: boolean; }; +const HIDDEN_COUNTY_SLUGS = new Set(["innlandet", "viken"]); + const AREA_GROUPS: Record = { "nord-norge": ["finnmark", "troms", "nordland"], "midt-norge": ["trondelag", "nord-trondelag", "sor-trondelag"], @@ -81,9 +82,9 @@ const HIERARCHICAL_AREA_OPTIONS = [ { value: "county:finnmark", label: "\u00A0\u00A0\u00A0Finnmark" }, { value: "county:troms", label: "\u00A0\u00A0\u00A0Troms" }, { value: "county:nordland", label: "\u00A0\u00A0\u00A0Nordland" }, + { value: "county:trondelag", label: "Trøndelag" }, { 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" }, @@ -103,8 +104,6 @@ const HIERARCHICAL_AREA_OPTIONS = [ { 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 = [ @@ -319,8 +318,6 @@ const matchesSpecialFilter = (specialFilter: string, flags: SpecialFlags) => { 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; }; @@ -353,6 +350,8 @@ export default function FacilitySearch({ const [statusFilter, setStatusFilter] = useState(""); const [holeFilter, setHoleFilter] = useState(""); const [specialFilter, setSpecialFilter] = useState(""); + const [architectFilter, setArchitectFilter] = useState(""); + const [facilityFilter, setFacilityFilter] = useState(""); const [sortMethod, setSortMethod] = useState("updated"); const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null); @@ -383,7 +382,7 @@ export default function FacilitySearch({ 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); + if (label && slug && !HIDDEN_COUNTY_SLUGS.has(slug) && !unique.has(slug)) unique.set(slug, label); } return Array.from(unique.entries()) @@ -410,6 +409,30 @@ export default function FacilitySearch({ return options; }, [countyOptions]); + const architectOptions = useMemo(() => { + const unique = new Map(); + + for (const facility of Array.isArray(initialFacilities) ? initialFacilities : []) { + const label = String(facility?.architect || "").trim(); + const key = normalizeText(label); + if (label && key && !unique.has(key)) unique.set(key, label); + } + + return Array.from(unique.entries()) + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label, "nb")); + }, [initialFacilities]); + + const facilityOptions = useMemo(() => { + return (Array.isArray(initialFacilities) ? initialFacilities : []) + .filter((facility) => facility?.slug && facility?.name) + .map((facility) => ({ + value: facility.slug, + label: facility.name, + })) + .sort((a, b) => a.label.localeCompare(b.label, "nb")); + }, [initialFacilities]); + const processedFacilities = useMemo(() => { if (!Array.isArray(initialFacilities)) return []; @@ -420,7 +443,6 @@ export default function FacilitySearch({ const amenities = parseJson>(facility.amenities, {}); const golfamoreData = parseJson>(facility.golfamore_data, {}); const nsgData = parseJson>(facility.nsg_data, {}); - const vtgDates = parseJson(facility.vtg_datoer, []); const rawStatuses = parseJson(facility.course_statuses, []); const statuses = Array.isArray(rawStatuses) && rawStatuses.length > 0 @@ -435,12 +457,7 @@ export default function FacilitySearch({ 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 architectKey = normalizeText(facility.architect || ""); const updatedTsRaw = facility.status_updated_at ? new Date(facility.status_updated_at).getTime() : 0; const lastUpdatedTs = Number.isFinite(updatedTsRaw) ? updatedTsRaw : 0; @@ -454,6 +471,7 @@ export default function FacilitySearch({ facility.city, facility.county, facility.banetype, + facility.architect, holeValue, ...statuses.map((status) => status.name), ...regions, @@ -464,8 +482,6 @@ export default function FacilitySearch({ 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"; @@ -490,9 +506,9 @@ export default function FacilitySearch({ hasGolfamore, hasNSG, hasSimulator, - hasDrivingRange, - hasVtg, }); + const matchesArchitect = !architectFilter || architectKey === architectFilter; + const matchesFacility = !facilityFilter || facility.slug === facilityFilter; return { ...facility, @@ -500,7 +516,6 @@ export default function FacilitySearch({ primaryStatus, hasGolfamore, hasNSG, - hasVtg, distance, lastUpdatedTs, matchesSearch, @@ -508,6 +523,8 @@ export default function FacilitySearch({ matchesStatus, matchesHoles, matchesSpecial, + matchesArchitect, + matchesFacility, }; }) .filter( @@ -516,7 +533,9 @@ export default function FacilitySearch({ facility.matchesArea && facility.matchesStatus && facility.matchesHoles && - facility.matchesSpecial + facility.matchesSpecial && + facility.matchesArchitect && + facility.matchesFacility ) .sort((a, b) => { if (sortMethod === "dist") { @@ -529,9 +548,17 @@ export default function FacilitySearch({ } return a.name.localeCompare(b.name, "nb"); }); - }, [areaFilter, holeFilter, initialFacilities, searchQuery, sortMethod, specialFilter, statusFilter, userLocation]); + }, [areaFilter, architectFilter, facilityFilter, holeFilter, initialFacilities, searchQuery, sortMethod, specialFilter, statusFilter, userLocation]); - const filtersCount = [areaFilter, statusFilter, holeFilter, specialFilter, searchQuery.trim()].filter(Boolean).length; + const filtersCount = [ + areaFilter, + statusFilter, + holeFilter, + specialFilter, + architectFilter, + facilityFilter, + searchQuery.trim(), + ].filter(Boolean).length; const summaryText = `${processedFacilities.length} baner • ${getAreaLabel(areaFilter, countyOptions)}${ filtersCount > 0 ? ` • ${filtersCount} aktive filtre` : "" }`; @@ -572,11 +599,13 @@ export default function FacilitySearch({ + + - + @@ -589,12 +618,38 @@ export default function FacilitySearch({ - - -
+
+ + + {architectOptions.map((option) => ( + + ))} + + + + + {facilityOptions.map((option) => ( + + ))} + +
); -} \ No newline at end of file +}