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 0000000..d8b43f8 Binary files /dev/null and b/bilde1.png differ diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..7e4f138 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,32 @@ +{ + email {$ACME_EMAIL} +} + +www.teeoff.no { + redir https://teeoff.no{uri} permanent +} + +teeoff.no { + encode zstd gzip + + log { + output stdout + format console + } + + # This upload route is implemented in Next.js, not FastAPI. + handle /api/admin/uploads/images* { + reverse_proxy frontend:3000 + } + + # All other /api traffic goes to the FastAPI backend. + # Use handle, not handle_path, so the /api prefix is preserved. + handle /api/* { + reverse_proxy api:8000 + } + + # Everything else is served by Next.js. + handle { + reverse_proxy frontend:3000 + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..98ccb5e --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,95 @@ +services: + db: + image: postgis/postgis:15-3.4 + container_name: teeoff_db + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - teeoff_db_data:/var/lib/postgresql/data + restart: unless-stopped + + api: + 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} + PUBLIC_SESSION_SECRET: ${PUBLIC_SESSION_SECRET} + PUBLIC_COMMENT_DEFAULT_STATUS: ${PUBLIC_COMMENT_DEFAULT_STATUS} + SMTP_SERVER: ${SMTP_SERVER} + SMTP_PORT: ${SMTP_PORT} + SMTP_USER: ${SMTP_USER} + SMTP_PASS: ${SMTP_PASS} + PUBLIC_FROM_EMAIL: ${PUBLIC_FROM_EMAIL} + PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES: ${PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES} + volumes: + - ./backend:/app + - ./frontend/src/content:/shared/frontend-content:ro + - ./frontend/public/media:/app/public/media + depends_on: + - db + restart: unless-stopped + expose: + - "8000" + + worker: + build: ./backend + container_name: teeoff_worker + command: python worker.py + environment: + DATABASE_URL: ${DATABASE_URL} + GEMINI_API_KEY: ${GEMINI_API_KEY} + SMTP_SERVER: ${SMTP_SERVER} + SMTP_PORT: ${SMTP_PORT} + SMTP_USER: ${SMTP_USER} + SMTP_PASS: ${SMTP_PASS} + EMAIL_TO: ${EMAIL_TO} + volumes: + - ./backend:/app + depends_on: + - db + restart: unless-stopped + + frontend: + build: + context: ./frontend + args: + NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL} + container_name: teeoff_frontend + command: npm start + environment: + NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL} + volumes: + - ./frontend/public/uploads:/app/public/uploads + depends_on: + - api + restart: unless-stopped + expose: + - "3000" + + caddy: + image: caddy:2 + container_name: teeoff_caddy + ports: + - "80:80" + - "443:443" + environment: + ACME_EMAIL: ${ACME_EMAIL} + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + - frontend + - api + restart: unless-stopped + +volumes: + teeoff_db_data: + caddy_data: + caddy_config: diff --git a/docs/vps-deploy-teeoff.md b/docs/vps-deploy-teeoff.md new file mode 100644 index 0000000..98ce0d3 --- /dev/null +++ b/docs/vps-deploy-teeoff.md @@ -0,0 +1,141 @@ +# TeeOff VPS deployment + +This project can be served on `https://teeoff.no` with Docker Compose and Caddy. + +## 1. DNS at one.com + +Create or update these records: + +- `A` record for the root domain (`@` / empty hostname) -> `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),