Etter å ha tettet passordlekkasjer og annet

This commit is contained in:
Erol 2026-04-16 09:58:08 +02:00
parent 9e8d622ca4
commit c26f6e8f20
24 changed files with 213 additions and 85 deletions

17
.env.example Normal file
View 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
View file

@ -2,3 +2,10 @@ __pycache__/
*.pyc
*.pyo
.env
backend/.env
.env.*
!.env.example
!backend/.env.example
*.dump
*_dump.txt
kode_eksport_*/

View file

@ -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
View 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
View 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}"

View file

@ -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"""

View file

@ -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"

View file

@ -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 = {

View file

@ -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"

View file

@ -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 ---

View file

@ -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 = {

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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 ""

View file

@ -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)

View file

@ -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:

View file

@ -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"

View file

@ -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)

View file

@ -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)

View file

@ -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"))

View file

@ -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:

View file

@ -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] ${

View file

@ -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);