Etter å ha tettet passordlekkasjer og annet
This commit is contained in:
parent
9e8d622ca4
commit
c26f6e8f20
24 changed files with 213 additions and 85 deletions
17
.env.example
Normal file
17
.env.example
Normal file
|
|
@ -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 <comment@example.com>
|
||||
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
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -2,3 +2,10 @@ __pycache__/
|
|||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
backend/.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!backend/.env.example
|
||||
*.dump
|
||||
*_dump.txt
|
||||
kode_eksport_*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
8
backend/.env.example
Normal file
8
backend/.env.example
Normal file
|
|
@ -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
|
||||
37
backend/env_config.py
Normal file
37
backend/env_config.py
Normal file
|
|
@ -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}"
|
||||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<string, string[]> = {
|
||||
"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<SortMethod>("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<string, string>();
|
||||
|
||||
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<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
|
||||
|
|
@ -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({
|
|||
<option value="stenger_snart">Stenger snart</option>
|
||||
<option value="aapner_snart">Åpner snart</option>
|
||||
<option value="stengt">Stengt</option>
|
||||
<option value="under_utvikling">Under utvikling</option>
|
||||
<option value="nedlagt">Nedlagt</option>
|
||||
<option value="ukjent">Ukjent status</option>
|
||||
</FieldSelect>
|
||||
|
||||
<FieldSelect label="Antall hull" value={holeFilter} onChange={setHoleFilter} labelClassName={labelClassName}>
|
||||
<option value="">Alle golfbaner</option>
|
||||
<option value="">Alle antall hull</option>
|
||||
<option value="18-plus">18 hull eller mer</option>
|
||||
<option value="18">Nøyaktig 18 hull</option>
|
||||
<option value="9">9 hull</option>
|
||||
|
|
@ -589,12 +618,38 @@ export default function FacilitySearch({
|
|||
<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]">
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_minmax(0,1fr)_220px_auto]">
|
||||
<FieldSelect
|
||||
label="Arkitekt"
|
||||
value={architectFilter}
|
||||
onChange={setArchitectFilter}
|
||||
labelClassName={labelClassName}
|
||||
>
|
||||
<option value="">Alle arkitekter</option>
|
||||
{architectOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
|
||||
<FieldSelect
|
||||
label="Golfanlegg"
|
||||
value={facilityFilter}
|
||||
onChange={setFacilityFilter}
|
||||
labelClassName={labelClassName}
|
||||
>
|
||||
<option value="">Alle golfanlegg</option>
|
||||
{facilityOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
|
||||
<FieldInput
|
||||
label="Søk"
|
||||
value={searchQuery}
|
||||
|
|
@ -622,6 +677,8 @@ export default function FacilitySearch({
|
|||
setStatusFilter("");
|
||||
setHoleFilter("");
|
||||
setSpecialFilter("");
|
||||
setArchitectFilter("");
|
||||
setFacilityFilter("");
|
||||
setSortMethod(userLocation ? "dist" : "updated");
|
||||
}}
|
||||
className={`btn btn-md mt-[1.72rem] h-[52px] ${
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ export default function AdminLogin() {
|
|||
} else {
|
||||
setError(data.detail || 'Ugyldig pålogging');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("🔥 DEN EKTE FEILEN ER:", err);
|
||||
} catch {
|
||||
console.error("Admin login request failed");
|
||||
setError('Systemfeil: Kunne ikke koble til API-et');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
|
|
|||
Loading…
Reference in a new issue