From e2f94dcaaa141643c9642a7c3f0b262970740167 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Fri, 17 Apr 2026 09:25:32 +0200 Subject: [PATCH] Etter dag 1 ny server --- .env.example | 17 -- backend/bootstrap_admin_access.py | 138 +++++++++ backend/main.py | 80 ++++-- bilde1.png | Bin 0 -> 17636 bytes deploy/Caddyfile | 32 +++ docker-compose.prod.yml | 95 +++++++ docs/vps-deploy-teeoff.md | 141 +++++++++ frontend/.nvmrc | 1 + frontend/Dockerfile | 11 +- frontend/package.json | 6 +- frontend/src/app/FacilitySearch.tsx | 9 +- frontend/src/app/admin/login/page.tsx | 17 +- .../[slug]/FacilityDetailLeafletMap.tsx | 86 ++++++ .../golfbaner/[slug]/FacilityDetailView.tsx | 113 +++----- frontend/src/components/PlaceMap.tsx | 208 +------------- frontend/src/components/PlaceMapLeaflet.tsx | 267 ++++++++++++++++++ init.sql | 2 +- ...2026-04-17_add_cooperating_clubs_jsonb.sql | 47 +++ schema.sql | 1 + 19 files changed, 958 insertions(+), 313 deletions(-) delete mode 100644 .env.example create mode 100644 backend/bootstrap_admin_access.py create mode 100644 bilde1.png create mode 100644 deploy/Caddyfile create mode 100644 docker-compose.prod.yml create mode 100644 docs/vps-deploy-teeoff.md create mode 100644 frontend/.nvmrc create mode 100644 frontend/src/app/golfbaner/[slug]/FacilityDetailLeafletMap.tsx create mode 100644 frontend/src/components/PlaceMapLeaflet.tsx create mode 100644 migrations/2026-04-17_add_cooperating_clubs_jsonb.sql diff --git a/.env.example b/.env.example deleted file mode 100644 index 29ef6e0..0000000 --- a/.env.example +++ /dev/null @@ -1,17 +0,0 @@ -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/backend/bootstrap_admin_access.py b/backend/bootstrap_admin_access.py new file mode 100644 index 0000000..0c960be --- /dev/null +++ b/backend/bootstrap_admin_access.py @@ -0,0 +1,138 @@ +""" +TEE OFF ADMIN ACCESS BOOTSTRAP +--------------------------------------------------------------------------- +FUNKSJON: Oppretter eller oppdaterer én administrator uten å påvirke andre. + Passord leses skjult fra terminalen, og 2FA kan genereres/roteres. +STATUS: Trygg erstatning for create_admin.py når flere admins skal eksistere. +--------------------------------------------------------------------------- +""" +import argparse +import asyncio +import getpass +import sys + +import asyncpg +import pyotp +from passlib.hash import pbkdf2_sha256 + +from env_config import get_database_url + +DB_URL = get_database_url() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Opprett eller oppdater en admin-bruker trygt.") + parser.add_argument("--username", required=True, help="Brukernavn for admin-brukeren") + parser.add_argument("--email", required=True, help="E-post for admin-brukeren") + parser.add_argument( + "--rotate-2fa", + action="store_true", + help="Generer en ny 2FA-hemmelighet selv om brukeren allerede har en", + ) + return parser.parse_args() + + +async def bootstrap_admin() -> None: + args = parse_args() + username = args.username.strip() + email = args.email.strip().lower() + + if not username: + print("❌ Brukernavn kan ikke være tomt.") + sys.exit(1) + if "@" not in email: + print("❌ E-postadressen ser ugyldig ut.") + sys.exit(1) + + while True: + password = getpass.getpass("Skriv inn passord: ") + password_confirm = getpass.getpass("Gjenta passord: ") + + if password != password_confirm: + print("❌ Passordene er ikke like. Prøv igjen.\n") + continue + + if len(password) < 8: + print("⚠️ Advarsel: Passordet bør være minst 8 tegn.") + break + + password_hash = pbkdf2_sha256.hash(password) + + conn = None + try: + conn = await asyncpg.connect(DB_URL) + existing = await conn.fetchrow( + """ + SELECT id, username, email, otp_secret + FROM admins + WHERE username = $1 OR email = $2 + ORDER BY id ASC + LIMIT 1 + """, + username, + email, + ) + + otp_secret = pyotp.random_base32() if (args.rotate_2fa or not existing or not existing["otp_secret"]) else existing["otp_secret"] + + if existing: + await conn.execute( + """ + UPDATE admins + SET username = $1, + email = $2, + password_hash = $3, + otp_secret = $4 + WHERE id = $5 + """, + username, + email, + password_hash, + otp_secret, + existing["id"], + ) + action = "oppdatert" + else: + await conn.execute( + """ + INSERT INTO admins (username, email, password_hash, otp_secret) + VALUES ($1, $2, $3, $4) + """, + username, + email, + password_hash, + otp_secret, + ) + action = "opprettet" + except asyncpg.UniqueViolationError: + print("❌ Brukernavn eller e-post er allerede i bruk av en annen admin.") + sys.exit(1) + except Exception as exc: + print(f"❌ Kunne ikke bootstrappe admin-brukeren: {type(exc).__name__}") + sys.exit(1) + finally: + if conn is not None: + await conn.close() + + provisioning_uri = pyotp.TOTP(otp_secret).provisioning_uri( + name=email or username, + issuer_name="TeeOff.no", + ) + + print("\n✅ ADMIN BRUKER KLAR") + print("-" * 50) + print(f"Brukeren '{username}' er {action}.") + print("Passordet ble satt skjult i terminalen.") + print("Legg inn denne 2FA-hemmeligheten i authenticator-appen:") + print(f"2FA-nøkkel: {otp_secret}") + print("Alternativt kan du bruke provisioning URI:") + print(provisioning_uri) + print("-" * 50 + "\n") + + +if __name__ == "__main__": + try: + asyncio.run(bootstrap_admin()) + except KeyboardInterrupt: + print("\nAvbrutt.") + sys.exit(0) diff --git a/backend/main.py b/backend/main.py index 43c5c4c..fb99094 100644 --- a/backend/main.py +++ b/backend/main.py @@ -80,6 +80,8 @@ def get_int_env(name: str, default: int) -> int: return default +ADMIN_SESSION_MAX_AGE_SECONDS = get_int_env("ADMIN_SESSION_MAX_AGE_SECONDS", 60 * 60 * 12) +ADMIN_REMEMBER_ME_MAX_AGE_SECONDS = get_int_env("ADMIN_REMEMBER_ME_MAX_AGE_SECONDS", 60 * 60 * 24 * 30) PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES = get_int_env("PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES", 20) PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS = get_int_env("PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS", 60) @@ -479,6 +481,19 @@ async def resolve_cooperating_club_slugs( return resolved_slugs +async def get_table_columns(conn, table_name: str, schema_name: str = "public") -> set[str]: + rows = await conn.fetch( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 + """, + schema_name, + table_name, + ) + return {str(row["column_name"]) for row in rows} + + def generate_totp_qr_svg(provisioning_uri: str) -> str: image = qrcode.make( provisioning_uri, @@ -1047,6 +1062,8 @@ 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.""" + remember_me = bool(data.get("remember_me") or data.get("rememberMe")) + async with app.state.pool.acquire() as conn: admin = await conn.fetchrow( "SELECT * FROM admins WHERE username = $1 OR email = $1", @@ -1066,19 +1083,25 @@ async def login(data: dict): 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)}, + { + "sub": admin['username'], + "partial": True, + "remember_me": remember_me, + "exp": datetime.utcnow() + timedelta(minutes=5), + }, SECRET_KEY, algorithm=ALGORITHM ) return {"step": "2fa", "temp_token": temp_token} @app.post("/api/auth/verify-2fa") -async def verify_2fa(data: dict, response: Response): +async def verify_2fa(data: dict, response: Response, request: Request): """Steg 2: Verifiser TOTP-kode og sett session cookie.""" try: payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM]) if not payload.get("partial"): raise JWTError() username = payload.get("sub") + remember_me = bool(payload.get("remember_me")) except JWTError: raise HTTPException(status_code=401, detail="Sesjonen har utløpt eller er ugyldig") @@ -1090,29 +1113,36 @@ async def verify_2fa(data: dict, response: Response): print("❌ Ugyldig 2FA-kode ved admin-innlogging") raise HTTPException(status_code=401, detail="Feil 2FA-kode") + session_max_age = ( + ADMIN_REMEMBER_ME_MAX_AGE_SECONDS + if remember_me + else ADMIN_SESSION_MAX_AGE_SECONDS + ) final_token = jwt.encode( - {"sub": username, "exp": datetime.utcnow() + timedelta(hours=12)}, + {"sub": username, "exp": datetime.utcnow() + timedelta(seconds=session_max_age)}, SECRET_KEY, algorithm=ALGORITHM ) - + # Sett som HTTP-only cookie response.set_cookie( key="admin_session", value=final_token, + max_age=session_max_age, + expires=session_max_age, httponly=True, samesite="lax", - secure=False # Sett til True i produksjon (HTTPS) + secure=should_use_secure_cookies(request), ) return {"status": "success"} @app.post("/api/auth/logout") -async def logout(response: Response): +async def logout(response: Response, request: Request): """Logger ut admin ved å slette sesjonscookien.""" response.delete_cookie( key="admin_session", httponly=True, samesite="lax", - secure=False, + secure=should_use_secure_cookies(request), ) return {"status": "success"} @@ -1927,6 +1957,8 @@ async def update_facility_full(facility_id: int, request: Request): async with app.state.pool.acquire() as conn: async with conn.transaction(): # Sikrer at alt lagres samlet + facility_columns = await get_table_columns(conn, "facilities") + update_data = {k: v for k, v in update_data.items() if k in facility_columns} # 1. OPPDATER ANLEGG (FACILITIES) if update_data: @@ -2196,6 +2228,9 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest): """Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet.""" async with app.state.pool.acquire() as conn: async with conn.transaction(): + facility_columns = await get_table_columns(conn, "facilities") + has_cooperating_clubs = "cooperating_clubs" in facility_columns + for approval in request.approvals: draft_row = await conn.fetchrow( "SELECT greenfee_draft FROM facilities WHERE id = $1", @@ -2210,17 +2245,26 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest): exclude_facility_id=approval.facility_id, ) - await conn.execute(""" - UPDATE facilities - SET greenfee = $1::jsonb, - cooperating_clubs = CASE - WHEN $2::jsonb = '[]'::jsonb THEN cooperating_clubs - ELSE $2::jsonb - END, - greenfee_updated_at = NOW(), - greenfee_draft = NULL - WHERE id = $3 - """, json.dumps(approval.greenfee), json.dumps(cooperating_club_slugs), approval.facility_id) + if has_cooperating_clubs: + await conn.execute(""" + UPDATE facilities + SET greenfee = $1::jsonb, + cooperating_clubs = CASE + WHEN $2::jsonb = '[]'::jsonb THEN cooperating_clubs + ELSE $2::jsonb + END, + greenfee_updated_at = NOW(), + greenfee_draft = NULL + WHERE id = $3 + """, json.dumps(approval.greenfee), json.dumps(cooperating_club_slugs), approval.facility_id) + else: + await conn.execute(""" + UPDATE facilities + SET greenfee = $1::jsonb, + greenfee_updated_at = NOW(), + greenfee_draft = NULL + WHERE id = $2 + """, json.dumps(approval.greenfee), approval.facility_id) return {"status": "success"} @app.post("/api/admin/run-greenfee-scraper") diff --git a/bilde1.png b/bilde1.png new file mode 100644 index 0000000000000000000000000000000000000000..d8b43f84e2d1b2ba0a22529b00473cec9a5f0436 GIT binary patch literal 17636 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 `85.137.228.98` +- `CNAME` for `www` -> `teeoff.no` + +If `teeoff.no` currently has old `A`, `AAAA`, web-forward, or alias records pointing elsewhere, remove or replace them. + +If you do not actively use IPv6 on the VPS, remove any stale `AAAA` record for `teeoff.no` and `www`. + +one.com documents A/CNAME management here: + +- https://help.one.com/hc/en-us/articles/360000799298-How-do-I-create-an-A-record +- https://help.one.com/hc/en-us/articles/360000803517-How-do-I-create-a-CNAME-record + +## 2. Required environment values + +On the VPS, create `.env` in the project root and make sure these values are correct: + +```env +PUBLIC_BASE_URL=https://teeoff.no +NEXT_PUBLIC_SITE_URL=https://teeoff.no +DATABASE_URL=postgresql://teeoff_admin:...@db:5432/teeoff +POSTGRES_USER=teeoff_admin +POSTGRES_PASSWORD=... +POSTGRES_DB=teeoff +JWT_SECRET=... +PUBLIC_SESSION_SECRET=... +ACME_EMAIL=you@example.com +``` + +If public comments or Google login are used, keep the SMTP and Google OAuth values configured too. + +Important Google OAuth update: + +- Authorized redirect URI should include `https://teeoff.no/api/public/auth/google/callback` + +## 3. Start the production stack + +Use the production compose file: + +```bash +docker compose -f docker-compose.prod.yml up -d --build +``` + +This stack exposes only: + +- `80/tcp` +- `443/tcp` + +The app containers stay internal and are reached through Caddy only. + +## 4. Reverse proxy layout + +The Caddy config is in `deploy/Caddyfile`. + +Routing is: + +- `https://teeoff.no/api/admin/uploads/images` -> Next.js +- all other `https://teeoff.no/api/*` -> FastAPI +- everything else -> Next.js frontend + +That exception matters because the image upload endpoint is implemented in Next.js, while the rest of the API lives in FastAPI. +The `/api` prefix must be preserved when proxying, because both apps define routes with `/api/...` paths. + +## 5. VPS hardening + +Recommended minimum host steps on Ubuntu 24.04: + +```bash +sudo apt update && sudo apt upgrade -y +sudo apt install -y ufw fail2ban +sudo ufw allow OpenSSH +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw enable +``` + +Important Docker note: published Docker ports can bypass `ufw` if you expose them directly on the host. That is why the production compose file publishes only Caddy's `80` and `443`. + +Useful official references: + +- Ubuntu UFW docs: https://ubuntu.com/server/docs/how-to/security/firewalls/ +- Docker firewall warning: https://docs.docker.com/engine/network/packet-filtering-firewalls/ +- Docker port publishing warning: https://docs.docker.com/engine/network/port-publishing/ +- Caddy automatic HTTPS: https://caddyserver.com/docs/automatic-https + +## 6. SSH hardening + +Do this after you have verified SSH key login works: + +- create a non-root sudo user if you do not already use one +- disable password authentication in `/etc/ssh/sshd_config` +- disable root SSH login if you do not need it +- restart SSH + +Typical settings: + +```text +PasswordAuthentication no +PermitRootLogin no +PubkeyAuthentication yes +``` + +Then: + +```bash +sudo systemctl restart ssh +``` + +## 7. Verification checklist + +After DNS has propagated and the stack is up: + +```bash +curl -I http://teeoff.no +curl -I https://teeoff.no +curl -I https://www.teeoff.no +curl -I https://teeoff.no/api/health +``` + +Expected results: + +- `http://teeoff.no` redirects to HTTPS +- `https://www.teeoff.no` redirects to `https://teeoff.no` +- `https://teeoff.no/api/health` returns the API health payload + +## 8. Existing compose file + +`docker-compose.yml` is still useful for the earlier/local setup. + +For the VPS cutover, prefer: + +```bash +docker compose -f docker-compose.prod.yml up -d --build +``` diff --git a/frontend/.nvmrc b/frontend/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/frontend/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 6c5403f..e1dfd87 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -2,15 +2,18 @@ FROM node:20-alpine WORKDIR /app -# Kopier package.json og installer avhengigheter +ARG NEXT_PUBLIC_SITE_URL +ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL} + +# Kopier package.json og installer avhengigheter deterministisk COPY package*.json ./ -RUN npm install +RUN npm ci # Kopier resten av koden COPY . . -# BYGG koden her (kjøres jyb én gang når imaget bygges +# Bygg koden én gang ved image-build RUN npm run build -# Vi starter serveren i "produksjons"-modus (utviklingsmodus). +# Start Next i produksjonsmodus CMD ["npm", "start"] diff --git a/frontend/package.json b/frontend/package.json index 673e0cf..6dfc8af 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,11 +2,15 @@ "name": "frontend", "version": "0.1.0", "private": true, + "engines": { + "node": ">=20.9.0", + "npm": ">=10" + }, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint ." }, "dependencies": { "@tiptap/extension-image": "^3.22.3", diff --git a/frontend/src/app/FacilitySearch.tsx b/frontend/src/app/FacilitySearch.tsx index 59dcd68..bec11be 100755 --- a/frontend/src/app/FacilitySearch.tsx +++ b/frontend/src/app/FacilitySearch.tsx @@ -236,13 +236,20 @@ const sanitizeHref = (value: string) => { return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#"; }; +const getSiteOrigin = () => { + if (typeof window !== "undefined" && window.location?.origin) { + return window.location.origin; + } + return process.env.NEXT_PUBLIC_SITE_URL || "https://teeoff.no"; +}; + const isInternalTeeoffHref = (href: string) => { if (!href || href.startsWith("/") || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) { return true; } try { - const url = new URL(href, "https://nye.teeoff.no"); + const url = new URL(href, getSiteOrigin()); return url.hostname === "teeoff.no" || url.hostname.endsWith(".teeoff.no"); } catch { return false; diff --git a/frontend/src/app/admin/login/page.tsx b/frontend/src/app/admin/login/page.tsx index 09ee83b..93f52ac 100644 --- a/frontend/src/app/admin/login/page.tsx +++ b/frontend/src/app/admin/login/page.tsx @@ -13,7 +13,7 @@ import { API_URL } from "@/config/constants"; export default function AdminLogin() { const [step, setStep] = useState(1); - const [formData, setFormData] = useState({ username: '', password: '', code: '' }); + const [formData, setFormData] = useState({ username: '', password: '', code: '', rememberMe: false }); const [tempToken, setTempToken] = useState(''); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -28,7 +28,11 @@ export default function AdminLogin() { const res = await fetch(`${API_URL}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username: formData.username, password: formData.password }) + body: JSON.stringify({ + username: formData.username, + password: formData.password, + remember_me: formData.rememberMe, + }) }); const data = await res.json(); @@ -85,6 +89,15 @@ export default function AdminLogin() { <> setFormData(prevState => ({...prevState, username: e.target.value}))} required /> setFormData(prevState => ({...prevState, password: e.target.value}))} required /> + ) : (
diff --git a/frontend/src/app/golfbaner/[slug]/FacilityDetailLeafletMap.tsx b/frontend/src/app/golfbaner/[slug]/FacilityDetailLeafletMap.tsx new file mode 100644 index 0000000..188b807 --- /dev/null +++ b/frontend/src/app/golfbaner/[slug]/FacilityDetailLeafletMap.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Icon as LeafletIcon } from "leaflet"; +import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet"; +import { STATUS_MAP } from "@/config/constants"; +import { STATUS_ICON_PATHS } from "@/app/facilityData"; + +type FacilityDetailLeafletMapProps = { + lat: number; + lng: number; + name: string; + city?: string | null; + county?: string | null; + primaryStatus: string; + mapUrl: string | null; +}; + +const detailMarkerIconCache: Record = {}; + +const getDetailMarkerIcon = (status: string) => { + const key = STATUS_ICON_PATHS[status] ? status : "ukjent"; + if (!detailMarkerIconCache[key]) { + detailMarkerIconCache[key] = new LeafletIcon({ + iconUrl: STATUS_ICON_PATHS[key], + iconSize: [34, 48], + iconAnchor: [17, 48], + popupAnchor: [0, -42], + }); + } + return detailMarkerIconCache[key]; +}; + +export default function FacilityDetailLeafletMap({ + lat, + lng, + name, + city, + county, + primaryStatus, + mapUrl, +}: FacilityDetailLeafletMapProps) { + return ( +
+ + + + +
+
+

{name}

+

+ {city} • {county} +

+
+
+ {STATUS_MAP[primaryStatus] || "Ukjent status"} +
+ {mapUrl && ( + + Åpne kart + + )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx index 334a9e9..bde7532 100644 --- a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx +++ b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx @@ -14,27 +14,20 @@ */ import { useState, useEffect } from 'react'; -import { Icon as LeafletIcon } from "leaflet"; -import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet"; +import dynamic from "next/dynamic"; import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants"; import { STATUS_ICON_PATHS, buildMapUrl, getPrimaryStatus, parseJson as parseSharedJson, slugify } from "@/app/facilityData"; import Link from 'next/link'; import CourseDisplay from './CourseDisplay'; -const detailMarkerIconCache: Record = {}; - -const getDetailMarkerIcon = (status: string) => { - const key = STATUS_ICON_PATHS[status] ? status : "ukjent"; - if (!detailMarkerIconCache[key]) { - detailMarkerIconCache[key] = new LeafletIcon({ - iconUrl: STATUS_ICON_PATHS[key], - iconSize: [34, 48], - iconAnchor: [17, 48], - popupAnchor: [0, -42], - }); - } - return detailMarkerIconCache[key]; -}; +const FacilityDetailLeafletMap = dynamic(() => import("./FacilityDetailLeafletMap"), { + ssr: false, + loading: () => ( +
+ Laster kart… +
+ ), +}); const formatPhoneForUrl = (phone: string) => { if (!phone) return ""; @@ -65,13 +58,20 @@ const sanitizeHref = (value: string) => { return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#"; }; +const getSiteOrigin = () => { + if (typeof window !== "undefined" && window.location?.origin) { + return window.location.origin; + } + return process.env.NEXT_PUBLIC_SITE_URL || "https://teeoff.no"; +}; + const isInternalTeeoffHref = (href: string) => { if (!href || href.startsWith("/") || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) { return true; } try { - const url = new URL(href, "https://nye.teeoff.no"); + const url = new URL(href, getSiteOrigin()); return url.hostname === "teeoff.no" || url.hostname.endsWith(".teeoff.no"); } catch { return false; @@ -445,20 +445,6 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
- {/* SAMARBEIDENDE KLUBBER */} - {cooperatingClubs.length > 0 && ( -
- Samarbeider med: -
- {cooperatingClubs.map((slug: string) => ( - - {slug.replace('-golfklubb', '').replace(/-/g, ' ')} - - ))} -
-
- )} - {golfpakkerRaw.length > 0 && (
Golfpakker: @@ -503,48 +489,15 @@ export default function FacilityDetailView({ facility }: { facility: any }) {

Kart

-
- - - - -
-
-

{facility.name}

-

- {facility.city} • {facility.county} -

-
-
- {STATUS_MAP[primaryStatus] || "Ukjent status"} -
- {mapUrl && ( - - Åpne kart - - )} -
-
-
-
-
+
)} @@ -655,6 +608,20 @@ export default function FacilityDetailView({ facility }: { facility: any }) { Krav: {facility.guest_requirements}

)} + {cooperatingClubs.length > 0 && ( +
+ + Samarbeidende klubber + +
+ {cooperatingClubs.map((slug: string) => ( + + {slug.replace('-golfklubb', '').replace(/-/g, ' ')} + + ))} +
+
+ )}
)} diff --git a/frontend/src/components/PlaceMap.tsx b/frontend/src/components/PlaceMap.tsx index cba7e8c..91f72a1 100755 --- a/frontend/src/components/PlaceMap.tsx +++ b/frontend/src/components/PlaceMap.tsx @@ -1,105 +1,22 @@ "use client"; -import Link from "next/link"; -import { useEffect, useMemo } from "react"; -import { Icon, LatLngBounds } from "leaflet"; -import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet"; -import { - type EnrichedFacility, - STATUS_ICON_PATHS, - buildMapUrl, - formatUpdatedDate, - getStatusLabel, - parseJson, -} from "@/app/facilityData"; +import dynamic from "next/dynamic"; +import { useMemo } from "react"; +import { type EnrichedFacility, STATUS_ICON_PATHS } from "@/app/facilityData"; type PlaceMapProps = { facilities: EnrichedFacility[]; placeLabel: string; }; -const markerIconCache: Record = {}; - -const getMarkerIcon = (status: string) => { - const key = STATUS_ICON_PATHS[status] ? status : "ukjent"; - if (!markerIconCache[key]) { - markerIconCache[key] = new Icon({ - iconUrl: STATUS_ICON_PATHS[key], - iconSize: [34, 48], - iconAnchor: [17, 48], - popupAnchor: [0, -42], - }); - } - return markerIconCache[key]; -}; - -function ShiftScrollZoomGuard() { - const map = useMap(); - - useEffect(() => { - const updateWheelMode = (shiftPressed: boolean) => { - if (window.innerWidth < 1024) { - map.scrollWheelZoom.enable(); - return; - } - - if (shiftPressed) { - map.scrollWheelZoom.enable(); - } else { - map.scrollWheelZoom.disable(); - } - }; - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Shift") updateWheelMode(true); - }; - - const onKeyUp = (event: KeyboardEvent) => { - if (event.key === "Shift") updateWheelMode(false); - }; - - const onBlur = () => updateWheelMode(false); - const onResize = () => updateWheelMode(false); - - updateWheelMode(false); - window.addEventListener("keydown", onKeyDown); - window.addEventListener("keyup", onKeyUp); - window.addEventListener("blur", onBlur); - window.addEventListener("resize", onResize); - - return () => { - window.removeEventListener("keydown", onKeyDown); - window.removeEventListener("keyup", onKeyUp); - window.removeEventListener("blur", onBlur); - window.removeEventListener("resize", onResize); - }; - }, [map]); - - return null; -} - -function FitMapBounds({ facilities }: { facilities: EnrichedFacility[] }) { - const map = useMap(); - - useEffect(() => { - const withCoords = facilities.filter( - (facility) => typeof facility.lat === "number" && typeof facility.lng === "number" - ); - - if (withCoords.length === 0) return; - if (withCoords.length === 1) { - map.setView([withCoords[0].lat as number, withCoords[0].lng as number], 10); - return; - } - - const bounds = new LatLngBounds( - withCoords.map((facility) => [facility.lat as number, facility.lng as number] as [number, number]) - ); - map.fitBounds(bounds, { padding: [36, 36] }); - }, [facilities, map]); - - return null; -} +const PlaceMapLeaflet = dynamic(() => import("./PlaceMapLeaflet"), { + ssr: false, + loading: () => ( +
+ Laster kart… +
+ ), +}); function ActionGlyph({ type }: { type: "teeoff" | "phone" | "mail" | "home" | "calendar" | "weather" | "facebook" | "instagram" }) { if (type === "teeoff") { @@ -252,108 +169,7 @@ export default function PlaceMap({ facilities, placeLabel }: PlaceMapProps) {
-
- - - - - - {mapFacilities.map((facility) => { - const socialLinks = parseJson>(facility.social_links, []); - const facebook = socialLinks.find((entry) => entry.platform?.toLowerCase() === "facebook")?.url; - const instagram = socialLinks.find((entry) => entry.platform?.toLowerCase() === "instagram")?.url; - - return ( - - -
-
- - {facility.name} - -

- {facility.city} • {facility.county} -

-
- -
- {getStatusLabel(facility.primaryStatus)} -
- - {facility.status_updated_at && ( -

Oppdatert {formatUpdatedDate(facility.status_updated_at)}

- )} - -
- - - - {facility.phone && ( - - - - )} - {facility.email && ( - - - - )} - {facility.website_url && ( - - - - )} - {facility.golfbox_tournament_url && ( - - - - )} - {facility.weather_url && ( - - - - )} - {facebook && ( - - - - )} - {instagram && ( - - - - )} - {buildMapUrl(facility.lat, facility.lng) && ( - - Åpne kart - - )} -
-
-
-
- ); - })} -
-
+
); diff --git a/frontend/src/components/PlaceMapLeaflet.tsx b/frontend/src/components/PlaceMapLeaflet.tsx new file mode 100644 index 0000000..e8bf133 --- /dev/null +++ b/frontend/src/components/PlaceMapLeaflet.tsx @@ -0,0 +1,267 @@ +"use client"; + +import Link from "next/link"; +import { useEffect } from "react"; +import { Icon, LatLngBounds } from "leaflet"; +import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet"; +import { + buildMapUrl, + formatUpdatedDate, + getStatusLabel, + parseJson, + STATUS_ICON_PATHS, + type EnrichedFacility, +} from "@/app/facilityData"; + +type PlaceMapLeafletProps = { + facilities: EnrichedFacility[]; +}; + +const markerIconCache: Record = {}; + +const getMarkerIcon = (status: string) => { + const key = STATUS_ICON_PATHS[status] ? status : "ukjent"; + if (!markerIconCache[key]) { + markerIconCache[key] = new Icon({ + iconUrl: STATUS_ICON_PATHS[key], + iconSize: [34, 48], + iconAnchor: [17, 48], + popupAnchor: [0, -42], + }); + } + return markerIconCache[key]; +}; + +function ShiftScrollZoomGuard() { + const map = useMap(); + + useEffect(() => { + const updateWheelMode = (shiftPressed: boolean) => { + if (window.innerWidth < 1024) { + map.scrollWheelZoom.enable(); + return; + } + + if (shiftPressed) { + map.scrollWheelZoom.enable(); + } else { + map.scrollWheelZoom.disable(); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Shift") updateWheelMode(true); + }; + + const onKeyUp = (event: KeyboardEvent) => { + if (event.key === "Shift") updateWheelMode(false); + }; + + const onBlur = () => updateWheelMode(false); + const onResize = () => updateWheelMode(false); + + updateWheelMode(false); + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + window.addEventListener("blur", onBlur); + window.addEventListener("resize", onResize); + + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("blur", onBlur); + window.removeEventListener("resize", onResize); + }; + }, [map]); + + return null; +} + +function FitMapBounds({ facilities }: { facilities: EnrichedFacility[] }) { + const map = useMap(); + + useEffect(() => { + const withCoords = facilities.filter( + (facility) => typeof facility.lat === "number" && typeof facility.lng === "number" + ); + + if (withCoords.length === 0) return; + if (withCoords.length === 1) { + map.setView([withCoords[0].lat as number, withCoords[0].lng as number], 10); + return; + } + + const bounds = new LatLngBounds( + withCoords.map((facility) => [facility.lat as number, facility.lng as number] as [number, number]) + ); + map.fitBounds(bounds, { padding: [36, 36] }); + }, [facilities, map]); + + return null; +} + +function ActionGlyph({ type }: { type: "teeoff" | "phone" | "mail" | "home" | "calendar" | "weather" | "facebook" | "instagram" }) { + if (type === "teeoff") { + return t; + } + + return ( + + ); +} + +export default function PlaceMapLeaflet({ facilities }: PlaceMapLeafletProps) { + return ( +
+ + + + + + {facilities.map((facility) => { + const socialLinks = parseJson>(facility.social_links, []); + const facebook = socialLinks.find((entry) => entry.platform?.toLowerCase() === "facebook")?.url; + const instagram = socialLinks.find((entry) => entry.platform?.toLowerCase() === "instagram")?.url; + + return ( + + +
+
+ + {facility.name} + +

+ {facility.city} • {facility.county} +

+
+ +
+ {getStatusLabel(facility.primaryStatus)} +
+ + {facility.status_updated_at && ( +

Oppdatert {formatUpdatedDate(facility.status_updated_at)}

+ )} + +
+ + + + {facility.phone && ( + + + + )} + {facility.email && ( + + + + )} + {facility.website_url && ( + + + + )} + {facility.golfbox_tournament_url && ( + + + + )} + {facility.weather_url && ( + + + + )} + {facebook && ( + + + + )} + {instagram && ( + + + + )} + {buildMapUrl(facility.lat, facility.lng) && ( + + Åpne kart + + )} +
+
+
+
+ ); + })} +
+
+ ); +} diff --git a/init.sql b/init.sql index 45691ec..1767583 100644 --- a/init.sql +++ b/init.sql @@ -20,7 +20,7 @@ CREATE TABLE facilities ( proshop_url VARCHAR(255), cafe_url VARCHAR(255), nsg_agreement BOOLEAN DEFAULT FALSE, - cooperating_clubs TEXT, + cooperating_clubs JSONB DEFAULT '[]'::jsonb, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); diff --git a/migrations/2026-04-17_add_cooperating_clubs_jsonb.sql b/migrations/2026-04-17_add_cooperating_clubs_jsonb.sql new file mode 100644 index 0000000..af841b1 --- /dev/null +++ b/migrations/2026-04-17_add_cooperating_clubs_jsonb.sql @@ -0,0 +1,47 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'facilities' + AND column_name = 'cooperating_clubs' + AND udt_name = 'text' + ) THEN + ALTER TABLE public.facilities + RENAME COLUMN cooperating_clubs TO cooperating_clubs_legacy; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'facilities' + AND column_name = 'cooperating_clubs' + ) THEN + ALTER TABLE public.facilities + ADD COLUMN cooperating_clubs jsonb DEFAULT '[]'::jsonb; + END IF; + + ALTER TABLE public.facilities + ALTER COLUMN cooperating_clubs SET DEFAULT '[]'::jsonb; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'facilities' + AND column_name = 'cooperating_clubs_legacy' + ) THEN + UPDATE public.facilities + SET cooperating_clubs = CASE + WHEN cooperating_clubs_legacy IS NULL OR btrim(cooperating_clubs_legacy) = '' THEN '[]'::jsonb + WHEN cooperating_clubs_legacy ~ '^\s*\[' THEN cooperating_clubs_legacy::jsonb + ELSE to_jsonb(regexp_split_to_array(cooperating_clubs_legacy, '\s*,\s*')) + END + WHERE cooperating_clubs = '[]'::jsonb; + + ALTER TABLE public.facilities + DROP COLUMN cooperating_clubs_legacy; + END IF; +END $$; diff --git a/schema.sql b/schema.sql index d54c3e4..fa21b79 100644 --- a/schema.sql +++ b/schema.sql @@ -24,6 +24,7 @@ CREATE TABLE facilities ( website_url VARCHAR(255), golfbox_booking_url VARCHAR(255), golfbox_tournament_url VARCHAR(255), + cooperating_clubs JSONB DEFAULT '[]'::jsonb, facebook_url VARCHAR(255), instagram_url VARCHAR(255), weather_url VARCHAR(255),