Før rekonstruksjon av backend, med skraping av medlemskap

This commit is contained in:
Erol 2026-03-08 10:26:56 +01:00
parent d9dca0ce6a
commit aca30c9adc
42 changed files with 5776 additions and 116 deletions

View file

@ -1,5 +1,5 @@
"""
TEE OFF BACKEND API v3.6.9 - KOBLET ADMIN KJØR-KNAPP
TEE OFF BACKEND API v3.7.0 - KOBLET FULL ADMIN REDIGERING
---------------------------------------------------------------------------
REGEL 1: Bruk str (ikke string) for type-hinting.
REGEL 2: Inkluder alle subqueries for banestatus og hull-data.
@ -23,7 +23,7 @@ from dotenv import load_dotenv
# NYE IMPORTER FOR ADMIN PANELET OG BAKGRUNNSJOBBER
from pydantic import BaseModel
from typing import Optional, List
from typing import Optional, List, Any
import subprocess
load_dotenv()
@ -51,6 +51,17 @@ class ScrapeSettingsUpdate(BaseModel):
class ScrapeRunRequest(BaseModel):
facility_ids: List[int]
class MembershipDraftApproval(BaseModel):
facility_id: int
navn_standard_medlemskap: Optional[str] = None
standard_medlemskap: Optional[int] = None
standard_medlemskap_kommentarer: Optional[str] = None
navn_rimeligste_alternativ: Optional[str] = None
rimeligste_alternativ: Optional[int] = None
class BulkApprovalRequest(BaseModel):
approvals: List[MembershipDraftApproval]
# --- FUNKSJONER ---
def format_row(row):
"""
@ -64,16 +75,16 @@ def format_row(row):
d = dict(row)
for key in ['status_updated_at', 'created_at']:
for key in ['status_updated_at', 'created_at', 'slope_valid_until', 'membership_updated_at']:
if isinstance(d.get(key), (date, datetime)):
d[key] = d[key].isoformat()
json_list_fields = [
'course_statuses', 'courses', 'gallery', 'greenfee',
'faqs', 'shotzoom', 'social_links', 'holes'
'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs'
]
json_dict_fields = [
'amenities', 'vtg', 'nsg_data', 'golfamore_data'
'amenities', 'vtg', 'nsg_data', 'golfamore_data', 'membership_draft'
]
for field in json_list_fields:
@ -146,7 +157,7 @@ async def lifespan(app: FastAPI):
# Lukk pool ved avslutning
await app.state.pool.close()
app = FastAPI(title="TeeOff API v3.6.9", lifespan=lifespan)
app = FastAPI(title="TeeOff API v3.7.0", lifespan=lifespan)
# CORS - Tillater både lokal utvikling og produksjonsdomene
app.add_middleware(
@ -315,6 +326,84 @@ async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdat
raise e
raise HTTPException(status_code=500, detail=str(e))
# --- NYTT ADMIN ENDPOINT FOR FULL OPPDATERING (JSON-EDITOR) ---
@app.put("/api/admin/facilities/{facility_id}/full")
async def update_facility_full(facility_id: int, request: Request):
"""Dynamisk endpoint som oppdaterer anlegg, baner og hull (den fulle editoren)."""
data = await request.json()
# Felter som er trygge å oppdatere manuelt på anlegget
allowed_fields = [
'name', 'description', 'established_year', 'season', 'banetype', 'architect', 'length_meters',
'address', 'zipcode', 'city', 'county', 'lat', 'lng',
'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url',
'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee',
'nsg_url', 'nsg_data', 'golfamore', 'golfamore_data',
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url',
'vtg_presentasjon', 'vtg_lenke', 'vtg_pris', 'vtg_kursdatoer',
'guest_requirements', 'scrape_method', 'scrape_status_url',
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at'
]
update_data = {k: v for k, v in data.items() if k in allowed_fields}
async with app.state.pool.acquire() as conn:
async with conn.transaction(): # Sikrer at alt lagres samlet
# 1. OPPDATER ANLEGG (FACILITIES)
if update_data:
set_clauses = []
values = []
for i, (k, v) in enumerate(update_data.items(), 1):
if isinstance(v, (dict, list)):
set_clauses.append(f"{k} = ${i}::jsonb")
values.append(json.dumps(v))
else:
set_clauses.append(f"{k} = ${i}")
values.append(v)
values.append(facility_id)
query = f"UPDATE facilities SET {', '.join(set_clauses)} WHERE id = ${len(values)}"
await conn.execute(query, *values)
# 2. OPPDATER BANER (COURSES) OG HULL (HOLES)
courses = data.get('courses', [])
for course in courses:
course_id = course.get('id')
if course_id:
# Rens datoformat for PostgreSQL (håndterer Next.js date input)
valid_until = course.get('slope_valid_until')
if valid_until == "" or valid_until is None:
valid_until = None
await conn.execute("""
UPDATE courses
SET name=$1, par=$2, length_meters=$3, architect=$4,
status=$5, is_main_course=$6, tee_boxes=$7::jsonb,
slope_valid_until=$8
WHERE id=$9 AND facility_id=$10
""",
course.get('name'), course.get('par'), course.get('length_meters'),
course.get('architect'), course.get('status'), course.get('is_main_course'),
json.dumps(course.get('tee_boxes', {})), valid_until, course_id, facility_id)
# 3. OPPDATER HULL PÅ BANEN (HOLES)
holes = course.get('holes', [])
for hole in holes:
hole_id = hole.get('id')
if hole_id:
await conn.execute("""
UPDATE holes
SET par=$1, hcp_index=$2, lengths=$3::jsonb
WHERE id=$4 AND course_id=$5
""",
hole.get('par'), hole.get('hcp_index'),
json.dumps(hole.get('lengths', {})), hole_id, course_id)
return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."}
# --- NYTT ADMIN ENDPOINT: KJØRER SKRAPEREN FOR VALGTE IDER ---
@app.post("/api/admin/run-scraper")
async def run_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
@ -327,12 +416,10 @@ async def run_scraper_endpoint(request: ScrapeRunRequest, background_tasks: Back
print(f"📡 API mottok forespørsel om å kjøre skraping for IDer: {request.facility_ids}")
# Her starter vi selve magien: Vi legger jobben i FastAPIs BackgroundTasks
background_tasks.add_task(run_scrape_worker, request.facility_ids)
return {"status": "queued", "message": f"Skraping for {len(request.facility_ids)} anlegg ble lagt i kø."}
@app.get("/api/health")
async def health_check():
"""Enkel sjekk for å se at API og DB lever."""
@ -343,6 +430,49 @@ async def health_check():
except Exception as e:
return {"status": "unhealthy", "error": str(e)}
# --- MEDLEMSKAP "VASKERI" ENDEPUNKTER ---
@app.get("/api/admin/membership/drafts")
async def get_membership_drafts():
"""Henter alle anlegg som har et ventende forslag fra AI-skraperen."""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT id, name, slug, medlemskap_url,
navn_standard_medlemskap, standard_medlemskap,
navn_rimeligste_alternativ, rimeligste_alternativ,
membership_draft
FROM facilities
WHERE membership_draft IS NOT NULL
AND membership_draft::text != '{}'
ORDER BY name ASC
""")
return [format_row(row) for row in rows]
@app.post("/api/admin/membership/approve-bulk")
async def approve_membership_bulk(request: BulkApprovalRequest):
"""Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet."""
async with app.state.pool.acquire() as conn:
async with conn.transaction():
for approval in request.approvals:
await conn.execute("""
UPDATE facilities
SET navn_standard_medlemskap = $1,
standard_medlemskap = $2,
standard_medlemskap_kommentarer = $3,
navn_rimeligste_alternativ = $4,
rimeligste_alternativ = $5,
membership_updated_at = NOW(),
membership_draft = NULL
WHERE id = $6
""",
approval.navn_standard_medlemskap,
approval.standard_medlemskap,
approval.standard_medlemskap_kommentarer,
approval.navn_rimeligste_alternativ,
approval.rimeligste_alternativ,
approval.facility_id)
return {"status": "success", "message": f"{len(request.approvals)} anlegg ble oppdatert med nye priser!"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View file

@ -0,0 +1,163 @@
"""
TEE OFF - MEDLEMSKAPSSKRAPER MED GEMINI AI
---------------------------------------------------------------------------
Går til oppgitte medlemskaps-URLer, henter ut tekst, og bruker Gemini til å
finne 'Standard' og 'Rimeligste' medlemskap basert TeeOffs definisjoner.
Lagrer resultatet som et utkast i databasen (membership_draft).
---------------------------------------------------------------------------
"""
import asyncio
import asyncpg
import os
import json
import argparse
from bs4 import BeautifulSoup
from playwright.async_api import async_playwright
import google.generativeai as genai
from dotenv import load_dotenv
# Last inn miljøvariabler
load_dotenv()
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
if not GEMINI_API_KEY:
raise ValueError("🚨 GEMINI_API_KEY mangler i .env filen!")
# Konfigurer Gemini
genai.configure(api_key=GEMINI_API_KEY)
model = genai.GenerativeModel('gemini-2.5-flash') # Eller gemini-1.5-pro avhengig av hva du har tilgang til
async def fetch_page_text(url: str) -> str:
"""Bruker Playwright for å hente all synlig tekst fra nettsiden."""
print(f" 🌐 Laster inn: {url}")
try:
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# Setter timeout til 15 sekunder
await page.goto(url, wait_until="domcontentloaded", timeout=15000)
# Hent hele HTML-innholdet
html_content = await page.content()
await browser.close()
# Bruk BeautifulSoup til å renske ut bare den synlige teksten
soup = BeautifulSoup(html_content, 'html.parser')
# Fjern script og style tags
for script in soup(["script", "style", "nav", "footer", "header"]):
script.extract()
text = soup.get_text(separator=' ', strip=True)
# Begrens teksten slik at vi ikke sprenger token-grensen til AI (f.eks max 15000 tegn)
return text[:15000]
except Exception as e:
print(f" ❌ Feil ved lasting av side: {e}")
return ""
def analyze_with_gemini(text: str, club_name: str) -> dict:
"""Sender teksten til Gemini for å trekke ut priser."""
print(f" 🧠 Sender {len(text)} tegn til Gemini for analyse...")
prompt = f"""
Du er en ekspert norske golfklubber og medlemskap.
Din oppgave er å lese teksten hentet fra nettsiden til "{club_name}" og trekke ut to spesifikke medlemskapspriser.
DEFINISJONER DU FØLGE STRENGT:
1. "Standard medlemskap": Hva vil det koste for en gjennomsnittsgolfer (voksen over 25/30 år, ikke student/senior) å spille RYE VEDKOMMENDE ØNSKER (Fritt spill) denne banen i år?
2. "Rimeligste alternativ": Det absolutt billigste medlemskapet som gir medlemskap i klubben (golfkortet), forutsatt at man aksepterer å måtte betale greenfee for hver runde man spiller. (Ofte kalt Greenfeemedlem, Postkassemedlem, Fjernmedlem el.l.)
TEKST FRA NETTSIDEN:
{text}
OPPGAVE:
Returner KUN et gyldig JSON-objekt med følgende struktur (og ingenting annet, ingen markdown):
{{
"foreslatt_standard_navn": "Navnet på medlemskapet (eks: Hovedmedlem Voksen)",
"foreslatt_standard_pris": 1234,
"foreslatt_standard_kommentar": "Kort evt kommentar (eks: Inkluderer ikke 500kr i dugnadsavgift)",
"foreslatt_rimeligste_navn": "Navnet (eks: Greenfeemedlemskap)",
"foreslatt_rimeligste_pris": 500,
"ai_begrunnelse": "Kort forklaring på hvorfor du valgte disse to, f.eks: 'Valgte Hovedmedlem for fritt spill og Greenfeemedlem fordi...'."
}}
Merk: Hvis prisene mangler, sett pris til null og skriv "Fant ikke" i navnet. Prisen SKAL være et tall (integer), ikke en tekststreng (bruk 6500, ikke "6 500").
"""
try:
response = model.generate_content(prompt)
raw_response = response.text.strip()
# Rensker vekk eventuell markdown-formatering som ```json
if raw_response.startswith("```json"):
raw_response = raw_response[7:]
if raw_response.endswith("```"):
raw_response = raw_response[:-3]
return json.loads(raw_response.strip())
except Exception as e:
print(f" ❌ AI-analyse feilet: {e}")
return None
async def run_scraper(facility_ids=None):
"""Hovedfunksjon som henter fra DB, skraper, og lagrer utkast."""
print("🚀 Starter Medlemskaps-skraperen...")
conn = await asyncpg.connect(DB_URL)
try:
# Hent anlegg som har en url for medlemskap
query = "SELECT id, name, medlemskap_url FROM facilities WHERE medlemskap_url IS NOT NULL AND medlemskap_url != ''"
if facility_ids:
query += f" AND id IN ({','.join(map(str, facility_ids))})"
facilities = await conn.fetch(query)
print(f"📋 Fant {len(facilities)} anlegg å skrape.")
for facility in facilities:
fac_id = facility['id']
name = facility['name']
url = facility['medlemskap_url']
print(f"\n▶️ Behandler: {name} (ID: {fac_id})")
# 1. Hent tekst
page_text = await fetch_page_text(url)
if not page_text or len(page_text) < 50:
print(" ⚠️ Fant for lite tekst på siden, hopper over.")
continue
# 2. Analyser med Gemini
draft_data = analyze_with_gemini(page_text, name)
if not draft_data:
continue
# 3. Lagre i databasen som utkast
print(f" ✅ AI foreslår: Standard: {draft_data.get('foreslatt_standard_pris')} | Rimeligste: {draft_data.get('foreslatt_rimeligste_pris')}")
await conn.execute("""
UPDATE facilities
SET membership_draft = $1::jsonb
WHERE id = $2
""", json.dumps(draft_data), fac_id)
print(" 💾 Utkast lagret i databasen!")
finally:
await conn.close()
print("\n🏁 Skraping fullført.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Skrap medlemskapspriser via AI.")
parser.add_argument("--ids", type=str, help="Kommaseparert liste med facility IDs (eks: 1,5,12)")
args = parser.parse_args()
ids_to_scrape = None
if args.ids:
ids_to_scrape = [int(x.strip()) for x in args.ids.split(",")]
asyncio.run(run_scraper(ids_to_scrape))

View file

@ -1,11 +1,16 @@
📁 teeoff/
📄 nsg.txt
📄 test_tjome.py
📄 fil-tre.txt
📄 losby_dump.txt
📄 seed.sql
📄 struktur3_dump.txt
📄 eksport_script.py
📄 update_golfbox.sql
📄 docker-compose.yml
📄 schema.sql
📄 init.sql
📄 rene_urler.txt
📁 frontend/
📄 eslint.config.mjs
📄 next-env.d.ts
@ -678,32 +683,56 @@
📄 FacilityDetailView.tsx
📁 admin/
📄 page.tsx
📁 rediger/
📁 [slug]/
📄 EditFacilityClient.tsx
📄 page.tsx
📁 login/
📄 page.tsx
📁 medlemskap/
📄 page.tsx
📁 kode_eksport_1/
📄 backend_scrape_membership_py.txt
📄 frontend_src_components_Header_tsx.txt
📄 backend_scrape_nsg_3_py.txt
📄 frontend_src_app_admin_medlemskap_page_tsx.txt
📄 frontend_next-env_d_ts.txt
📄 frontend_src_app_layout_tsx.txt
📄 frontend_src_app_page_tsx.txt
📄 eksport_script_py.txt
📄 frontend_src_components_ScrapeMethodSelect_tsx.txt
📄 frontend_src_app_golfbaner_[slug]_page_tsx.txt
📄 backend_import_wp_py.txt
📄 frontend_src_middleware_ts.txt
📄 test_tjome_py.txt
📄 backend_test_gemini_py.txt
📄 frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt
📄 frontend_next_config_ts.txt
📄 backend_update_admin_py.txt
📄 backend_import_nye_felter_py.txt
📄 frontend_src_app_admin_login_page_tsx.txt
📄 frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt
📄 backend_main_py.txt
📄 frontend_src_app_admin_rediger_[slug]_page_tsx.txt
📄 frontend_src_app_admin_page_tsx.txt
📄 frontend_src_app_admin_rediger_[slug]_EditFacilityClient_tsx.txt
📄 frontend_src_app_HeroSlider_tsx.txt
📄 backend_test_login_py.txt
📄 backend_create_admin_py.txt
📄 backend_sync_greenfee_py.txt
📄 backend_scrape_status_py.txt
📄 backend_scrape_golfamore1_3_py.txt
📄 frontend_src_app_FacilitySearch_tsx.txt
📄 frontend_src_config_constants_ts.txt
📄 backend_import_gallery_py.txt
📁 backend/
📄 scrape_nsg_3.py
📄 update_admin.py
📄 test_gemini.py
📄 import_gallery.py
📄 import_nye_felter.py
📄 .env
📄 scrape_membership.py
📄 test_login.py
📄 sync_greenfee.py
📄 scrape_status.py

View file

@ -0,0 +1,179 @@
"use client";
import { useState, useEffect } from 'react';
import { API_URL } from "@/config/constants";
import Link from 'next/link';
export default function MembershipWasher() {
const [drafts, setDrafts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
const fetchDrafts = () => {
setLoading(true);
fetch(`${API_URL}/admin/membership/drafts`)
.then(res => res.json())
.then(data => {
// Konverter innkommende drafts til editerbare felter lokalt
const editableDrafts = data.map((f: any) => ({
...f,
edit_standard_navn: f.membership_draft?.foreslatt_standard_navn || f.navn_standard_medlemskap || "",
edit_standard_pris: f.membership_draft?.foreslatt_standard_pris || f.standard_medlemskap || "",
edit_standard_kommentar: f.membership_draft?.foreslatt_standard_kommentar || "",
edit_rimeligste_navn: f.membership_draft?.foreslatt_rimeligste_navn || f.navn_rimeligste_alternativ || "",
edit_rimeligste_pris: f.membership_draft?.foreslatt_rimeligste_pris || f.rimeligste_alternativ || "",
}));
setDrafts(editableDrafts);
setLoading(false);
})
.catch(() => setLoading(false));
};
useEffect(() => {
fetchDrafts();
}, []);
const toggleSelectAll = (checked: boolean) => {
if (checked) setSelectedIds(drafts.map(d => d.id));
else setSelectedIds([]);
};
const toggleOne = (id: number) => {
if (selectedIds.includes(id)) setSelectedIds(selectedIds.filter(i => i !== id));
else setSelectedIds([...selectedIds, id]);
};
const updateDraftField = (id: number, field: string, value: any) => {
setDrafts(drafts.map(d => d.id === id ? { ...d, [field]: value } : d));
};
const handleApprove = async () => {
const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({
facility_id: d.id,
navn_standard_medlemskap: d.edit_standard_navn,
standard_medlemskap: Number(d.edit_standard_pris) || null,
standard_medlemskap_kommentarer: d.edit_standard_kommentar,
navn_rimeligste_alternativ: d.edit_rimeligste_navn,
rimeligste_alternativ: Number(d.edit_rimeligste_pris) || null,
}));
if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne.");
setSaving(true);
try {
const res = await fetch(`${API_URL}/admin/membership/approve-bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approvals: toApprove })
});
if (res.ok) {
alert(`${toApprove.length} anlegg ble oppdatert og lagret til live!`);
setSelectedIds([]);
fetchDrafts(); // Oppdaterer listen (fjerner de godkjente)
} else {
alert("Noe gikk galt under lagring.");
}
} catch (e) {
alert("Nettverksfeil");
}
setSaving(false);
};
if (loading) return <div className="p-20 text-center font-black animate-pulse">Laster utkast...</div>;
return (
<div className="min-h-screen bg-[#f1f7ed] p-8 text-[#11280f]">
<div className="max-w-[1400px] mx-auto">
<div className="flex justify-between items-end mb-10 border-b border-gray-200 pb-6">
<div>
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block"> Tilbake til oversikten</Link>
<h1 className="text-4xl font-black">Medlemskaps-Vaskeriet</h1>
<p className="text-sm text-gray-600 mt-2"> gjennom AI-ens forslag, juster hvis nødvendig, og godkjenn for å publisere. Oppdatert-dato settes automatisk i dag.</p>
</div>
<button
onClick={handleApprove}
disabled={saving || selectedIds.length === 0}
className="bg-[#8bc34a] text-white px-8 py-4 rounded-xl font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50 disabled:scale-100"
>
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
</button>
</div>
{drafts.length === 0 ? (
<div className="bg-white p-20 rounded-[2rem] text-center shadow-sm">
<span className="text-6xl mb-4 block">🧹</span>
<h2 className="text-2xl font-black text-gray-400">Alt er rent og pent!</h2>
<p className="text-gray-500">Ingen ventende forslag fra AI-skraperen akkurat .</p>
</div>
) : (
<div className="space-y-6">
<div className="bg-white p-4 rounded-2xl shadow-sm flex items-center gap-4">
<input
type="checkbox"
className="w-5 h-5 accent-[#8bc34a] ml-2"
checked={selectedIds.length === drafts.length}
onChange={(e) => toggleSelectAll(e.target.checked)}
/>
<span className="font-black uppercase tracking-widest text-xs text-gray-500">Velg Alle</span>
</div>
{drafts.map(draft => (
<div key={draft.id} className={`bg-white p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/5' : 'border-transparent'}`}>
<div className="flex gap-6 items-start">
<div className="pt-2">
<input
type="checkbox"
className="w-6 h-6 accent-[#8bc34a] cursor-pointer"
checked={selectedIds.includes(draft.id)}
onChange={() => toggleOne(draft.id)}
/>
</div>
<div className="flex-grow space-y-4">
{/* OPPDATERT: Navn + ID Badge */}
<div className="flex justify-between items-center border-b pb-4">
<h3 className="text-2xl font-black flex items-center gap-3">
{draft.name}
<span className="text-xs font-mono font-bold bg-gray-100 text-gray-400 px-2 py-1 rounded-md">ID: {draft.id}</span>
</h3>
<a href={draft.medlemskap_url} target="_blank" className="text-xs font-bold text-blue-600 hover:underline bg-blue-50 px-4 py-2 rounded-lg">Sjekk Klubbens Nettside </a>
</div>
{draft.membership_draft?.ai_begrunnelse && (
<div className="bg-blue-50/50 p-4 rounded-xl text-sm italic text-blue-900 border border-blue-100">
<strong>🤖 AI Begrunnelse:</strong> {draft.membership_draft.ai_begrunnelse}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-2">
{/* Standard */}
<div className="space-y-3">
<h4 className="text-xs font-black uppercase tracking-widest text-gray-400">Standard Medlemskap (Ubegrenset)</h4>
<div className="flex gap-2">
<input className="w-2/3 p-3 rounded-xl border border-gray-200 font-bold focus:border-[#8bc34a] outline-none" value={draft.edit_standard_navn} onChange={e => updateDraftField(draft.id, 'edit_standard_navn', e.target.value)} placeholder="Navn (eks. Hovedmedlem)" />
<input className="w-1/3 p-3 rounded-xl border border-gray-200 font-bold text-right focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_standard_pris} onChange={e => updateDraftField(draft.id, 'edit_standard_pris', e.target.value)} placeholder="Pris" />
</div>
<input className="w-full p-3 rounded-xl border border-gray-200 text-sm focus:border-[#8bc34a] outline-none" value={draft.edit_standard_kommentar} onChange={e => updateDraftField(draft.id, 'edit_standard_kommentar', e.target.value)} placeholder="Kommentar (F.eks: Inkluderer ikke treningsavgift)" />
<p className="text-[10px] text-gray-400">Gammel pris var: {draft.standard_medlemskap ? `kr ${draft.standard_medlemskap} (${draft.navn_standard_medlemskap})` : 'Ikke registrert'}</p>
</div>
{/* Rimeligste */}
<div className="space-y-3">
<h4 className="text-xs font-black uppercase tracking-widest text-gray-400">Rimeligste (Betaler Greenfee)</h4>
<div className="flex gap-2">
<input className="w-2/3 p-3 rounded-xl border border-gray-200 font-bold focus:border-[#8bc34a] outline-none" value={draft.edit_rimeligste_navn} onChange={e => updateDraftField(draft.id, 'edit_rimeligste_navn', e.target.value)} placeholder="Navn (eks. Greenfeemedlem)" />
<input className="w-1/3 p-3 rounded-xl border border-gray-200 font-bold text-right focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_rimeligste_pris} onChange={e => updateDraftField(draft.id, 'edit_rimeligste_pris', e.target.value)} placeholder="Pris" />
</div>
<p className="text-[10px] text-gray-400 mt-2">Gammel pris var: {draft.rimeligste_alternativ ? `kr ${draft.rimeligste_alternativ} (${draft.navn_rimeligste_alternativ})` : 'Ikke registrert'}</p>
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -57,7 +57,7 @@ export default function AdminDashboard() {
return () => clearInterval(interval);
}, [isScraping]);
// NYTT: Filtreringslogikken som kjører automatisk når facilities eller filteret endres
// Filtreringslogikken som kjører automatisk når facilities eller filteret endres
const filteredFacilities = useMemo(() => {
if (statusFilter === 'alle') return facilities;
@ -87,11 +87,10 @@ export default function AdminDashboard() {
return { ...facility, course_statuses: filteredCourses };
}).filter(facility => facility.course_statuses && facility.course_statuses.length > 0);
// Skjul anlegget helt hvis det ikke har noen baner som matcher filteret
}, [facilities, statusFilter]);
// OPPDATERT: "Velg alle" gjelder kun de anleggene som er synlige i filteret
// "Velg alle" gjelder kun de anleggene som er synlige i filteret
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedFacilities(filteredFacilities.map(f => f.id));
@ -171,12 +170,12 @@ export default function AdminDashboard() {
return (
<div className="flex min-h-screen bg-[#f1f7ed] font-sans relative overflow-hidden">
{/* REDIGER-MODAL */}
{/* REDIGER-MODAL FOR SKRAPING */}
{editingFacility && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]">
<div className="bg-[#11280f] text-white p-6 shrink-0">
<h3 className="text-xl font-black uppercase tracking-widest">Rediger Konfigurasjon</h3>
<h3 className="text-xl font-black uppercase tracking-widest">Skrape-innstillinger</h3>
<p className="text-sm text-[#7ca982]">{editingFacility.name}</p>
</div>
@ -287,12 +286,17 @@ export default function AdminDashboard() {
</div>
<nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow">
<div className={`text-white border-l-4 border-[#8bc34a] py-1 ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4'}`} title="Scraping Monitor">
{/* Lenke til Hoveddashbord / Scraping Monitor */}
<Link href="/admin" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-[#8bc34a] text-white'}`} title="Scraping Monitor">
{isSidebarCollapsed ? 'SM' : 'Scraping Monitor'}
</div>
<div className={`hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Medlemskap">
</Link>
{/* Lenke til Medlemskaps-Vaskeriet */}
<Link href="/admin/medlemskap" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Medlemskap">
{isSidebarCollapsed ? 'M' : 'Medlemskap'}
</div>
</Link>
{/* Bildegalleri (Placeholder inntil vi bygger denne) */}
<div className={`hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Bildegalleri">
{isSidebarCollapsed ? 'B' : 'Bildegalleri'}
</div>
@ -333,7 +337,6 @@ export default function AdminDashboard() {
</button>
</header>
{/* NYTT: Filter-meny UI */}
<div className="flex flex-wrap items-center gap-4 bg-gray-50 p-4 rounded-2xl border border-gray-100 mb-8">
<label htmlFor="statusFilter" className="text-xs font-bold text-gray-500 uppercase tracking-widest">Filtrer status:</label>
<select
@ -362,6 +365,7 @@ export default function AdminDashboard() {
onChange={handleSelectAll}
/>
</th>
<th className="pb-4 w-12 text-center">ID</th>
<th className="pb-4 pr-6">Anlegg</th>
<th className="pb-4">Konfigurasjon</th>
<th className="pb-4">Metode</th>
@ -381,6 +385,11 @@ export default function AdminDashboard() {
onChange={(e) => handleSelectOne(f.id, e.target.checked)}
/>
</td>
<td className="py-6 text-center text-xs font-mono text-gray-400">
#{f.id}
</td>
<td className="py-6 pr-6">
<div className="font-black text-base md:text-lg whitespace-nowrap">{f.name}</div>
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
@ -418,6 +427,7 @@ export default function AdminDashboard() {
})}
</div>
</td>
<td className="py-6 text-right pr-4">
<div className="flex flex-col gap-2 items-end">
<button
@ -434,6 +444,7 @@ export default function AdminDashboard() {
</Link>
</div>
</td>
</tr>
))}
</tbody>

View file

@ -3,7 +3,28 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
// KOMPONENT 1: Viser flate JSON-objekter (som fasiliteter) som rader med Nøkkel og Verdi
// KOMPONENT 1: MultiSelect for samarbeidende klubber
const MultiSelect = ({ label, options, selected, onChange }: { label: string, options: any[], selected: string[], onChange: (s: string[]) => void }) => {
const toggle = (val: string) => {
if (selected.includes(val)) onChange(selected.filter(x => x !== val));
else onChange([...selected, val]);
};
return (
<div className="flex flex-col gap-2 mb-8 col-span-1 md:col-span-2">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">{label}</label>
<div className="p-4 rounded-2xl border-2 border-gray-300 bg-white shadow-sm max-h-64 overflow-y-auto grid grid-cols-1 md:grid-cols-2 gap-2">
{options.map(opt => (
<label key={opt.slug} className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded-lg cursor-pointer border border-transparent hover:border-gray-200 transition-all">
<input type="checkbox" checked={selected.includes(opt.slug)} onChange={() => toggle(opt.slug)} className="w-5 h-5 accent-[#8bc34a]" />
<span className="text-sm font-bold text-gray-700">{opt.name}</span>
</label>
))}
</div>
</div>
);
};
// KOMPONENT 2: Viser flate JSON-objekter (som fasiliteter) som rader med Nøkkel og Verdi
const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any, onChange: (v: any) => void }) => {
const entries = Object.entries(value || {});
@ -35,39 +56,38 @@ const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any,
};
return (
<div className="flex flex-col gap-3 mb-8 bg-gray-50 p-6 rounded-[2rem] border border-gray-100">
<label className="text-xs font-black uppercase tracking-widest text-[#11280f]">{label}</label>
<div className="space-y-2">
<div className="flex flex-col gap-4 mb-8 bg-gray-100 p-6 md:p-8 rounded-[2rem] border border-gray-200 shadow-sm">
<label className="text-sm font-black uppercase tracking-widest text-[#11280f]">{label}</label>
<div className="space-y-3">
{entries.map(([k, v]) => (
<div key={k} className="flex gap-2 items-center">
<div key={k} className="flex gap-3 items-center">
<input
className="w-1/3 p-3 rounded-xl border border-gray-200 text-xs font-bold focus:border-[#8bc34a] outline-none"
className="w-1/3 p-4 rounded-xl border-2 border-gray-300 text-sm font-bold text-black bg-white focus:border-[#8bc34a] outline-none shadow-sm"
placeholder="Nøkkel (f.eks proshop)"
defaultValue={k.startsWith('ny_rad_') ? '' : k}
onBlur={e => updateKey(k, e.target.value, v)}
/>
<input
className="w-full p-3 rounded-xl border border-gray-200 text-xs focus:border-[#8bc34a] outline-none"
className="w-full p-4 rounded-xl border-2 border-gray-300 text-base font-medium text-black bg-white focus:border-[#8bc34a] outline-none shadow-sm"
placeholder="Verdi (f.eks Ja, eller et navn)"
value={String(v)}
onChange={e => updateVal(k, e.target.value)}
/>
<button onClick={() => removeKey(k)} className="p-3 bg-red-100 text-red-600 hover:bg-red-200 rounded-xl font-bold transition-colors"></button>
<button onClick={() => removeKey(k)} className="p-4 bg-red-100 text-red-700 hover:bg-red-200 hover:text-red-900 rounded-xl font-black text-lg transition-colors border border-red-200"></button>
</div>
))}
</div>
<button onClick={addRow} className="mt-2 text-left text-xs font-bold text-[#8bc34a] hover:text-[#11280f] transition-colors">+ Legg til ny rad</button>
<button onClick={addRow} className="mt-2 text-left text-sm font-black text-[#8bc34a] hover:text-[#11280f] transition-colors bg-white px-6 py-3 rounded-xl border-2 border-[#8bc34a] self-start">+ Legg til ny rad</button>
</div>
);
};
// KOMPONENT 2: Viser Arrays med objekter (som Greenfee-lister) som små pene kort
// KOMPONENT 3: Viser Arrays med objekter (som Greenfee-lister) som små pene kort
const ListObjectEditor = ({ label, value, templateKeys, onChange }: { label: string, value: any[], templateKeys: string[], onChange: (v: any[]) => void }) => {
const items = Array.isArray(value) ? value : [];
const updateField = (index: number, key: string, val: string | number) => {
const newItems = [...items];
// Prøv å konvertere til tall hvis det gir mening (for priser)
const parsedVal = (!isNaN(Number(val)) && val !== "") ? Number(val) : val;
newItems[index] = { ...newItems[index], [key]: parsedVal };
onChange(newItems);
@ -85,18 +105,18 @@ const ListObjectEditor = ({ label, value, templateKeys, onChange }: { label: str
};
return (
<div className="flex flex-col gap-4 mb-8 bg-gray-50 p-6 rounded-[2rem] border border-gray-100">
<label className="text-xs font-black uppercase tracking-widest text-[#11280f]">{label}</label>
<div className="space-y-4">
<div className="flex flex-col gap-4 mb-8 bg-gray-100 p-6 md:p-8 rounded-[2rem] border border-gray-200 shadow-sm">
<label className="text-sm font-black uppercase tracking-widest text-[#11280f]">{label}</label>
<div className="space-y-6">
{items.map((item, idx) => (
<div key={idx} className="flex flex-col bg-white p-4 rounded-2xl border border-gray-100 shadow-sm relative group hover:border-[#8bc34a] transition-colors">
<button onClick={() => removeRow(idx)} className="absolute top-4 right-4 w-6 h-6 flex items-center justify-center bg-red-100 text-red-600 hover:bg-red-200 rounded-full text-xs font-bold transition-colors"></button>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pr-8">
<div key={idx} className="flex flex-col bg-white p-6 rounded-2xl border-2 border-gray-300 shadow-sm relative group hover:border-[#8bc34a] transition-colors">
<button onClick={() => removeRow(idx)} className="absolute top-4 right-4 w-8 h-8 flex items-center justify-center bg-red-100 text-red-700 hover:bg-red-200 hover:text-red-900 rounded-full text-sm font-black transition-colors border border-red-200"></button>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pr-10">
{templateKeys.map(key => (
<div key={key} className="flex flex-col gap-1">
<label className="text-[9px] uppercase font-bold text-gray-400 tracking-wider">{key.replace(/_/g, ' ')}</label>
<div key={key} className="flex flex-col gap-2">
<label className="text-xs uppercase font-black text-gray-600 tracking-wider">{key.replace(/_/g, ' ')}</label>
<input
className="p-2 rounded-lg border border-gray-200 text-xs font-bold text-gray-700 focus:border-[#8bc34a] outline-none"
className="p-3 rounded-lg border-2 border-gray-300 text-base font-bold text-black bg-gray-50 focus:bg-white focus:border-[#8bc34a] outline-none transition-colors"
value={item[key] || ""}
onChange={e => updateField(idx, key, e.target.value)}
/>
@ -106,18 +126,199 @@ const ListObjectEditor = ({ label, value, templateKeys, onChange }: { label: str
</div>
))}
</div>
<button onClick={addRow} className="mt-2 text-left text-xs font-bold text-[#8bc34a] hover:text-[#11280f] transition-colors">+ Legg til nytt element</button>
<button onClick={addRow} className="mt-2 text-left text-sm font-black text-[#8bc34a] hover:text-[#11280f] transition-colors bg-white px-6 py-3 rounded-xl border-2 border-[#8bc34a] self-start">+ Legg til nytt element</button>
</div>
);
};
// KOMPONENT 4: DEN NYE SCOREKORT-BYGGEREN
const ScorecardBuilder = ({ course, onChange }: { course: any, onChange: (c: any) => void }) => {
const ALL_KEYS = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest'];
const [holes, setHoles] = useState<any[]>(() => {
const h = course.holes || [];
if (h.length === 0) {
return Array.from({length: 18}, (_, i) => ({ hole_number: i+1, par: '', hcp_index: '', lengths: {} }));
}
return h.sort((a: any, b: any) => a.hole_number - b.hole_number);
});
const [activeKeys, setActiveKeys] = useState<string[]>(() => {
const keys = new Set<string>();
holes.forEach(h => {
if (h.lengths) Object.keys(h.lengths).forEach(k => keys.add(k));
});
return ALL_KEYS.filter(k => keys.has(k));
});
const [tees, setTees] = useState<any>(() => {
const herrer = course.tee_boxes?.herrer || [];
const damer = course.tee_boxes?.damer || [];
const initialTees = { herrer: {} as any, damer: {} as any };
activeKeys.forEach((key, idx) => {
initialTees.herrer[key] = herrer[idx] || { navn_utslag: '', baneverdi: '', slopeverdi: '' };
initialTees.damer[key] = damer[idx] || { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' };
});
return initialTees;
});
const syncToParent = (newHoles: any[], newKeys: string[], newTees: any) => {
const updatedTeeBoxes = {
herrer: newKeys.map(k => newTees.herrer[k] || {}),
damer: newKeys.map(k => newTees.damer[k] || {})
};
onChange({
...course,
holes: newHoles,
tee_boxes: updatedTeeBoxes
});
};
const toggleKey = (key: string) => {
const newKeys = activeKeys.includes(key)
? activeKeys.filter(k => k !== key)
: ALL_KEYS.filter(k => activeKeys.includes(k) || k === key);
setActiveKeys(newKeys);
const newTees = { ...tees };
if (!newTees.herrer[key]) newTees.herrer[key] = { navn_utslag: '', baneverdi: '', slopeverdi: '' };
if (!newTees.damer[key]) newTees.damer[key] = { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' };
setTees(newTees);
syncToParent(holes, newKeys, newTees);
};
const updateTee = (gender: 'herrer'|'damer', key: string, field: string, value: string) => {
const newTees = { ...tees };
newTees[gender][key] = { ...newTees[gender][key], [field]: value };
setTees(newTees);
syncToParent(holes, activeKeys, newTees);
};
const updateHole = (index: number, field: string, value: string, lengthKey: string | null = null) => {
const newHoles = [...holes];
if (lengthKey) {
newHoles[index].lengths = { ...newHoles[index].lengths, [lengthKey]: value === '' ? '' : Number(value) };
} else {
newHoles[index][field] = value === '' ? '' : Number(value);
}
setHoles(newHoles);
syncToParent(newHoles, activeKeys, tees);
};
const addHole = () => {
const newHoles = [...holes, { hole_number: holes.length + 1, par: '', hcp_index: '', lengths: {} }];
setHoles(newHoles);
syncToParent(newHoles, activeKeys, tees);
};
const removeLastHole = () => {
const newHoles = holes.slice(0, -1);
setHoles(newHoles);
syncToParent(newHoles, activeKeys, tees);
};
return (
<div className="flex flex-col gap-4 mt-6">
<div className="flex flex-wrap gap-4 items-center bg-gray-100 p-4 rounded-xl border-2 border-gray-200">
<span className="text-xs font-black uppercase tracking-widest text-gray-600">Aktive Utslagskolonner:</span>
{ALL_KEYS.map(k => (
<label key={k} className="flex items-center gap-2 text-sm font-bold cursor-pointer text-black">
<input
type="checkbox"
checked={activeKeys.includes(k)}
onChange={() => toggleKey(k)}
className="w-5 h-5 accent-[#8bc34a]"
/>
{k.toUpperCase()}
</label>
))}
</div>
<div className="overflow-x-auto rounded-2xl border-2 border-gray-300 shadow-sm bg-white pb-2">
<table className="w-full text-center text-sm min-w-[800px] border-collapse">
<thead>
<tr className="bg-gray-100 text-gray-700 text-xs font-black uppercase tracking-widest border-b-2 border-gray-300">
<th className="p-3 border-r border-gray-200">Hull</th>
<th className="p-3 border-r border-gray-200">Par</th>
<th className="p-3 border-r border-gray-300">HCP</th>
{activeKeys.map(k => <th key={k} className="p-3 border-r border-gray-300 w-32">{k}</th>)}
</tr>
{/* Herrer */}
<tr className="bg-blue-50 border-b border-gray-300">
<th colSpan={3} className="p-3 text-right text-[10px] font-black text-blue-900 uppercase tracking-widest border-r border-gray-300">
Herrer (Navn / CR / Slope)
</th>
{activeKeys.map(k => (
<td key={k} className="p-2 border-r border-gray-300 align-top">
<div className="flex flex-col gap-1">
<input placeholder="Eks: Gul" className="w-full p-2 text-xs font-bold text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.navn_utslag || ''} onChange={e => updateTee('herrer', k, 'navn_utslag', e.target.value)} />
<div className="flex gap-1">
<input placeholder="CR" className="w-1/2 p-2 text-xs text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.baneverdi || ''} onChange={e => updateTee('herrer', k, 'baneverdi', e.target.value)} />
<input placeholder="Slope" className="w-1/2 p-2 text-xs text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.slopeverdi || ''} onChange={e => updateTee('herrer', k, 'slopeverdi', e.target.value)} />
</div>
</div>
</td>
))}
</tr>
{/* Damer */}
<tr className="bg-red-50 border-b-4 border-gray-400">
<th colSpan={3} className="p-3 text-right text-[10px] font-black text-red-900 uppercase tracking-widest border-r border-gray-300">
Damer (Navn / CR / Slope)
</th>
{activeKeys.map(k => (
<td key={k} className="p-2 border-r border-gray-300 align-top">
<div className="flex flex-col gap-1">
<input placeholder="Eks: Rød" className="w-full p-2 text-xs font-bold text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.navn_utslag_damer || ''} onChange={e => updateTee('damer', k, 'navn_utslag_damer', e.target.value)} />
<div className="flex gap-1">
<input placeholder="CR" className="w-1/2 p-2 text-xs text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.baneverdi_damer || ''} onChange={e => updateTee('damer', k, 'baneverdi_damer', e.target.value)} />
<input placeholder="Slope" className="w-1/2 p-2 text-xs text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.slopeverdi_damer || ''} onChange={e => updateTee('damer', k, 'slopeverdi_damer', e.target.value)} />
</div>
</div>
</td>
))}
</tr>
</thead>
<tbody>
{holes.map((h, idx) => (
<tr key={idx} className="border-b border-gray-200 hover:bg-gray-50">
<td className="p-2 font-black text-lg text-gray-800 border-r border-gray-200">{h.hole_number}</td>
<td className="p-2 border-r border-gray-200"><input type="number" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.par || ''} onChange={e => updateHole(idx, 'par', e.target.value)} /></td>
<td className="p-2 border-r border-gray-300"><input type="number" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.hcp_index || ''} onChange={e => updateHole(idx, 'hcp_index', e.target.value)} /></td>
{activeKeys.map(k => (
<td key={k} className="p-2 border-r border-gray-300 bg-gray-50/50">
<input type="number" placeholder="Lengde" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-mono font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.lengths?.[k] || ''} onChange={e => updateHole(idx, 'lengths', e.target.value, k)} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex gap-4 px-2">
<button onClick={addHole} className="text-sm font-black text-[#8bc34a] hover:text-[#11280f] px-4 py-2 border-2 border-[#8bc34a] rounded-xl">+ Legg til hull</button>
<button onClick={removeLastHole} className="text-sm font-black text-red-500 hover:text-red-700 px-4 py-2 border-2 border-red-500 rounded-xl">- Slett siste hull</button>
</div>
</div>
);
};
export default function EditFacilityClient({ initialData }: { initialData: any }) {
export default function EditFacilityClient({ initialData, allFacilities }: { initialData: any, allFacilities: any[] }) {
const router = useRouter();
const [formData, setFormData] = useState(initialData);
const [activeTab, setActiveTab] = useState('generelt');
const [saving, setSaving] = useState(false);
// Trekk ut unike arkitekter fra alle anlegg
const uniqueArchitects = Array.from(new Set(allFacilities.map(f => f.architect).filter(Boolean))).sort();
// Sørg for at cooperating_clubs er et array
const [coopClubs, setCoopClubs] = useState<string[]>(
Array.isArray(initialData.cooperating_clubs) ? initialData.cooperating_clubs :
(typeof initialData.cooperating_clubs === 'string' ? JSON.parse(initialData.cooperating_clubs) : [])
);
const handleChange = (field: string, value: any) => {
setFormData((prev: any) => ({ ...prev, [field]: value }));
};
@ -132,7 +333,7 @@ export default function EditFacilityClient({ initialData }: { initialData: any }
});
if (res.ok) {
alert("Anlegget ble oppdatert suksessfullt!");
alert("Lagret suksessfullt!");
router.refresh();
} else {
alert("Noe gikk galt under lagring.");
@ -148,64 +349,116 @@ export default function EditFacilityClient({ initialData }: { initialData: any }
{ id: 'lokasjon', label: 'Lokasjon & Kontakt' },
{ id: 'linker', label: 'Lenker & Media' },
{ id: 'okonomi', label: 'Økonomi & Medlemskap' },
{ id: 'avansert', label: 'Spesial & Strukturer (JSON)' }
{ id: 'baner', label: 'Baner & Scorekort' }
];
const Input = ({ field, label, type = "text" }: { field: string, label: string, type?: string }) => (
<div className="flex flex-col gap-2 mb-6">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500">{label}</label>
{type === 'textarea' ? (
<textarea className="p-4 rounded-2xl border-2 border-gray-100 bg-gray-50 focus:bg-white focus:border-[#8bc34a] outline-none transition-all" rows={4} value={formData[field] || ""} onChange={e => handleChange(field, e.target.value)} />
) : (
<input type={type} className="p-4 rounded-2xl border-2 border-gray-100 bg-gray-50 focus:bg-white focus:border-[#8bc34a] outline-none transition-all font-bold text-[#11280f]" value={formData[field] || ""} onChange={e => handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)} />
)}
</div>
);
const Input = ({ field, label, type = "text" }: { field: string, label: string, type?: string }) => {
// Håndter dato-formatet (YYYY-MM-DD) slik at HTML5 date picker forstår det
let displayValue = formData[field] || "";
if (type === 'date' && displayValue) {
displayValue = displayValue.split('T')[0]; // Kutter vekk klokkeslettet
}
return (
<div className="flex flex-col gap-2 mb-8">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">{label}</label>
{type === 'textarea' ? (
<textarea
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base shadow-sm focus:border-[#8bc34a] outline-none transition-all"
rows={4}
value={displayValue}
onChange={e => handleChange(field, e.target.value)}
/>
) : (
<input
type={type}
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none transition-all"
value={displayValue}
onChange={e => handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)}
/>
)}
</div>
);
};
return (
<div className="max-w-[1200px] mx-auto p-8 relative z-40 bg-white min-h-screen">
<div className="flex justify-between items-center mb-10 pb-6 border-b border-gray-100">
<div className="max-w-[1400px] mx-auto p-4 md:p-8 relative z-40 bg-white min-h-screen">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-10 pb-6 border-b border-gray-200 gap-6">
<div>
<Link href="/admin" className="text-sm font-bold text-gray-400 hover:text-[#8bc34a] mb-2 block"> Tilbake til oversikten</Link>
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block"> Tilbake til oversikten</Link>
<h1 className="text-4xl font-black text-[#11280f]">Rediger: <span className="text-[#8bc34a]">{initialData.name}</span></h1>
</div>
<button
onClick={handleSave}
disabled={saving}
className="bg-[#11280f] text-white px-8 py-4 rounded-full font-black uppercase tracking-widest hover:bg-[#8bc34a] transition-colors shadow-xl disabled:opacity-50"
className="bg-[#11280f] text-white px-8 py-4 rounded-full font-black uppercase tracking-widest hover:bg-[#8bc34a] transition-colors shadow-xl disabled:opacity-50 w-full md:w-auto"
>
{saving ? "Lagrer..." : "Lagre endringer"}
</button>
</div>
<div className="flex flex-col md:flex-row gap-10">
<div className="w-full md:w-1/4 flex flex-col gap-2">
{/* SIDEBAR MENY */}
<div className="w-full md:w-1/4 flex flex-col gap-3">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`p-4 rounded-2xl text-left font-black uppercase text-xs tracking-widest transition-all ${activeTab === tab.id ? 'bg-[#8bc34a] text-white shadow-lg translate-x-2' : 'bg-gray-50 text-gray-500 hover:bg-gray-100'}`}
className={`p-4 rounded-2xl text-left font-black uppercase text-sm tracking-widest transition-all ${activeTab === tab.id ? 'bg-[#8bc34a] text-white shadow-lg translate-x-2' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{tab.label}
</button>
))}
</div>
<div className="w-full md:w-3/4 bg-white">
{/* SKJEMA OMRÅDE */}
<div className="w-full md:w-3/4">
{activeTab === 'generelt' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
<div className="col-span-1 md:col-span-2"><Input field="name" label="Anleggsnavn" /></div>
<div className="col-span-1 md:col-span-2"><Input field="description" label="Beskrivelse" type="textarea" /></div>
{/* NYTT: Viktig beskjed / Kursiv intro */}
<div className="col-span-1 md:col-span-2"><Input field="footnote" label="Viktig beskjed (Kursiv intro-tekst over beskrivelsen)" type="textarea" /></div>
<div className="col-span-1 md:col-span-2"><Input field="description" label="Hovedbeskrivelse" type="textarea" /></div>
<Input field="banetype" label="Banetype (f.eks Park/Skog)" />
<Input field="season" label="Sesong (f.eks April-Oktober)" />
<Input field="established_year" label="Byggeår" type="number" />
<Input field="architect" label="Arkitekt" />
{/* NYTT: Arkitekt med forslag */}
<div className="flex flex-col gap-2 mb-8">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Arkitekt</label>
<input
list="architect-list"
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none transition-all"
value={formData.architect || ""}
onChange={e => handleChange('architect', e.target.value)}
placeholder="Velg eller skriv inn ny..."
/>
<datalist id="architect-list">
<option value="Ukjent" />
{uniqueArchitects.map((arch: any) => <option key={arch} value={arch} />)}
</datalist>
</div>
<Input field="length_meters" label="Totallengde (meter)" type="number" />
{/* NYTT: Samarbeidende klubber */}
<MultiSelect
label="Samarbeidende Klubber (Gjestespill etc.)"
options={allFacilities.filter(f => f.id !== initialData.id)}
selected={coopClubs}
onChange={(val) => {
setCoopClubs(val);
handleChange('cooperating_clubs', val);
}}
/>
</div>
)}
{activeTab === 'lokasjon' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
<div className="col-span-1 md:col-span-2"><Input field="address" label="Gateadresse" /></div>
<Input field="zipcode" label="Postnummer" />
<Input field="city" label="Poststed / By" />
@ -227,51 +480,133 @@ export default function EditFacilityClient({ initialData }: { initialData: any }
<Input field="weather_url" label="Vær URL (YR)" />
<Input field="webcam_url" label="Webkamera URL" />
<Input field="video_url" label="Video URL (YouTube/Vimeo)" />
{/* NYTT: Sosiale Medier lagt inn som liste-editor her */}
<ListObjectEditor
label="Sosiale Medier (Legg inn f.eks facebook, instagram, linkedin)"
value={formData.social_links}
templateKeys={['platform', 'url']}
onChange={(v) => handleChange('social_links', v)}
/>
</div>
)}
{activeTab === 'okonomi' && (
<div className="flex flex-col">
<h3 className="font-black uppercase tracking-widest text-gray-300 mb-4 pb-2 border-b">Medlemskap</h3>
<Input field="navn_standard_medlemskap" label="Navn på standard medlemskap" />
<Input field="standard_medlemskap" label="Pris standard (kun tall)" type="number" />
<Input field="standard_medlemskap_kommentarer" label="Kommentar standard" />
<Input field="navn_rimeligste_alternativ" label="Navn på rimeligste" />
<Input field="rimeligste_alternativ" label="Pris rimeligste (kun tall)" type="number" />
<Input field="medlemskap_url" label="Lenke til medlemskapsside" />
{/* NYTT FELT FOR MANUELL DATO */}
<div className="mb-6">
<Input field="membership_updated_at" label="Sist Oppdatert (Dato for Medlemskapspriser)" type="date" />
</div>
<div className="bg-gray-100 p-6 rounded-2xl mb-8 border border-gray-200">
<h3 className="font-black uppercase tracking-widest text-gray-800 mb-6 pb-2 border-b-2 border-gray-200">Medlemskap</h3>
<Input field="navn_standard_medlemskap" label="Navn på standard medlemskap" />
<Input field="standard_medlemskap" label="Pris standard (kun tall)" type="number" />
<Input field="standard_medlemskap_kommentarer" label="Kommentar standard" />
<Input field="navn_rimeligste_alternativ" label="Navn på rimeligste" />
<Input field="rimeligste_alternativ" label="Pris rimeligste (kun tall)" type="number" />
<Input field="medlemskap_url" label="Lenke til medlemskapsside" />
</div>
<h3 className="font-black uppercase tracking-widest text-gray-300 mb-4 mt-8 pb-2 border-b">Veien til Golf (VTG)</h3>
<Input field="vtg_pris" label="Pris VTG kurs (kun tall)" type="number" />
<Input field="vtg_lenke" label="Lenke til VTG påmelding" />
<div className="bg-gray-100 p-6 rounded-2xl border border-gray-200">
<h3 className="font-black uppercase tracking-widest text-gray-800 mb-6 pb-2 border-b-2 border-gray-200">Veien til Golf (VTG)</h3>
<Input field="vtg_pris" label="Pris VTG kurs (kun tall)" type="number" />
<Input field="vtg_lenke" label="Lenke til VTG påmelding" />
</div>
{/* Tilbud (tidligere under Avansert) */}
<div className="mt-8 border-t-2 border-gray-200 pt-8">
<KeyValueEditor label="Fasiliteter (Proshop, Kafé etc.)" value={formData.amenities} onChange={(v) => handleChange('amenities', v)} />
<KeyValueEditor label="Norsk Seniorgolf (NSG)" value={formData.nsg_data} onChange={(v) => handleChange('nsg_data', v)} />
<KeyValueEditor label="Golfamore Info" value={formData.golfamore_data} onChange={(v) => handleChange('golfamore_data', v)} />
<ListObjectEditor
label="Greenfee Priser"
value={formData.greenfee}
templateKeys={['banenavn', 'priskategori', 'pris_voksne', 'pris_junior']}
onChange={(v) => handleChange('greenfee', v)}
/>
<ListObjectEditor
label="Golfpakker"
value={formData.golfpakker}
templateKeys={['navn', 'pris', 'beskrivelse']}
onChange={(v) => handleChange('golfpakker', v)}
/>
</div>
</div>
)}
{activeTab === 'avansert' && (
<div className="flex flex-col gap-4">
<div className="bg-[#f1f7ed] p-5 rounded-2xl border border-[#7ca982]/20 mb-4">
<h3 className="font-black text-[#11280f] text-sm uppercase tracking-widest mb-1">Strukturerte Data</h3>
<p className="text-xs text-gray-600">Her kan du redigere komplekse lister og felter uten å bekymre deg for kodefeil. Skjemaet lagrer det trygt i databasen for deg.</p>
{/* BANER & SCOREKORT MED NY GRAFISK BYGGER */}
{activeTab === 'baner' && (
<div className="flex flex-col gap-8">
<div className="bg-[#f1f7ed] p-6 rounded-2xl border-2 border-[#7ca982] mb-4">
<h3 className="font-black text-[#11280f] text-lg uppercase tracking-widest mb-2">Baner og Scorekort</h3>
<p className="text-sm text-gray-800 font-medium">Bruk det interaktive skjemaet under for å redigere lengder, par og utslag.</p>
</div>
{/* Enkle Key-Value editorer for objekter */}
<KeyValueEditor label="Fasiliteter (Proshop, Kafé etc.)" value={formData.amenities} onChange={(v) => handleChange('amenities', v)} />
<KeyValueEditor label="Norsk Seniorgolf Info" value={formData.nsg_data} onChange={(v) => handleChange('nsg_data', v)} />
<KeyValueEditor label="Golfamore Info" value={formData.golfamore_data} onChange={(v) => handleChange('golfamore_data', v)} />
{/* Komplekse Liste-editorer for Arrays */}
<ListObjectEditor
label="Greenfee Priser"
value={formData.greenfee}
templateKeys={['banenavn', 'priskategori', 'pris_voksne', 'pris_junior']}
onChange={(v) => handleChange('greenfee', v)}
/>
<ListObjectEditor
label="Golfpakker"
value={formData.golfpakker}
templateKeys={['navn', 'pris', 'beskrivelse']}
onChange={(v) => handleChange('golfpakker', v)}
/>
{formData.courses?.map((course: any, cIdx: number) => (
<div key={course.id || cIdx} className="bg-gray-100 p-8 rounded-[2rem] border-2 border-gray-200 shadow-sm mb-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4 border-b-2 border-gray-200 pb-4">
<h4 className="text-2xl font-black text-black">{course.name}</h4>
<span className={`px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest ${course.is_main_course ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-300 text-gray-700'}`}>
{course.is_main_course ? 'Hovedbane' : 'Sekundærbane'}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
<div className="flex flex-col gap-2 mb-6">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Banenavn</label>
<input className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.name || ""} onChange={e => {
const newCourses = [...formData.courses];
newCourses[cIdx] = {...course, name: e.target.value};
handleChange('courses', newCourses);
}} />
</div>
<div className="flex flex-col gap-2 mb-6">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Status</label>
<select className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.status || "ukjent"} onChange={e => {
const newCourses = [...formData.courses];
newCourses[cIdx] = {...course, status: e.target.value};
handleChange('courses', newCourses);
}}>
<option value="aapen">🟢 Åpen</option>
<option value="aapen_med_vintergreener">🟡 Vintergreener</option>
<option value="aapner_snart">🟡 Åpner Snart</option>
<option value="stengt">🔴 Stengt</option>
<option value="nedlagt"> Nedlagt</option>
<option value="ukjent"> Ukjent</option>
</select>
</div>
<div className="flex flex-col gap-2 mb-6">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Total Par (Bane)</label>
<input type="number" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.par || ""} onChange={e => {
const newCourses = [...formData.courses];
newCourses[cIdx] = {...course, par: Number(e.target.value)};
handleChange('courses', newCourses);
}} />
</div>
<div className="flex flex-col gap-2 mb-6">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Utløpsdato Slope</label>
<input type="date" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.slope_valid_until ? course.slope_valid_until.split('T')[0] : ""} onChange={e => {
const newCourses = [...formData.courses];
newCourses[cIdx] = {...course, slope_valid_until: e.target.value};
handleChange('courses', newCourses);
}} />
</div>
</div>
{/* DET NYE SCOREKORTET INKLUDERES HER */}
<ScorecardBuilder
course={course}
onChange={(updatedCourse) => {
const newCourses = [...formData.courses];
newCourses[cIdx] = updatedCourse;
handleChange('courses', newCourses);
}}
/>
</div>
))}
</div>
)}
</div>

View file

@ -3,12 +3,18 @@ import EditFacilityClient from "./EditFacilityClient";
export default async function EditFacilityPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
// Henter anlegget vi skal redigere
const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: 'no-store' });
const facility = await res.json();
// Henter ALLE anlegg slik at vi kan bygge lister for samarbeid og arkitekter
const allRes = await fetch(`${API_URL}/facilities`, { cache: 'no-store' });
const allFacilities = await allRes.json();
if (!facility || facility.error) {
return <div className="p-20 text-center font-bold text-2xl">Fant ikke anlegget...</div>;
}
return <EditFacilityClient initialData={facility} />;
return <EditFacilityClient initialData={facility} allFacilities={allFacilities} />;
}

View file

@ -1,12 +1,12 @@
"use client";
/**
* TEE OFF DETAIL VIEW - COMPLETE v3.21
* TEE OFF DETAIL VIEW - COMPLETE v3.22
* ---------------------------------------------------------------------------
* FIX: Gjenopprettet "Turneringer" i den flytende knapperaden over bildet.
* FIX: Byttet plass tekst og sidebar (Tekst øverst mobil).
* FIX: Økt padding (pb-32) i Hero-teksten mobil for å unngå krasj med knapper.
* FIX: Alle 4 kontaktpunkter i sidebar er klikkbare (tel:0047 fix inkludert).
* NEW: Webkamera under Andre ressurser, NSG, Golfamore og Bilutleie under Andre Tilbud.
* NEW: Sosiale Medier, Footnote og Samarbeidende klubber integrert.
* REGEL: Beholder monokrome ikoner, 22/78 layout og robust JSON-parsing.
* ---------------------------------------------------------------------------
*/
@ -34,7 +34,7 @@ const renderValue = (val: string) => {
const Icon = ({ children, className = "w-5 h-5" }: { children: React.ReactNode, className?: string }) => (
<svg
className={`${className} flex-shrink-0 text-[#11280f]`}
className={`${className} flex-shrink-0 text-current`}
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ width: '20px', height: '20px' }}
>
@ -56,6 +56,17 @@ const ICONS = {
weather: <><path d="M12 2v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="M20 12h2"/><path d="m19.07 4.93-1.41 1.41"/><path d="M15.947 12.65a4 4 0 0 0-5.925-4.128"/><path d="M13 22H7a5 5 0 1 1 4.9-6H13a3 3 0 0 1 0 6Z"/></>
};
const SOCIAL_ICONS: Record<string, React.ReactNode> = {
facebook: <path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />,
instagram: <><rect x="2" y="2" width="20" height="20" rx="5" ry="5" /><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" /><line x1="17.5" y1="6.5" x2="17.51" y2="6.5" /></>,
twitter: <path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z" />,
x: <path d="M4 4l16 16M4 20L20 4" />,
linkedin: <><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" /><rect x="2" y="9" width="4" height="12" /><circle cx="4" cy="4" r="2" /></>,
youtube: <><path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33 2.78 2.78 0 0 0 1.94 2c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.33 29 29 0 0 0-.46-5.33z" /><polygon points="9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02" /></>,
tiktok: <path d="M9 12a4 4 0 1 0 4 4V2a5 5 0 0 0 5 5h-2a3 3 0 0 1-3-3V16a2 2 0 1 1-2-2v-2z" />,
snapchat: <path d="M12 2C8.5 2 6 5 6 8.5c0 1.5.5 3 1.5 4-1 .5-2.5 1-3.5 1-.5 0-1 .5-1 1s.5 1 1.5 1h15c1 0 1.5-.5 1.5-1s-.5-1-1-1c-1 0-2.5-.5-3.5-1 1-1 1.5-2.5 1.5-4C18 5 15.5 2 12 2zm0 15c-3 0-5-1-5-1s.5 1.5 1.5 2h7C16.5 17.5 17 16 17 16s-2 1-5 1z" />
};
export default function FacilityDetailView({ facility }: { facility: any }) {
const [showBackToTop, setShowBackToTop] = useState(false);
const [currentSlide, setCurrentSlide] = useState(0);
@ -74,11 +85,14 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
const greenfeeRaw = parseJson(facility.greenfee, []);
const shotzoom = parseJson(facility.shotzoom, []);
// Parse Golfamore og NSG
const golfamoreData = parseJson(facility.golfamore_data, {});
const nsgData = parseJson(facility.nsg_data, {});
const socialLinksRaw = parseJson(facility.social_links, []);
const socialLinks = Array.isArray(socialLinksRaw) ? socialLinksRaw : [];
const coopClubsRaw = parseJson(facility.cooperating_clubs, []);
const cooperatingClubs = Array.isArray(coopClubsRaw) ? coopClubsRaw : [];
// Sjekker om de er aktive
const hasGolfamore = facility.golfamore === true;
const hasNSG = facility.nsg_url || (nsgData && Object.keys(nsgData).length > 0);
@ -89,8 +103,8 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
return acc;
}, {});
const sidebarLinkClass = "flex items-center gap-4 hover:text-[#ff5722] transition-colors group";
const resourceBtnClass = "flex justify-between items-center p-5 bg-gray-50 rounded-2xl text-[11px] font-black uppercase hover:bg-[#ff5722] hover:text-white transition-all group";
const sidebarLinkClass = "flex items-center gap-4 text-[#11280f] hover:text-[#ff5722] transition-colors group";
const resourceBtnClass = "flex justify-between items-center p-5 bg-gray-50 rounded-2xl text-[11px] font-black uppercase text-[#11280f] hover:bg-[#ff5722] hover:text-white transition-all group";
useEffect(() => {
if (gallery.length <= 1) return;
@ -138,8 +152,8 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
)}
</div>
{/* FLYTENDE HURTIGKNAPPER (Inkludert Turneringer) */}
<div className="absolute bottom-8 right-8 z-40 flex gap-2.5 bg-black/30 backdrop-blur-md p-2 rounded-2xl border border-white/10 shadow-2xl">
{/* FLYTENDE HURTIGKNAPPER */}
<div className="absolute bottom-8 right-8 z-40 flex gap-2.5 bg-black/30 backdrop-blur-md p-2 rounded-2xl border border-white/10 shadow-2xl text-[#11280f]">
{facility.website_url && <a href={facility.website_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.web} /></a>}
{facility.golfbox_booking_url && <a href={facility.golfbox_booking_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.booking} /></a>}
{facility.golfbox_tournament_url && <a href={facility.golfbox_tournament_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.trophy} /></a>}
@ -176,7 +190,11 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
<section id="intro" className="flex flex-col lg:flex-row gap-0 md:gap-8 items-stretch">
{/* HOVEDINNHOLD (78%) */}
<div className="lg:w-[78%] bg-white p-10 md:p-16 md:rounded-[3rem] shadow-sm border-b md:border-none">
{facility.footnote && <div className="mb-8 pb-8 border-b border-gray-50 italic text-gray-400 text-lg font-serif">{facility.footnote}</div>}
{facility.footnote && (
<div className="mb-8 pb-8 border-b border-gray-50 italic text-[#ff5722] text-lg font-serif">
{facility.footnote}
</div>
)}
<div className="leading-relaxed text-lg md:text-xl text-gray-600" dangerouslySetInnerHTML={{ __html: facility.description || 'Ingen beskrivelse tilgjengelig.' }} />
</div>
@ -197,7 +215,25 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
</a>
</div>
</div>
<div className="mt-auto pt-10 border-t border-gray-50">
{/* SOSIALE MEDIER IKONER */}
{socialLinks.length > 0 && (
<div className="pt-6 border-t border-gray-50 mt-6 flex flex-wrap gap-3">
{socialLinks.map((social: any, idx: number) => {
const platform = (social.platform || '').toLowerCase().trim();
// Finn riktig ikon, fall tilbake til en generell link-pil
const iconData = SOCIAL_ICONS[platform] || <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3" />;
return (
<a key={idx} href={social.url} target="_blank" rel="noreferrer" title={social.platform} className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center text-[#11280f] hover:bg-[#ff5722] hover:text-white transition-all shadow-sm">
<Icon children={iconData} className="w-4 h-4 text-current" />
</a>
);
})}
</div>
)}
<div className="mt-10 pt-6 border-t border-gray-50">
<Link href={`/`} className="text-[10px] font-black uppercase tracking-widest text-[#7ca982] hover:text-[#11280f] transition-all flex items-center gap-1">
Se alle baner i {facility.county}
</Link>
@ -230,7 +266,6 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
<span className="flex items-center gap-3"><Icon children={ICONS.camera} className="group-hover:text-white" /> Flyfoto</span><span></span>
</a>
)}
{/* Ny rad for Webkamera */}
{facility.webcam_url && (
<a href={facility.webcam_url} target="_blank" className={resourceBtnClass}>
<span className="flex items-center gap-3"><Icon children={ICONS.webcam} className="group-hover:text-white" /> Webkamera</span><span></span>
@ -243,9 +278,9 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
))}
</div>
</div>
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm text-sm font-bold text-gray-700">
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm text-sm font-bold text-gray-700 flex flex-col">
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter text-[#11280f]">Banen</h3>
<div className="space-y-5">
<div className="space-y-5 flex-grow">
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Hull:</span><span>{amenities.antall_hull || '--'}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Lengde:</span><span>{facility.length_meters ? `${facility.length_meters}m` : '--'}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Sesong:</span><span>{facility.season || '--'}</span></div>
@ -263,17 +298,16 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Kølleutleie:</span><span>{amenities.kolleutleie || 'Ja'}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Bilutleie:</span><span>{amenities.bilutleie || 'Nei'}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Simulator:</span><span className="text-right ml-4">{renderValue(amenities.simulator)}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Head Pro:</span><span className="text-right ml-4">{renderValue(amenities.pro)}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Kafé:</span><span className="text-right ml-4">{renderValue(amenities.kafe)}</span></div>
{/* NYE RADER: Golfamore og NSG */}
{/* Golfamore og NSG */}
<div className="flex justify-between border-b border-gray-50 pb-2">
<span className="text-gray-400">Golfamore:</span>
<span className="text-right ml-4">
{hasGolfamore ? <span className="text-[#ff5722] font-black">{golfamoreData.gyldighet || "Ja"}</span> : "Nei"}
</span>
</div>
<div className="flex justify-between">
<div className="flex justify-between border-b border-gray-50 pb-2">
<span className="text-gray-400">Seniorgolf (NSG):</span>
<span className="text-right ml-4">
{hasNSG && facility.nsg_url
@ -282,6 +316,20 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
}
</span>
</div>
{/* SAMARBEIDENDE KLUBBER */}
{cooperatingClubs.length > 0 && (
<div className="pt-2">
<span className="text-gray-400 block mb-2">Samarbeider med:</span>
<div className="flex flex-wrap gap-2">
{cooperatingClubs.map((slug: string) => (
<Link key={slug} href={`/golfbaner/${slug}`} className="px-3 py-1 bg-gray-100 rounded-lg text-[10px] uppercase font-black tracking-widest hover:bg-[#8bc34a] hover:text-white transition-colors">
{slug.replace('-golfklubb', '').replace(/-/g, ' ')}
</Link>
))}
</div>
</div>
)}
</div>
</div>
</section>

View file

@ -0,0 +1,64 @@
"""
TEE OFF ADMIN GENERATOR v1.9 (DEBUG & BULLETPROOF)
---------------------------------------------------------------------------
FUNKSJON: Genererer SQL-kommando for administrator.
STATUS: Beholder TRUNCATE for feilsøking, men sikrer SQL-innsendingen.
---------------------------------------------------------------------------
"""
import pyotp
from passlib.hash import pbkdf2_sha256
import getpass
import sys
def generate_admin():
print("\n" + "="*50)
print(" TEE OFF ADMIN GENERATOR v1.9 (DEBUG MODE)")
print("="*50)
username = input("Brukernavn (f.eks Envide Webutvikling): ").strip()
email = input("E-post: ").strip()
# Sikre mot SQL-feil hvis navnet/eposten inneholder apostrof
safe_username = username.replace("'", "''")
safe_email = email.replace("'", "''")
# Passord-verifisering
while True:
password = getpass.getpass("Skriv inn passord: ")
password_confirm = getpass.getpass("Gjenta passord: ")
if password == password_confirm:
if len(password) < 8:
print("⚠️ Advarsel: Passordet bør være minst 8 tegn.")
print(f"\n[DEBUG] Passord akseptert. Lengde: {len(password)} tegn.")
break
else:
print("❌ Passordene er ikke like. Prøv igjen.\n")
otp_secret = pyotp.random_base32()
print("⏳ Genererer PBKDF2-hash...")
password_hash = pbkdf2_sha256.hash(password)
print(f"[DEBUG] Hash generert. Lengde: {len(password_hash)} tegn.")
print("\n✅ GENERERING VELLYKKET!")
print("-" * 50)
print("SLIK LEGGER DU INN BRUKEREN TRYGT:")
print("-" * 50)
print("1. Gå inn i databasen:")
print(" docker exec -it teeoff_db psql -U teeoff_admin -d teeoff")
print("\n2. Lim inn disse to linjene nøyaktig slik de står:")
print("TRUNCATE admins;")
print(f"INSERT INTO admins (username, email, password_hash, otp_secret) VALUES ('{safe_username}', '{safe_email}', '{password_hash}', '{otp_secret}');")
print("\n3. Skriv 'exit' for å gå ut.")
print("-" * 50)
print("4. KONFIGURER 2FA I GOOGLE AUTHENTICATOR:")
print(f"Bruk denne nøkkelen: {otp_secret}")
print("-" * 50 + "\n")
if __name__ == "__main__":
try:
generate_admin()
except KeyboardInterrupt:
print("\nAvbrutt.")
sys.exit(0)

View file

@ -0,0 +1,111 @@
import asyncio
import asyncpg
import urllib.request
import json
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
async def fetch_json(url):
"""Hjelpefunksjon for å hente JSON fra en URL"""
try:
req = urllib.request.Request(url, headers={'User-Agent': 'TeeOff-Migrator/2.0'})
with urllib.request.urlopen(req) as response:
return json.loads(response.read().decode())
except Exception as e:
# print(f"⚠️ Kunne ikke hente {url}: {e}")
return None
async def fetch_media_urls_by_ids(media_ids):
"""Henter URLer for en liste med media-IDer (ACF Slides)"""
if not media_ids or not isinstance(media_ids, list) or len(media_ids) == 0:
return []
valid_ids = [str(mid) for mid in media_ids if isinstance(mid, (int, str)) and str(mid).isdigit()]
if not valid_ids: return []
ids_str = ",".join(valid_ids)
url = f"https://teeoff.no/wp-json/wp/v2/media?include={ids_str}"
data = await fetch_json(url)
urls = []
if data:
for m in data:
if 'source_url' in m:
urls.append(m['source_url'])
return urls
async def run_robust_import():
print("🕵️‍♂️ Starter den store bildejakten (sjekker både Utvalgt bilde og Slides)...")
conn = await asyncpg.connect(DB_URL)
# VIKTIG: Vi tømmer tabellen for å starte med blanke ark og unngå duplikater
await conn.execute("TRUNCATE facility_images CASCADE;")
print("🗑️ Tømte gammel bilde-tabell. Starter import...")
# Hent alle anleggene fra vår egen database
facilities = await conn.fetch("SELECT id, slug, name FROM facilities ORDER BY name")
total_images_saved = 0
for i, fac in enumerate(facilities):
fac_id = fac['id']
slug = fac['slug']
name = fac['name']
print(f"[{i+1}/{len(facilities)}] Sjekker: {name} ({slug})...")
# Hent data fra WP med ?_embed for å få tak i Utvalgt bilde lett
wp_url = f"https://teeoff.no/wp-json/wp/v2/golfbaner?slug={slug}&_embed"
wp_data_list = await fetch_json(wp_url)
if not wp_data_list:
print(" ❌ Fant ikke anlegget i WordPress API.")
continue
post = wp_data_list[0]
final_image_urls = []
# 1. SJEKK: "Utvalgt bilde" (Standard WordPress)
try:
embedded = post.get('_embedded', {})
if 'wp:featuredmedia' in embedded and len(embedded['wp:featuredmedia']) > 0:
feat_media = embedded['wp:featuredmedia'][0]
feat_url = feat_media.get('source_url')
if feat_url:
final_image_urls.append(feat_url)
# print(f" -> Fant utvalgt bilde.")
except Exception as e:
print(f" ⚠️ Feil ved sjekk av utvalgt bilde: {e}")
# 2. SJEKK: ACF Slides (Bildekarusell)
try:
acf = post.get('acf') or {}
slides_ids = acf.get('slides')
slide_urls = await fetch_media_urls_by_ids(slides_ids)
if slide_urls:
final_image_urls.extend(slide_urls)
# print(f" -> Fant {len(slide_urls)} bilder i slider.")
except Exception as e:
print(f" ⚠️ Feil ved sjekk av slides: {e}")
# Fjern duplikater (hvis samme bilde er brukt begge steder) og bevar rekkefølgen
unique_urls = list(dict.fromkeys(final_image_urls))
# LAGRE I DATABASEN
if unique_urls:
sort_order = 0
for url in unique_urls:
await conn.execute(
"INSERT INTO facility_images (facility_id, image_url, sort_order) VALUES ($1, $2, $3)",
fac_id, url, sort_order
)
sort_order += 1
print(f" ✅ Lagret {len(unique_urls)} unike bilder.")
total_images_saved += len(unique_urls)
else:
print(" ⚠️ Fant INGEN bilder for dette anlegget.")
print(f"\n🎉 FERDIG! Totalt {total_images_saved} bilder er nå trygt lagret i galleriet.")
await conn.close()
if __name__ == "__main__":
asyncio.run(run_robust_import())

View file

@ -0,0 +1,150 @@
import asyncio
import asyncpg
import requests
import json
import os
import re
from datetime import datetime
from dotenv import load_dotenv
# Laster miljøvariabler
load_dotenv()
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
# Grunn-URL uten page-parameter
WP_API_BASE_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100"
def extract_price(text):
"""Finner første hele tall i en tekst og returnerer det som integer."""
if not text:
return None
clean_text = str(text).replace(" ", "").replace(".", "")
match = re.search(r'\d+', clean_text)
if match:
return int(match.group())
return None
def parse_date(date_string):
"""Forsøker å konvertere ulike tekstformater for dato til et ekte Date-objekt."""
if not date_string:
return None
ds = str(date_string).strip().lower()
if ds in ["ukjent", "ikke oppgitt", "har ikke", ""]:
return None
formats = ['%Y-%m-%d', '%d.%m.%Y', '%d/%m/%Y', '%Y%m%d', '%d.%m.%y']
for fmt in formats:
try:
return datetime.strptime(ds, fmt).date()
except ValueError:
continue
return None
def clean_jsonb(value):
"""Sørger for at vi ikke fyller databasen med 'Ikke oppgitt', men bruker tomme lister."""
if not value or str(value).lower() in ["ikke oppgitt", "har ikke / ikke oppgitt"]:
return []
if isinstance(value, str):
return [{"beskrivelse": value}]
if isinstance(value, list):
cleaned = [v for v in value if v and "ikke oppgitt" not in str(v).lower()]
return cleaned
return value
async def run_import():
print("📡 Henter anleggsdata fra WordPress (inkluderer paginering)...")
all_data = []
page = 1
# --- LØKKE SOM HENTER ALLE SIDER FRA WORDPRESS ---
while True:
url = f"{WP_API_BASE_URL}&page={page}"
print(f" -> Henter side {page}...")
response = requests.get(url)
# Hvis vi får 400 Bad Request, betyr det at vi har nådd forbi siste side
if response.status_code != 200:
break
data = response.json()
if not data:
break
all_data.extend(data)
page += 1
print(f"✅ Fant totalt {len(all_data)} anlegg. Starter oppdatering av database...")
conn = await asyncpg.connect(DB_URL)
success_count = 0
for item in all_data:
slug = item.get('slug')
acf = item.get('acf', {})
# Ekstraher og vask verdiene
golfpakker = clean_jsonb(acf.get('golfpakke'))
rabattert_greenfee = clean_jsonb(acf.get('rabattert_greenfee'))
vtg_presentasjon = acf.get('vtg_presentasjon') or None
vtg_lenke = acf.get('lenke_til_kurssider') or None
vtg_pris = extract_price(acf.get('vtg_pris'))
vtg_kursdatoer = clean_jsonb(acf.get('kursdatoer'))
slope_hovedbane = parse_date(acf.get('gyldig_til_og_med'))
slope_bane_to = parse_date(acf.get('gyldig_til_og_med_bane_to'))
try:
# 1. Oppdater fasilitets-tabellen
await conn.execute("""
UPDATE facilities
SET
golfpakker = $1::jsonb,
rabattert_greenfee = $2::jsonb,
vtg_presentasjon = $3,
vtg_lenke = $4,
vtg_pris = $5,
vtg_kursdatoer = $6::jsonb
WHERE slug = $7
""",
json.dumps(golfpakker),
json.dumps(rabattert_greenfee),
vtg_presentasjon,
vtg_lenke,
vtg_pris,
json.dumps(vtg_kursdatoer),
slug)
# 2. Oppdater utløpsdato på hovedbanen
if slope_hovedbane:
await conn.execute("""
UPDATE courses
SET slope_valid_until = $1
WHERE facility_id = (SELECT id FROM facilities WHERE slug = $2)
AND is_main_course = true
""", slope_hovedbane, slug)
# 3. Oppdater utløpsdato på bane 2
if slope_bane_to:
await conn.execute("""
UPDATE courses
SET slope_valid_until = $1
WHERE facility_id = (SELECT id FROM facilities WHERE slug = $2)
AND is_main_course = false
""", slope_bane_to, slug)
success_count += 1
except Exception as e:
print(f" ❌ Feil ved oppdatering av {slug}: {e}")
await conn.close()
print(f"\n🎉 Kjøring fullført! Målrettet import for {success_count} anlegg er lagret.")
if __name__ == "__main__":
asyncio.run(run_import())

View file

@ -0,0 +1,157 @@
import asyncio, asyncpg, urllib.request, json, re, os, requests
# --- KONFIGURASJON ---
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
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"
os.makedirs(MEDIA_DIR, exist_ok=True)
media_cache = {}
def get_url_from_id(media_id):
if not media_id or not isinstance(media_id, int): return None
if media_id in media_cache: return media_cache[media_id]
try:
resp = requests.get(f"{MEDIA_ENDPOINT}/{media_id}", timeout=10)
if resp.status_code == 200:
url = resp.json().get('source_url')
media_cache[media_id] = url
return url
except: return None
def download_media(url, slug, prefix):
if not isinstance(url, str) or not url: return None
clean_url = url.replace("https:///", "https://").replace("http:///", "http://")
if "teeoff.no" not in clean_url: return clean_url
try:
ext = clean_url.split('.')[-1].split('?')[0].lower()
if len(ext) > 4 or len(ext) < 3: ext = "jpg"
filename = f"{prefix}_{slug}.{ext}"
filepath = os.path.join(MEDIA_DIR, filename)
if os.path.exists(filepath): return f"/media/{filename}"
response = requests.get(clean_url, timeout=15)
if response.status_code == 200:
with open(filepath, 'wb') as f: f.write(response.content)
return f"/media/{filename}"
except: pass
return None
def decode_html(text):
if not text: return ""
return str(text).replace('&#038;', '&').replace('&amp;', '&').replace('&nbsp;', ' ').strip()
def parse_int(val):
if val is None or val == '': return None
try:
nums = re.findall(r'\d+', str(val))
return int(nums[0]) if nums else None
except: return None
def extract_url(val):
if isinstance(val, dict): return val.get('url')
if isinstance(val, str): return val
return None
async def run_master_import():
print("🚀 Starter MASTER IMPORT v9.2 (Robust datakonvertering & Banetype)...")
conn = await asyncpg.connect(DB_URL)
# Tømmer kun courses og holes (hjelpetabeller)
await conn.execute("TRUNCATE courses, holes RESTART IDENTITY CASCADE;")
page = 1
while True:
try:
req = urllib.request.Request(f"{WP_API_URL}&page={page}", headers={'User-Agent': 'TeeOff-V9.2'})
with urllib.request.urlopen(req) as response:
data = json.loads(response.read().decode())
except: break
if not data: break
for post in data:
acf = post.get('acf', {})
slug = post['slug']
name = decode_html(post.get('title', {}).get('rendered', ''))
print(f"📦 Mapper {name}...")
# Media & Identifiers
local_main_img = download_media(post.get('_embedded', {}).get('wp:featuredmedia', [{}])[0].get('source_url'), slug, "main")
local_logo = download_media(get_url_from_id(acf.get('logo')) if isinstance(acf.get('logo'), int) else extract_url(acf.get('logo')), slug, "logo")
# Galleri
slides = acf.get('slides') or []
local_gallery = [download_media(get_url_from_id(s) if isinstance(s, int) else extract_url(s), f"{slug}_{i}", "slide") for i, s in enumerate(slides)]
local_gallery = [url for url in local_gallery if url]
# Golfbox
booking_id = acf.get('golfbox_booking_id')
gb_booking_url = f"http://www.golfbox.no/site/system/redirect.asp?locale=nb_NO&rUrl=%2Fsite%2Fressources%2Fbooking%2Fgrid.asp%3FRessource_GUID%3D%{{{str(booking_id).strip().replace('{','').replace('}','')}}}" if booking_id else None
# --- UPSERT FACILITY ---
# Merk: $16 (status_updated_at) pakkes nå inn i TO_DATE for å unngå krasj
await conn.execute('''
INSERT INTO facilities (
name, slug, description, address, city, county, established_year, season,
email, phone, website_url, image_url, logo_url, video_url,
amenities, status_updated_at, gallery, banetype,
ngf_number, golfbox_club_id, golfbox_booking_url,
facebook_url, instagram_url, baneguide_url, flyfoto_url,
golfbox_tournament_url, footnote, social_links, webcam_url,
weather_url, architect,
navn_standard_medlemskap, standard_medlemskap, standard_medlemskap_kommentarer,
navn_rimeligste_alternativ, rimeligste_alternativ, rimeligste_alternativ_kommentarer,
medlemskap_url
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb,
TO_DATE(NULLIF($16, ''), 'YYYYMMDD'),
$17::jsonb, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28::jsonb,
$29, $30, $31, $32, $33, $34, $35, $36, $37, $38
)
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
address = EXCLUDED.address,
city = EXCLUDED.city,
phone = EXCLUDED.phone,
email = EXCLUDED.email,
website_url = EXCLUDED.website_url,
image_url = EXCLUDED.image_url,
logo_url = EXCLUDED.logo_url,
amenities = EXCLUDED.amenities,
gallery = EXCLUDED.gallery,
status_updated_at = EXCLUDED.status_updated_at,
banetype = EXCLUDED.banetype,
architect = EXCLUDED.architect
''', name, slug, decode_html(acf.get('beskrivelse')), acf.get('gateadresse'), acf.get('postnummer_og_poststed'), acf.get('fylke'), parse_int(acf.get('byggear')), acf.get('sesong'), acf.get('e-post'), acf.get('telefon'), extract_url(acf.get('hjemmeside')), local_main_img, local_logo, None, json.dumps({"drivingrange": decode_html(acf.get("drivingrange")), "treningsgreen": decode_html(acf.get("treningsgreen")), "proshop": decode_html(acf.get("proshop")), "kafe": decode_html(acf.get("kafe")), "bilutleie": decode_html(acf.get("bilutleie")), "kolleutleie": decode_html(acf.get("kolleutleie")), "pro": decode_html(acf.get("pro")), "simulator": decode_html(acf.get("golfsimulator")), "antall_hull": decode_html(acf.get("antall_hull"))}),
acf.get('dato_for_oppdatert_status'), # $16
json.dumps(local_gallery), decode_html(acf.get('banetype')),
parse_int(acf.get('klubbnummer_norges_golfforbund')), parse_int(acf.get('klubbnummer_golfbox')), gb_booking_url, extract_url(acf.get('facebook_url')), extract_url(acf.get('instagram_url')), extract_url(acf.get('baneguide')), extract_url(acf.get('flyfoto')), extract_url(acf.get('golfbox')), decode_html(acf.get('fotnote')), json.dumps(acf.get('sosiale_lenker') or []), decode_html(acf.get('webkamera')), extract_url(acf.get('varmelding_yr')), decode_html(acf.get('arkitekt')), decode_html(acf.get('navn_standard_medlemskap')), parse_int(acf.get('standard_medlemskap')), decode_html(acf.get('standard_medlemskap_kommentarer')), decode_html(acf.get('navn_rimeligste_alternativ')), parse_int(acf.get('rimeligste_alternativ')), decode_html(acf.get('rimeligste_alternativ_kommentarer')), extract_url(acf.get('medlemskap_url')))
fac_id = (await conn.fetchrow("SELECT id FROM facilities WHERE slug = $1", slug))['id']
# Baner og Hull
fac_main_len = 0
for suffix in ['', '_bane_to']:
c_name = acf.get('navn_pa_hovedbane' if suffix == '' else 'navn_pa_sekundar_bane') or ('Hovedbanen' if suffix == '' else 'Bane 2')
status = acf.get('banestatus' if suffix == '' else 'banestatus_sekundar_bane')
if suffix == '_bane_to' and (status == 'finnes_ingen_bane_to' or not parse_int(acf.get('hull_1_par_bane_to'))): continue
course_id = await conn.fetchval('INSERT INTO courses (facility_id, name, status, par, is_main_course, tee_boxes, architect) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id', fac_id, c_name, status, parse_int(acf.get('totalt_par' if suffix == '' else 'totalt_par_bane_to')), (suffix == ''), json.dumps({"herrer": acf.get(f"utslag_herrer{suffix}"), "damer": acf.get(f"utslag_damer{suffix}")}), decode_html(acf.get('arkitekt')))
curr_len = 0
for h_num in range(1, 19):
p = parse_int(acf.get(f'hull_{h_num}_par{suffix}'))
if p:
idx = parse_int(acf.get(f'hull_{h_num}_index{suffix}'))
lens = {k: parse_int(acf.get(f'{k}_hull_{h_num}{suffix}')) for k in ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest']}
curr_len += (lens['lengst'] or 0)
await conn.execute('INSERT INTO holes (course_id, hole_number, par, hcp_index, lengths) VALUES ($1, $2, $3, $4, $5::jsonb)', course_id, h_num, p, idx, json.dumps(lens))
await conn.execute("UPDATE courses SET length_meters = $1 WHERE id = $2", curr_len, course_id)
if suffix == '': fac_main_len = curr_len
await conn.execute("UPDATE facilities SET length_meters = $1 WHERE id = $2", fac_main_len, fac_id)
page += 1
await conn.close()
print("✅ IMPORT FERDIG!")
if __name__ == "__main__":
asyncio.run(run_master_import())

View file

@ -0,0 +1,478 @@
"""
TEE OFF BACKEND API v3.7.0 - KOBLET PÅ FULL ADMIN REDIGERING
---------------------------------------------------------------------------
REGEL 1: Bruk str (ikke string) for type-hinting.
REGEL 2: Inkluder alle subqueries for banestatus og hull-data.
REGEL 3: Robust JSON-parsing (format_row) for å hindre Frontend-krasj.
REGEL 4: JWT-sesjoner lagres i HTTP-only cookies.
LOV: Aldri trunker eller slett logikk for "effektivitet".
---------------------------------------------------------------------------
"""
from fastapi import FastAPI, HTTPException, Response, Cookie, Depends, Request, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import asyncpg
import json
import pyotp
import os
from datetime import datetime, date, timedelta
from jose import jwt, JWTError
from passlib.context import CryptContext
from dotenv import load_dotenv
# NYE IMPORTER FOR ADMIN PANELET OG BAKGRUNNSJOBBER
from pydantic import BaseModel
from typing import Optional, List, Any
import subprocess
load_dotenv()
# --- 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")
ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
# --- PYDANTIC MODELLER ---
class CourseStatusUpdate(BaseModel):
id: int
status: str
class ScrapeSettingsUpdate(BaseModel):
scrape_method: Optional[str] = None
scrape_status_url: Optional[str] = None
scrape_status_selector: Optional[str] = None
ai_instruction: Optional[str] = None
courses: Optional[List[CourseStatusUpdate]] = []
# NY MODELL FOR Å TA IMOT IDER FOR SCRAPING
class ScrapeRunRequest(BaseModel):
facility_ids: List[int]
class MembershipDraftApproval(BaseModel):
facility_id: int
navn_standard_medlemskap: Optional[str] = None
standard_medlemskap: Optional[int] = None
standard_medlemskap_kommentarer: Optional[str] = None
navn_rimeligste_alternativ: Optional[str] = None
rimeligste_alternativ: Optional[int] = None
class BulkApprovalRequest(BaseModel):
approvals: List[MembershipDraftApproval]
# --- FUNKSJONER ---
def format_row(row):
"""
Vasker data fra databasen:
1. Konverterer datoer til ISO-format.
2. Tvinger tekst-JSON (stringified JSON) over til ekte Python objekter/lister.
3. Sikrer at lister og objekter aldri er None for å hindre Frontend-krasj.
"""
if row is None:
return None
d = dict(row)
for key in ['status_updated_at', 'created_at', 'slope_valid_until', 'membership_updated_at']:
if isinstance(d.get(key), (date, datetime)):
d[key] = d[key].isoformat()
json_list_fields = [
'course_statuses', 'courses', 'gallery', 'greenfee',
'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs'
]
json_dict_fields = [
'amenities', 'vtg', 'nsg_data', 'golfamore_data', 'membership_draft'
]
for field in json_list_fields:
if field in d:
val = d[field]
if val is None:
d[field] = []
elif isinstance(val, str):
try:
d[field] = json.loads(val)
except:
d[field] = []
elif not isinstance(val, list):
d[field] = []
for field in json_dict_fields:
if field in d:
val = d[field]
if val is None:
d[field] = {}
elif isinstance(val, str):
try:
d[field] = json.loads(val)
except:
d[field] = {}
elif not isinstance(val, dict):
d[field] = {}
return d
# --- BAKGRUNNSARBEIDER: FUNKSJON SOM KJØRER SKRAPEREN I BAKGRUNNEN ---
def run_scrape_worker(facility_ids: List[int]):
"""
Kjører selve skraping-scriptet i bakgrunnen.
Slik kan frontenden få et umiddelbart svar, mens skraperen jobber.
"""
print(f"🔄 STARTER BAKGRUNNSSKRAPING FOR FØLGENDE IDER: {facility_ids}")
try:
ids_arg = ",".join(map(str, facility_ids))
# NYTT: Bruker "python -u" for LIVE logging, og fjerner "> /dev/null 2>&1"
command = f"python -u scrape_status.py --ids {ids_arg}"
subprocess.run(command, shell=True, check=True)
print(f"✅ BAKGRUNNSSKRAPING FULLFØRT FOR IDER: {facility_ids}")
except subprocess.CalledProcessError as e:
print(f"❌ FEIL UNDER BAKGRUNNSSKRAPING: {e}")
except Exception as e:
print(f"🔥 UFORUTSETT FEIL UNDER BAKGRUNNSSKRAPING: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Opprett database-pool ved start
try:
print(f"📡 Forsøker å koble til database på: {DB_URL}")
app.state.pool = await asyncpg.create_pool(
DB_URL,
min_size=5,
max_size=20,
command_timeout=60
)
print("✅ Database tilkoblet og pool opprettet")
except Exception as e:
print(f"❌ Databasefeil under oppstart: {e}")
raise e
yield
# Lukk pool ved avslutning
await app.state.pool.close()
app = FastAPI(title="TeeOff API v3.7.0", lifespan=lifespan)
# CORS - Tillater både lokal utvikling og produksjonsdomene
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://nye.teeoff.no",
"http://nye.teeoff.no",
"http://localhost:3000"
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- AUTH ENDPOINTS ---
@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",
data.get('username')
)
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)
except Exception as e:
print(f" - 🔥 FEIL VED LESING AV HASH: {e}")
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")
async def verify_2fa(data: dict, response: Response):
"""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")
except JWTError:
raise HTTPException(status_code=401, detail="Sesjonen har utløpt eller er ugyldig")
async with app.state.pool.acquire() as conn:
admin = await conn.fetchrow("SELECT otp_secret FROM admins WHERE username = $1", username)
totp = pyotp.TOTP(admin['otp_secret'])
if not totp.verify(data.get('code')):
print(f" - ❌ Feil 2FA-kode oppgitt for {username}")
raise HTTPException(status_code=401, detail="Feil 2FA-kode")
final_token = jwt.encode(
{"sub": username, "exp": datetime.utcnow() + timedelta(hours=12)},
SECRET_KEY, algorithm=ALGORITHM
)
# Sett som HTTP-only cookie
response.set_cookie(
key="admin_session",
value=final_token,
httponly=True,
samesite="lax",
secure=False # Sett til True i produksjon (HTTPS)
)
return {"status": "success"}
# --- DATA ENDPOINTS ---
@app.get("/api/facilities")
async def get_facilities():
"""Henter alle golfanlegg med aggregert banestatus for forsiden."""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT f.*, (
SELECT jsonb_agg(cs) FROM (
SELECT id, name, status FROM courses
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to'
ORDER BY is_main_course DESC, id ASC
) cs
) as course_statuses
FROM facilities f
ORDER BY f.name ASC
""")
return [format_row(row) for row in rows]
@app.get("/api/facilities/{slug}")
async def get_facility(slug: str):
"""Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull."""
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT f.*, (
SELECT jsonb_agg(c_data) FROM (
SELECT c.*, (
SELECT jsonb_agg(h_data ORDER BY h_data.hole_number ASC)
FROM (SELECT * FROM holes WHERE course_id = c.id) h_data
) as holes
FROM courses c
WHERE c.facility_id = f.id
AND (c.is_main_course = true OR (c.status NOT IN ('finnes_ingen_bane_to', 'ukjent')))
ORDER BY c.is_main_course DESC, c.id ASC
) c_data
) as courses
FROM facilities f WHERE f.slug = $1
""", slug)
if not row:
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
return format_row(row)
# --- ADMIN ENDPOINTS ---
@app.patch("/api/admin/facilities/{facility_id}/scrape-settings")
async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate):
"""Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL)."""
async with app.state.pool.acquire() as conn:
try:
# Sjekk først at anlegget eksisterer
facility = await conn.fetchrow("SELECT id FROM facilities WHERE id = $1", facility_id)
if not facility:
raise HTTPException(status_code=404, detail="Anlegget finnes ikke.")
# Oppdater verdiene i databasen inkludert AI instruks
await conn.execute("""
UPDATE facilities
SET scrape_method = $1,
scrape_status_url = $2,
scrape_status_selector = $3,
ai_instruction = $4
WHERE id = $5
""",
settings.scrape_method,
settings.scrape_status_url,
settings.scrape_status_selector,
settings.ai_instruction,
facility_id)
# Hvis metoden er manuell, tvinger vi gjennom de nye banestatusene direkte
if settings.scrape_method == 'manual' and settings.courses:
for c in settings.courses:
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", c.status, c.id)
return {"status": "success", "message": f"Skrapeinnstillinger for anlegg ID {facility_id} ble oppdatert."}
except Exception as e:
if isinstance(e, HTTPException):
raise e
raise HTTPException(status_code=500, detail=str(e))
# --- NYTT ADMIN ENDPOINT FOR FULL OPPDATERING (JSON-EDITOR) ---
@app.put("/api/admin/facilities/{facility_id}/full")
async def update_facility_full(facility_id: int, request: Request):
"""Dynamisk endpoint som oppdaterer anlegg, baner og hull (den fulle editoren)."""
data = await request.json()
# Felter som er trygge å oppdatere manuelt på anlegget
allowed_fields = [
'name', 'description', 'established_year', 'season', 'banetype', 'architect', 'length_meters',
'address', 'zipcode', 'city', 'county', 'lat', 'lng',
'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url',
'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee',
'nsg_url', 'nsg_data', 'golfamore', 'golfamore_data',
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url',
'vtg_presentasjon', 'vtg_lenke', 'vtg_pris', 'vtg_kursdatoer',
'guest_requirements', 'scrape_method', 'scrape_status_url',
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at'
]
update_data = {k: v for k, v in data.items() if k in allowed_fields}
async with app.state.pool.acquire() as conn:
async with conn.transaction(): # Sikrer at alt lagres samlet
# 1. OPPDATER ANLEGG (FACILITIES)
if update_data:
set_clauses = []
values = []
for i, (k, v) in enumerate(update_data.items(), 1):
if isinstance(v, (dict, list)):
set_clauses.append(f"{k} = ${i}::jsonb")
values.append(json.dumps(v))
else:
set_clauses.append(f"{k} = ${i}")
values.append(v)
values.append(facility_id)
query = f"UPDATE facilities SET {', '.join(set_clauses)} WHERE id = ${len(values)}"
await conn.execute(query, *values)
# 2. OPPDATER BANER (COURSES) OG HULL (HOLES)
courses = data.get('courses', [])
for course in courses:
course_id = course.get('id')
if course_id:
# Rens datoformat for PostgreSQL (håndterer Next.js date input)
valid_until = course.get('slope_valid_until')
if valid_until == "" or valid_until is None:
valid_until = None
await conn.execute("""
UPDATE courses
SET name=$1, par=$2, length_meters=$3, architect=$4,
status=$5, is_main_course=$6, tee_boxes=$7::jsonb,
slope_valid_until=$8
WHERE id=$9 AND facility_id=$10
""",
course.get('name'), course.get('par'), course.get('length_meters'),
course.get('architect'), course.get('status'), course.get('is_main_course'),
json.dumps(course.get('tee_boxes', {})), valid_until, course_id, facility_id)
# 3. OPPDATER HULL PÅ BANEN (HOLES)
holes = course.get('holes', [])
for hole in holes:
hole_id = hole.get('id')
if hole_id:
await conn.execute("""
UPDATE holes
SET par=$1, hcp_index=$2, lengths=$3::jsonb
WHERE id=$4 AND course_id=$5
""",
hole.get('par'), hole.get('hcp_index'),
json.dumps(hole.get('lengths', {})), hole_id, course_id)
return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."}
# --- NYTT ADMIN ENDPOINT: KJØRER SKRAPEREN FOR VALGTE IDER ---
@app.post("/api/admin/run-scraper")
async def run_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
"""
Tar imot IDer for skraping, og starter en bakgrunnsjobb.
Gir et umiddelbart svar tilbake til frontenden slik at den slipper å vente.
"""
if not request.facility_ids:
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
print(f"📡 API mottok forespørsel om å kjøre skraping for IDer: {request.facility_ids}")
background_tasks.add_task(run_scrape_worker, request.facility_ids)
return {"status": "queued", "message": f"Skraping for {len(request.facility_ids)} anlegg ble lagt i kø."}
@app.get("/api/health")
async def health_check():
"""Enkel sjekk for å se at API og DB lever."""
try:
async with app.state.pool.acquire() as conn:
await conn.execute("SELECT 1")
return {"status": "healthy", "database": "connected"}
except Exception as e:
return {"status": "unhealthy", "error": str(e)}
# --- MEDLEMSKAP "VASKERI" ENDEPUNKTER ---
@app.get("/api/admin/membership/drafts")
async def get_membership_drafts():
"""Henter alle anlegg som har et ventende forslag fra AI-skraperen."""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT id, name, slug, medlemskap_url,
navn_standard_medlemskap, standard_medlemskap,
navn_rimeligste_alternativ, rimeligste_alternativ,
membership_draft
FROM facilities
WHERE membership_draft IS NOT NULL
AND membership_draft::text != '{}'
ORDER BY name ASC
""")
return [format_row(row) for row in rows]
@app.post("/api/admin/membership/approve-bulk")
async def approve_membership_bulk(request: BulkApprovalRequest):
"""Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet."""
async with app.state.pool.acquire() as conn:
async with conn.transaction():
for approval in request.approvals:
await conn.execute("""
UPDATE facilities
SET navn_standard_medlemskap = $1,
standard_medlemskap = $2,
standard_medlemskap_kommentarer = $3,
navn_rimeligste_alternativ = $4,
rimeligste_alternativ = $5,
membership_updated_at = NOW(),
membership_draft = NULL
WHERE id = $6
""",
approval.navn_standard_medlemskap,
approval.standard_medlemskap,
approval.standard_medlemskap_kommentarer,
approval.navn_rimeligste_alternativ,
approval.rimeligste_alternativ,
approval.facility_id)
return {"status": "success", "message": f"{len(request.approvals)} anlegg ble oppdatert med nye priser!"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View file

@ -0,0 +1,124 @@
import asyncio
import asyncpg
import json
import re
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
# Data hentet direkte fra bildet du sendte
GOLFAMORE_DATA = {
"borre": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 14, 19, 20, 21",
"nesfjellet": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
"vradal": "Kortet er gyldig alle dager, ikke uke 28, 29, 30, 31",
"alta": "Kortet er gyldig alle dager",
"elverum": "Kortet er gyldig hverdager (ikke helligdager)",
"gronmo": "Kortet er gyldig alle dager",
"notteroy": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
"roros": "Kortet er gyldig alle dager",
"stiklestad": "Kortet er gyldig alle dager",
"arendalomegn": "Kortet er gyldig alle dager, ikke uke 27, 28, 29, 30",
"northcape": "Kortet er gyldig alle dager",
"trysil": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 28, 29, 30, 31",
"mork": "Kortet er gyldig hverdager (ikke helligdager)",
"norsjo": "Kortet er gyldig alle dager",
"ringerike": "Kortet er gyldig alle dager",
"stord": "Kortet er gyldig alle dager",
"sunnmore": "Kortet er gyldig alle dager",
"bodogolfparksalten": "Kortet er gyldig alle dager",
"drammen": "Kortet er gyldig alle dager",
"gjoviktoten": "Kortet er gyldig alle dager",
"grenlandomegn": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
"nes09": "Kortet er gyldig alle dager, ikke uke 15, 16, 17, 18",
"romerike": "Kortet er gyldig alle dager",
"bamble": "Kortet er gyldig alle dager",
"bleik": "Kortet er gyldig alle dager",
"krokhol": "Kortet er gyldig alle dager",
"skjeberg": "Kortet er gyldig hverdager (ikke helligdager)",
"utsikten": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
"eiker": "Kortet er gyldig alle dager",
"hafjell": "Kortet er gyldig alle dager",
"mandal": "Kortet er gyldig alle dager, ikke uke 27, 28, 29, 30",
"mjosen": "Kortet er gyldig alle dager",
"randsfjorden": "Kortet er gyldig alle dager",
"ski": "Kortet er gyldig alle dager",
"bjornefjorden": "Kortet er gyldig alle dager",
"sande": "Kortet er gyldig alle dager",
"haugesund": "Kortet er gyldig alle dager",
"midttroms": "Kortet er gyldig alle dager",
"skei": "Kortet er gyldig hverdager (ikke helligdager)",
"sorknes": "Kortet er gyldig alle dager",
"gjerdrum": "Kortet er gyldig alle dager",
"herdla": "Kortet er gyldig alle dager",
"hovden": "Kortet er gyldig alle dager",
"oppdal": "Kortet er gyldig alle dager",
"gjersjoen": "Kortet er gyldig alle dager",
"ogna": "Kortet er gyldig alle dager",
"tonsberg": "Kortet er gyldig alle dager",
"ullensaker": "Kortet er gyldig alle dager",
"hof": "Kortet er gyldig hverdager (ikke helligdager)",
"klabu": "Kortet er gyldig alle dager",
"hemsedal": "Kortet er gyldig alle dager",
"narvik": "Kortet er gyldig alle dager",
"norefjell": "Kortet er gyldig hverdager (ikke helligdager)",
"austratt": "Kortet er gyldig alle dager",
"hammerfest": "Kortet er gyldig alle dager",
"helgeland": "Kortet er gyldig alle dager",
"jaren": "Kortet er gyldig alle dager",
"namdal": "Kortet er gyldig alle dager",
"namsos": "Kortet er gyldig alle dager",
"nordfjord": "Kortet er gyldig alle dager",
"polarsirkelen": "Kortet er gyldig alle dager",
"sandnesbarheim": "Kortet er gyldig alle dager",
"steinkjer": "Kortet er gyldig alle dager",
"varanger": "Kortet er gyldig alle dager"
}
def clean(text):
if not text: return ""
# Fjerner alt som ikke er bokstaver/tall for matching
s = text.lower().replace("golfklubb", "").replace("gk", "").replace(" og ", "").replace("&", "").strip()
return re.sub(r'[^a-z0-9]', '', s)
async def update_golfamore():
print("\n🚀 OPPDATERER GOLFAMORE FRA BILDE-DATA...")
conn = await asyncpg.connect(DB_URL)
facilities = await conn.fetch("SELECT id, name FROM facilities")
# Lag et vasket map av bilde-dataen
image_data_clean = {clean(name): val for name, val in GOLFAMORE_DATA.items()}
matches = 0
for fac in facilities:
fac_id = fac['id']
fac_name = fac['name']
fac_clean = clean(fac_name)
validity = None
# Prøv eksakt match først
if fac_clean in image_data_clean:
validity = image_data_clean[fac_clean]
else:
# Prøv delvis match (f.eks "Arendal" i "Arendal & Omegn")
for key, val in image_data_clean.items():
if len(fac_clean) > 4 and (fac_clean in key or key in fac_clean):
validity = val
break
if validity:
print(f"✅ Match funnet: {fac_name}")
ga_data = {"validity": validity}
await conn.execute("""
UPDATE facilities
SET golfamore = true, golfamore_data = $1
WHERE id = $2
""", json.dumps(ga_data), fac_id)
matches += 1
else:
# Hvis den ikke er i listen fra bildet, sett til false
await conn.execute("UPDATE facilities SET golfamore = false, golfamore_data = '{}' WHERE id = $1", fac_id)
await conn.close()
print(f"\n🎉 Ferdig! {matches} baner ble oppdatert med Golfamore-info.")
if __name__ == "__main__":
asyncio.run(update_golfamore())

View file

@ -0,0 +1,163 @@
"""
TEE OFF - MEDLEMSKAPSSKRAPER MED GEMINI AI
---------------------------------------------------------------------------
Går til oppgitte medlemskaps-URLer, henter ut tekst, og bruker Gemini til å
finne 'Standard' og 'Rimeligste' medlemskap basert på TeeOffs definisjoner.
Lagrer resultatet som et utkast i databasen (membership_draft).
---------------------------------------------------------------------------
"""
import asyncio
import asyncpg
import os
import json
import argparse
from bs4 import BeautifulSoup
from playwright.async_api import async_playwright
import google.generativeai as genai
from dotenv import load_dotenv
# Last inn miljøvariabler
load_dotenv()
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
if not GEMINI_API_KEY:
raise ValueError("🚨 GEMINI_API_KEY mangler i .env filen!")
# Konfigurer Gemini
genai.configure(api_key=GEMINI_API_KEY)
model = genai.GenerativeModel('gemini-2.5-flash') # Eller gemini-1.5-pro avhengig av hva du har tilgang til
async def fetch_page_text(url: str) -> str:
"""Bruker Playwright for å hente all synlig tekst fra nettsiden."""
print(f" 🌐 Laster inn: {url}")
try:
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# Setter timeout til 15 sekunder
await page.goto(url, wait_until="domcontentloaded", timeout=15000)
# Hent hele HTML-innholdet
html_content = await page.content()
await browser.close()
# Bruk BeautifulSoup til å renske ut bare den synlige teksten
soup = BeautifulSoup(html_content, 'html.parser')
# Fjern script og style tags
for script in soup(["script", "style", "nav", "footer", "header"]):
script.extract()
text = soup.get_text(separator=' ', strip=True)
# Begrens teksten slik at vi ikke sprenger token-grensen til AI (f.eks max 15000 tegn)
return text[:15000]
except Exception as e:
print(f" ❌ Feil ved lasting av side: {e}")
return ""
def analyze_with_gemini(text: str, club_name: str) -> dict:
"""Sender teksten til Gemini for å trekke ut priser."""
print(f" 🧠 Sender {len(text)} tegn til Gemini for analyse...")
prompt = f"""
Du er en ekspert på norske golfklubber og medlemskap.
Din oppgave er å lese teksten hentet fra nettsiden til "{club_name}" og trekke ut to spesifikke medlemskapspriser.
DEFINISJONER DU MÅ FØLGE STRENGT:
1. "Standard medlemskap": Hva vil det koste for en gjennomsnittsgolfer (voksen over 25/30 år, ikke student/senior) å spille SÅ RYE VEDKOMMENDE ØNSKER (Fritt spill) på denne banen i år?
2. "Rimeligste alternativ": Det absolutt billigste medlemskapet som gir medlemskap i klubben (golfkortet), forutsatt at man aksepterer å måtte betale greenfee for hver runde man spiller. (Ofte kalt Greenfeemedlem, Postkassemedlem, Fjernmedlem el.l.)
TEKST FRA NETTSIDEN:
{text}
OPPGAVE:
Returner KUN et gyldig JSON-objekt med følgende struktur (og ingenting annet, ingen markdown):
{{
"foreslatt_standard_navn": "Navnet på medlemskapet (eks: Hovedmedlem Voksen)",
"foreslatt_standard_pris": 1234,
"foreslatt_standard_kommentar": "Kort evt kommentar (eks: Inkluderer ikke 500kr i dugnadsavgift)",
"foreslatt_rimeligste_navn": "Navnet (eks: Greenfeemedlemskap)",
"foreslatt_rimeligste_pris": 500,
"ai_begrunnelse": "Kort forklaring på hvorfor du valgte disse to, f.eks: 'Valgte Hovedmedlem for fritt spill og Greenfeemedlem fordi...'."
}}
Merk: Hvis prisene mangler, sett pris til null og skriv "Fant ikke" i navnet. Prisen SKAL være et tall (integer), ikke en tekststreng (bruk 6500, ikke "6 500").
"""
try:
response = model.generate_content(prompt)
raw_response = response.text.strip()
# Rensker vekk eventuell markdown-formatering som ```json
if raw_response.startswith("```json"):
raw_response = raw_response[7:]
if raw_response.endswith("```"):
raw_response = raw_response[:-3]
return json.loads(raw_response.strip())
except Exception as e:
print(f" ❌ AI-analyse feilet: {e}")
return None
async def run_scraper(facility_ids=None):
"""Hovedfunksjon som henter fra DB, skraper, og lagrer utkast."""
print("🚀 Starter Medlemskaps-skraperen...")
conn = await asyncpg.connect(DB_URL)
try:
# Hent anlegg som har en url for medlemskap
query = "SELECT id, name, medlemskap_url FROM facilities WHERE medlemskap_url IS NOT NULL AND medlemskap_url != ''"
if facility_ids:
query += f" AND id IN ({','.join(map(str, facility_ids))})"
facilities = await conn.fetch(query)
print(f"📋 Fant {len(facilities)} anlegg å skrape.")
for facility in facilities:
fac_id = facility['id']
name = facility['name']
url = facility['medlemskap_url']
print(f"\n▶ Behandler: {name} (ID: {fac_id})")
# 1. Hent tekst
page_text = await fetch_page_text(url)
if not page_text or len(page_text) < 50:
print(" ⚠️ Fant for lite tekst på siden, hopper over.")
continue
# 2. Analyser med Gemini
draft_data = analyze_with_gemini(page_text, name)
if not draft_data:
continue
# 3. Lagre i databasen som utkast
print(f" ✅ AI foreslår: Standard: {draft_data.get('foreslatt_standard_pris')} | Rimeligste: {draft_data.get('foreslatt_rimeligste_pris')}")
await conn.execute("""
UPDATE facilities
SET membership_draft = $1::jsonb
WHERE id = $2
""", json.dumps(draft_data), fac_id)
print(" 💾 Utkast lagret i databasen!")
finally:
await conn.close()
print("\n🏁 Skraping fullført.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Skrap medlemskapspriser via AI.")
parser.add_argument("--ids", type=str, help="Kommaseparert liste med facility IDs (eks: 1,5,12)")
args = parser.parse_args()
ids_to_scrape = None
if args.ids:
ids_to_scrape = [int(x.strip()) for x in args.ids.split(",")]
asyncio.run(run_scraper(ids_to_scrape))

View file

@ -0,0 +1,96 @@
import asyncio
import asyncpg
import httpx
from bs4 import BeautifulSoup
import re
import json
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
def clean_name(text):
if not text: return ""
s = text.lower().replace("golfklubb", "").replace("gk", "").replace("par3golf", "").replace(" & ", "").strip()
return re.sub(r'[^a-z]', '', s)
def clean_nsg_content(text):
"""Fjerner doble linjeskift og kutter teksten før websidemenyen starter"""
if not text: return ""
# Fjern alt som ligner på bunn-menyen til NSG
garbage_phrases = [
"Klubbens hjemmeside", "Resultatlister i Golfbox", "Livescoring",
"Scoreinntasting", "Lagserie", "Turneringer", "Innmelding"
]
for phrase in garbage_phrases:
text = text.split(phrase)[0]
# Rydd opp i linjeskift og doble mellomrom
text = text.replace('\r', '').replace('\n', ' ')
text = re.sub(r'\s+', ' ', text).strip()
return text
async def get_nsg_links(client):
links = []
urls = ["https://seniorgolf.no/lojalitetskort-sitemap.xml", "https://seniorgolf.no/fordelskortet/"]
for url in urls:
try:
resp = await client.get(url)
if resp.status_code == 200:
if ".xml" in url:
found = re.findall(r'<loc>(https://seniorgolf.no/lojalitetskort/.*?/)</loc>', resp.text)
if found: return list(set(found))
else:
soup = BeautifulSoup(resp.text, 'html.parser')
links.extend([l['href'] for l in soup.select('a[href*="/lojalitetskort/"]')])
except: continue
return list(set(links))
async def scrape_nsg():
print("🚀 Starter NSG VASKEMASKIN v3.8...")
conn = await asyncpg.connect(DB_URL)
facilities = await conn.fetch("SELECT id, name FROM facilities")
async with httpx.AsyncClient(timeout=20.0, headers={'User-Agent': 'Mozilla/5.0'}) as client:
all_nsg_links = await get_nsg_links(client)
link_map = {clean_name(l.split('/')[-2].replace('-', ' ')): l for l in all_nsg_links}
matches_found = 0
for fac in facilities:
fac_name_clean = clean_name(fac['name'])
match_url = link_map.get(fac_name_clean)
if not match_url:
for slug, url in link_map.items():
if fac_name_clean in slug or slug in fac_name_clean:
match_url = url
break
if match_url:
try:
f_resp = await client.get(match_url)
f_soup = BeautifulSoup(f_resp.text, 'html.parser')
# Finn hovedinnholdet i stedet for hele siden for å unngå menyer
main_content = f_soup.find('div', {'class': 'entry-content'}) or f_soup
text = main_content.get_text()
st = re.search(r"Starttider:?\s*(.*?)(?=Greenfee|Booking|Adresse|Kontakt|$)", text, re.S | re.I)
gf = re.search(r"Greenfee:?\s*(.*?)(?=Booking|Adresse|Kontakt|$)", text, re.S | re.I)
bk = re.search(r"Booking:?\s*(.*?)(?=Adresse|Kontakt|$)", text, re.S | re.I)
nsg_data = {
"url": match_url,
"starttider": clean_nsg_content(st.group(1)) if st else "Se nettside",
"greenfee": clean_nsg_content(gf.group(1)) if gf else "Se nettside",
"booking": clean_nsg_content(bk.group(1)) if bk else "Se nettside"
}
await conn.execute("UPDATE facilities SET nsg_data = $1 WHERE id = $2", json.dumps(nsg_data), fac['id'])
print(f"✅ Vasket & Lagret: {fac['name']}")
matches_found += 1
except: pass
await conn.close()
print(f"\n🎉 Vask ferdig! {matches_found} baner er nå 100% klare.")
if __name__ == "__main__":
asyncio.run(scrape_nsg())

View file

@ -0,0 +1,332 @@
import asyncio
import os
import asyncpg
import smtplib
import re
import argparse
from datetime import datetime
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from playwright.async_api import async_playwright
try:
from playwright_stealth import stealth_async as apply_stealth
except ImportError:
from playwright_stealth import stealth as apply_stealth
from google import genai
from dotenv import load_dotenv
load_dotenv()
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
# ==========================================
# KONFIGURERER GEMINI AI (NY SDK)
# ==========================================
client = genai.Client()
async def ask_llm_status(text, course_name, is_single_course, ai_instruction=None):
if is_single_course:
bane_instruks = "Finn den generelle banestatusen for dette golfanlegget. Se bort fra spesifikke banenavn, da anlegget kun har én bane."
else:
bane_instruks = f'Finn banestatusen SPESIFIKT for banen som heter/omtales som: "{course_name}".'
ekstra_tekst = f"\n!!! VIKTIG EKSTRA-INSTRUKS FRA ADMIN (DENNE OVERSTYRER ALLE ANDRE REGLER) !!!:\n{ai_instruction}\n" if ai_instruction else ""
prompt = f"""
Du er en ekspert på å lese norske golfklubbers nettsider for å finne banestatus.
{bane_instruks}
{ekstra_tekst}
Svar KUN med nøyaktig ETT av disse ordene:
- aapen (hvis banen er åpen/sommergreener)
- stengt (hvis banen er lukket/stengt/frost/snø)
- aapen_med_vintergreener (hvis det spilles på vintergreener)
- aapner_snart (hvis den åpner om kort tid)
- stenger_snart (hvis den stenger for sesongen om kort tid)
- under_utvikling (hvis den er under utvikling)
- nedlagt (hvis den er nedlagt)
- ukjent (hvis du ikke finner noe info om banen i teksten)
Tekst fra nettsiden:
{text[:15000]}
"""
print("\n" + "="*60)
print(f"🤖 SENDER PROMPT TIL GEMINI FOR: '{course_name}'")
print(f"👉 STANDARD-INSTRUKS: {bane_instruks}")
if ai_instruction:
print(f"👉 ADMIN-HVISKER: {ai_instruction}")
clean_text_sample = " ".join(text.split())[:250]
print(f"👉 TEKST FRA NETTSIDEN (utdrag): '{clean_text_sample}...'")
print("="*60 + "\n")
try:
response = await client.aio.models.generate_content(
model='gemini-2.5-flash',
contents=prompt
)
svar = response.text.strip().lower()
print(f" 🧠 GEMINI RÅ-SVAR: '{svar}'")
# --- NYTT: SORTERT SIKKERHETSFILTER ---
gyldige_svar = [
"aapen_med_vintergreener",
"aapner_snart",
"stenger_snart",
"under_utvikling",
"nedlagt",
"stengt",
"aapen",
"ukjent"
]
for gyldig in gyldige_svar:
if gyldig in svar:
return gyldig
return "ukjent"
except Exception as e:
print(f"❌ Gemini Feil: {e}")
return "ukjent"
# ==========================================
# EKSISTERENDE LOGIKK FOR MANUELL SCRAPING
# ==========================================
def clean_text(text):
return re.sub(r'[^a-zA-Z0-9æøåÆØÅ]', '', text).lower()
def interpret_status(text, keyword=None):
t_raw = text.lower()
if keyword:
k_clean = clean_text(keyword)
if k_clean not in clean_text(t_raw):
return "NOT_FOUND"
parts = re.split(re.escape(keyword), t_raw, flags=re.IGNORECASE)
if len(parts) > 1:
t_raw = parts[1][:150]
else:
t_raw = t_raw[-200:]
if any(word in t_raw for word in ["stengt", "lukket", "frost", "snø", "is", "closed", "stenger"]):
return "stengt"
if any(word in t_raw for word in ["vintergreen", "vintergrønn", "vinter"]):
return "aapen_med_vintergreener"
if any(word in t_raw for word in ["snart", "åpner kl"]):
return "aapner_snart"
if any(word in t_raw for word in ["åpen", "åpent", "aapen", "open"]):
return "aapen"
return "ukjent"
def send_report(changes, warnings, successes):
if not changes and not warnings and not successes: return
subject = f"TeeOff Banestatus Rapport - {datetime.now().strftime('%d.%m.%Y')}"
body = "BANESTATUS RAPPORT\n" + "="*30 + "\n\n"
if changes: body += "✅ OPPDATERINGER:\n" + "\n".join(changes) + "\n\n"
if warnings: body += "⚠️ MERKNADER / ADVARSLER:\n" + "\n".join(warnings) + "\n\n"
if successes: body += "🆗 VELLYKKEDE SJEKKER (INGEN ENDRING):\n" + "\n".join(successes) + "\n"
msg = MIMEMultipart()
msg['From'] = os.getenv("SMTP_USER")
msg['To'] = os.getenv("EMAIL_TO")
msg['Subject'] = subject
msg.attach(MIMEText(body, 'plain'))
try:
with smtplib.SMTP_SSL(os.getenv("SMTP_SERVER"), int(os.getenv("SMTP_PORT"))) as server:
server.login(os.getenv("SMTP_USER"), os.getenv("SMTP_PASS"))
server.send_message(msg)
print("✅ Rapport sendt på e-post.")
except Exception as e:
print(f"❌ E-post feil: {e}")
# ==========================================
# HOVEDMOTOR
# ==========================================
async def run_daily_scraping(facility_ids=None):
print(f"🚀 Starter sjekk {datetime.now().strftime('%H:%M:%S')}...")
conn = await asyncpg.connect(DB_URL)
if facility_ids:
print(f"📌 Kjører skraping KUN for anlegg-ID(er): {facility_ids}")
facilities = await conn.fetch(
"SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method, ai_instruction FROM facilities WHERE scrape_status_url IS NOT NULL AND id = ANY($1::int[])",
facility_ids
)
else:
print("🌍 Kjører skraping for ALLE anlegg med scrape_status_url...")
facilities = await conn.fetch(
"SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method, ai_instruction FROM facilities WHERE scrape_status_url IS NOT NULL"
)
if not facilities:
print("⚠️ Fant ingen anlegg å skrape.")
await conn.close()
return
changes, warnings, successes = [], [], []
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
for f in facilities:
method = f.get('scrape_method') or 'css_selector'
if method == 'manual':
successes.append(f"⏸️ {f['name']}: Hoppet over (Manuell overstyring)")
print(f" ⏸️ Hopper over skraping av {f['name']} (Satt til Manuell)")
continue
page = await context.new_page()
try: await apply_stealth(page)
except: pass
try:
print(f"🔍 Besøker {f['name']} (Metode: {method})...")
await page.goto(f['scrape_status_url'], timeout=60000, wait_until="domcontentloaded")
await page.wait_for_timeout(3000)
full_text = ""
if method == 'css_selector':
element = page.locator(f['scrape_status_selector']).first
if await element.count() == 0:
warnings.append(f"❌ {f['name']}: Fant ikke CSS-elementet '{f['scrape_status_selector']}'")
continue
full_text = await element.inner_text()
elif method == 'iframe_golfbox':
frame = page.frame_locator('iframe[src*="golfbox"]')
element = frame.locator(f['scrape_status_selector']).first
if await element.count() == 0:
warnings.append(f"❌ {f['name']}: Fant ikke elementet '{f['scrape_status_selector']}' i iframen")
continue
full_text = await element.inner_text()
elif method == 'click_then_css':
parts = f['scrape_status_selector'].split('||')
if len(parts) != 2:
warnings.append(f"❌ {f['name']}: Ugyldig selector for click_then_css (mangler ||)")
continue
btn_selector, text_selector = parts
btn = page.locator(btn_selector).first
if await btn.count() == 0:
warnings.append(f"❌ {f['name']}: Fant ikke knappen å klikke på: '{btn_selector}'")
continue
await btn.click(force=True)
await page.wait_for_timeout(2000)
element = page.locator(text_selector).first
if await element.count() == 0:
warnings.append(f"❌ {f['name']}: Fant ikke tekstboksen '{text_selector}' etter klikk")
continue
full_text = await element.inner_text()
elif method == 'llm_parse':
print(" 🖱️ Leter etter knapper å klikke på for å avdekke skjult tekst...")
knapper = await page.get_by_text(re.compile(r"banestatus|dagens status|se status|se dagens status|baneinfo|\bstatus\b", re.IGNORECASE)).all()
klikk_count = 0
for knapp in knapper:
try:
if await knapp.is_visible():
await knapp.click(timeout=2000, force=True)
klikk_count += 1
await page.wait_for_timeout(2000)
except Exception:
pass
if klikk_count > 0:
print(f" 🎯 Tvangsklikket på {klikk_count} status-knapp(er)! Venter ekstra på at innholdet laster...")
await page.wait_for_timeout(2000)
else:
print(" ⚠️ Fant ingen knapper å klikke på.")
# --- NYTT: HENTER OGSÅ SKJULT TEKST (For Scangolf megamenyer) ---
element = page.locator("body").first
if await element.count() == 0:
warnings.append(f"❌ {f['name']}: Klarte ikke å lese siden for AI-tolkning")
continue
synlig_tekst = await element.inner_text() or ""
skjult_tekst = await element.text_content() or ""
# Slår sammen all tekst slik at Gemini får med seg menyer som er gjemt med CSS
råtekst = synlig_tekst + " " + skjult_tekst
full_text = " ".join(råtekst.split())
# ----------------------------------------------------------------
else:
warnings.append(f"⚠️ {f['name']}: Ukjent skrapemetode i databasen: '{method}'")
continue
await conn.execute("UPDATE facilities SET status_updated_at = CURRENT_DATE WHERE id = $1", f['id'])
courses = await conn.fetch("SELECT id, name, status, scrape_keyword FROM courses WHERE facility_id = $1", f['id'])
is_single_course = len(courses) == 1
for c in courses:
old_status = c['status'] or "ukjent"
if method == 'llm_parse':
print(f" 🤖 Spør Gemini om status for '{c['name']}' (Singelbane: {is_single_course})...")
new_status = await ask_llm_status(full_text, c['name'], is_single_course, f.get('ai_instruction'))
print(" ⏳ Tar 5 sekunders pause for å spare Gemini-kvoten...")
await asyncio.sleep(5)
else:
new_status = interpret_status(full_text, c['scrape_keyword'])
if new_status == "NOT_FOUND":
warnings.append(f"❓ {f['name']} ({c['name']}): Fant ikke søkeordet '{c['scrape_keyword']}' i teksten.")
continue
# --- OPPDATERT LOGIKK (Fikser logg-buggen) ---
if new_status == "ukjent":
# Sikkerhetsnettet slår inn: Vi beholder gammel status!
warnings.append(f"⚠️ {f['name']} ({c['name']}): Fant ikke status. Beholder '{old_status.upper()}'.")
print(f" 🟡 KONKLUSJON: Fant ikke status i teksten (Sikkerhetsnett). Beholder gammel status ({old_status.upper()}).")
elif new_status != old_status:
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", new_status, c['id'])
changes.append(f"🔹 {f['name']} ({c['name']}): {old_status.upper()} ➔ {new_status.upper()}")
print(f" 🟢 KONKLUSJON: Status endret fra {old_status.upper()} til {new_status.upper()}")
else:
successes.append(f"✅ {f['name']} ({c['name']}): {new_status.upper()}")
print(f" ⚪ KONKLUSJON: Ingen endring. Banen er fortsatt {old_status.upper()}")
# ---------------------------------------------
except Exception as e:
err_msg = str(e).split('\n')[0]
warnings.append(f"🔥 {f['name']}: Feil under skraping: {err_msg}")
finally:
await page.close()
await browser.close()
await conn.close()
send_report(changes, warnings, successes)
print("🏁 Ferdig.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="TeeOff Status Scraper")
parser.add_argument("--ids", type=str, help="Kommaseparert liste med anleggs-IDer", default=None)
args = parser.parse_args()
facility_ids_list = None
if args.ids:
try:
facility_ids_list = [int(id_str.strip()) for id_str in args.ids.split(",") if id_str.strip()]
except ValueError:
print("❌ Feil format på --ids. Må være kommaseparerte tall, f.eks: 1,4,12")
exit(1)
asyncio.run(run_daily_scraping(facility_ids_list))

View file

@ -0,0 +1,79 @@
import asyncio, asyncpg, urllib.request, json
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
# 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"
def decode_html(text):
if not text: return ""
return str(text).replace('&#038;', '&').replace('&amp;', '&').replace('&nbsp;', ' ').strip()
async def run_greenfee_sync():
print("🎯 Starter GREENFEE-SYNC v1.2 (Basert på rå-API mapping)...")
conn = await asyncpg.connect(DB_URL)
page = 1
total_updated = 0
while True:
try:
req = urllib.request.Request(f"{WP_API_URL}&page={page}", headers={'User-Agent': 'TeeOff-Sync'})
with urllib.request.urlopen(req) as response:
data = json.loads(response.read().decode())
except: break
if not data: break
for post in data:
slug = post['slug']
acf = post.get('acf', {})
# Henter banenavn for å gruppere riktig
bane_1_navn = acf.get('navn_pa_hovedbane') or "Hovedbanen"
bane_2_navn = acf.get('navn_pa_sekundar_bane') or "Bane 2"
final_greenfee = []
# --- MAPPER BANE 1 (Voksne + Junior) ---
voksne_1 = acf.get('greenfee_-_voksne') or []
junior_1 = acf.get('greenfee_-_junior') or []
for i, item in enumerate(voksne_1):
row = {
"banenavn": bane_1_navn,
"priskategori": item.get('priskategori'),
"pris_voksne": item.get('pris_voksne')
}
# Legger til juniorpris hvis den finnes på samme index
if i < len(junior_1):
row["pris_junior"] = junior_1[i].get('pris_junior')
final_greenfee.append(row)
# --- MAPPER BANE 2 (Voksne + Junior) ---
voksne_2 = acf.get('greenfee_-_voksne_bane_to') or []
junior_2 = acf.get('greenfee_-_junior_bane_to') or []
for i, item in enumerate(voksne_2):
row = {
"banenavn": bane_2_navn,
"priskategori": item.get('priskategori_bane_to'),
"pris_voksne": item.get('pris_voksne_bane_to')
}
if i < len(junior_2):
row["pris_junior"] = junior_2[i].get('pris_junior_bane_to')
final_greenfee.append(row)
# Henter krav (Gjeste_krav)
reqs = decode_html(acf.get('krav_til_gjestespillere'))
if final_greenfee:
await conn.execute('''
UPDATE facilities SET greenfee = $1::jsonb, guest_requirements = $2 WHERE slug = $3
''', json.dumps(final_greenfee), reqs, slug)
print(f"✅ {slug}: Importerte {len(final_greenfee)} prisrader for {bane_1_navn}/{bane_2_navn}")
total_updated += 1
page += 1
await conn.close()
print(f"\n✨ Ferdig! Oppdaterte priser for {total_updated} anlegg.")
if __name__ == "__main__":
asyncio.run(run_greenfee_sync())

View file

@ -0,0 +1,116 @@
import asyncio
import os
import re
from playwright.async_api import async_playwright
from google import genai
from dotenv import load_dotenv
load_dotenv()
# Den nye pakken henter automatisk GEMINI_API_KEY fra .env-filen din
client = genai.Client()
async def ask_llm_status(text, course_name, is_single_course):
if is_single_course:
bane_instruks = "Finn den generelle banestatusen for dette golfanlegget. Se bort fra spesifikke banenavn, da anlegget kun har én bane."
else:
bane_instruks = f'Finn banestatusen SPESIFIKT for banen som heter/omtales som: "{course_name}".'
prompt = f"""
Du er en ekspert på å lese norske golfklubbers nettsider for å finne banestatus.
{bane_instruks}
Svar KUN med nøyaktig ETT av disse ordene:
- aapen (hvis banen er åpen/sommergreener)
- stengt (hvis banen er lukket/stengt/frost/snø)
- aapen_med_vintergreener (hvis det spilles på vintergreener)
- aapner_snart (hvis den åpner om kort tid)
- stenger_snart (hvis den stenger for sesongen om kort tid)
- under_utvikling (hvis den er under utvikling)
- nedlagt (hvis den er nedlagt)
- ukjent (hvis du ikke finner noe info om banen i teksten)
Tekst fra nettsiden:
{text[:15000]}
"""
try:
# Ny måte å kalle modellen asynkront på med google-genai
response = await client.aio.models.generate_content(
model='gemini-2.5-flash',
contents=prompt
)
svar = response.text.strip().lower()
gyldige_svar = [
"aapen", "stengt", "aapen_med_vintergreener",
"aapner_snart", "stenger_snart", "under_utvikling",
"nedlagt", "ukjent"
]
for gyldig in gyldige_svar:
if gyldig in svar:
return gyldig
return "ukjent"
except Exception as e:
print(f"❌ Gemini Feil: {e}")
return "ukjent"
async def run_test():
print("\n" + "="*50)
print(" 🧪 TEE OFF: GEMINI TEST-VERKTØY (MED AUTO-KLIKKER)")
print("="*50)
url = input("🌐 Skriv inn URL til golfklubben (f.eks. https://oslogk.no): ").strip()
if not url.startswith("http"):
url = "https://" + url
course_name = input("⛳ Skriv inn banenavn (eller trykk ENTER hvis anlegget kun har 1 bane): ").strip()
is_single = len(course_name) == 0
print("\n⏳ 1. Starter nettleser og besøker siden...")
full_text = ""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
try:
await page.goto(url, timeout=30000, wait_until="domcontentloaded")
await asyncio.sleep(3) # Vent på animasjoner og iframes
# --- NY LOGIKK: AUTO-KLIKKER ---
print("🖱️ Leter etter 'banestatus'-knapper å klikke på...")
# Vi leter etter tekst som inneholder "banestatus" (ignorerer store/små bokstaver)
knapper = await page.get_by_text(re.compile(r"banestatus", re.IGNORECASE)).all()
for knapp in knapper:
try:
if await knapp.is_visible():
await knapp.click(timeout=3000)
print(" 🎯 Klikket på en banestatus-knapp! Venter 2 sekunder...")
await asyncio.sleep(2) # Venter på at modalen/pop-upen åpner seg
break # Vi trenger bare å klikke på den første vi finner
except Exception as e:
# Ignorerer hvis knappen ikke er klikkbar, prøver neste
pass
# --------------------------------
element = page.locator("body").first
råtekst = await element.inner_text()
full_text = " ".join(råtekst.split())
print(f"✅ Hentet {len(full_text)} tegn med tekst fra nettsiden.")
except Exception as e:
print(f"❌ Feil ved innlasting av side: {e}")
await browser.close()
return
await browser.close()
print("🧠 2. Sender teksten til Gemini for analyse...")
status = await ask_llm_status(full_text, course_name, is_single)
print("\n" + "="*50)
print(f"🎯 GEMINI SITT SVAR: {status.upper()}")
print("="*50 + "\n")
if __name__ == "__main__":
asyncio.run(run_test())

View file

@ -0,0 +1,47 @@
import asyncio
import asyncpg
import os
from passlib.context import CryptContext
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
# Vi setter opp passord-sjekkeren AKKURAT slik main.py gjør det
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
async def test_sannheten():
print("\n" + "="*50)
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!
try:
conn = await asyncpg.connect(DB_URL)
row = await conn.fetchrow("SELECT password_hash FROM admins WHERE username = $1", username)
if not row:
print("❌ FEIL: Fant ikke brukeren i det hele tatt!")
return
db_hash = row['password_hash']
print(f"1. Hash funnet i databasen: {db_hash[:30]}...")
print(f"2. Tester mot passordet: '{test_password}'")
# Den magiske testen
is_valid = pwd_context.verify(test_password, db_hash)
print("-" * 50)
if is_valid:
print("✅ SUKSESS! Passordet og hashen stemmer 100% overens.")
print("➡️ KONKLUSJON: Hashingen fungerer perfekt. Problemet MÅ være at FastAPI (main.py) ikke klarer å lese JSON-dataene fra curl/frontend riktig.")
else:
print("❌ FEIL! Passordet stemmer IKKE med hashen i databasen.")
print("➡️ KONKLUSJON: Scriptet som oppdaterer passordet gjør en feil (f.eks. legger til usynlige tegn), eller lagringen i databasen blir korrupt.")
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(test_sannheten())

View file

@ -0,0 +1,85 @@
"""
TEE OFF ADMIN PASSWORD UPDATER (API CONTAINER VERSION)
---------------------------------------------------------------------------
FUNKSJON: Kobler direkte til databasen inni API-containeren, sjekker at
brukeren finnes, og utfører passordoppdateringen automatisk.
STATUS: Påvirker IKKE tofaktor (2FA). Gjør jobben fra start til slutt.
---------------------------------------------------------------------------
"""
import asyncio
import asyncpg
import os
import sys
import getpass
from passlib.hash import pbkdf2_sha256
# 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")
async def update_admin_password():
print("\n" + "="*50)
print(" TEE OFF ADMIN PASSORD-OPPDATERER (DIREKTE TILKOBLING)")
print("="*50)
# Kobler til databasen på ekte backend-vis
try:
conn = await asyncpg.connect(DB_URL)
except Exception as e:
print(f"❌ Kunne ikke koble til databasen: {e}")
sys.exit(1)
try:
# Brukernavn-verifisering
while True:
username = input("Brukernavn på admin som skal oppdateres: ").strip()
print("⏳ Sjekker databasen...")
# Spør databasen direkte hvor mange som har dette navnet
count = await conn.fetchval("SELECT COUNT(*) FROM admins WHERE username = $1", username)
if count == 0:
print(f"❌ Fant ingen bruker med navnet '{username}'. Prøv igjen.\n")
elif count > 1:
print(f"⚠️ KRITISK FEIL: Fant {count} brukere med navnet '{username}'. Avbryter.")
sys.exit(1)
else:
print(f"✅ Bruker '{username}' funnet i databasen!\n")
break
# Passord-verifisering
while True:
password = getpass.getpass("Skriv inn NYTT passord: ")
password_confirm = getpass.getpass("Gjenta NYTT passord: ")
if password == password_confirm:
if len(password) < 8:
print("⚠️ Advarsel: Passordet bør være minst 8 tegn.")
print(f"\n[DEBUG] Passord akseptert.")
break
else:
print("❌ Passordene er ikke like. Prøv igjen.\n")
print("⏳ Genererer PBKDF2-hash...")
password_hash = pbkdf2_sha256.hash(password)
print("⏳ Oppdaterer databasen automatisk...")
# Utfører selve oppdateringen (sikret mot SQL-injeksjoner)
await conn.execute("UPDATE admins SET password_hash = $1 WHERE username = $2", password_hash, username)
print("\n✅ PASSORD OPPDATERT VELLYKKET!")
print("-" * 50)
print(f"Passordet for '{username}' er nå endret i databasen.")
print("Tofaktor (2FA) og alt annet er beholdt urørt.")
print("-" * 50 + "\n")
finally:
# Lukk tilkoblingen pent
await conn.close()
if __name__ == "__main__":
try:
# Siden vi bruker asyncpg, må scriptet kjøres i en asyncio-loop
asyncio.run(update_admin_password())
except KeyboardInterrupt:
print("\nAvbrutt.")
sys.exit(0)

View file

@ -0,0 +1,72 @@
import os
import shutil
from pathlib import Path
# --- KONFIGURASJON ---
KILDE_MAPPE = "/opt/teeoff/"
EKSPORT_MAPPE = "/opt/teeoff/kode_eksport_1/"
TRE_FIL = "/opt/teeoff/fil-tre.txt"
# Filtyper vi vil kopiere
FILTYPER = ['.py', '.ts', '.tsx']
# Mapper vi IKKE vil ha med i treet eller skanne (sparer tid og rot)
IGNORER_MAPPER = ['.git', 'node_modules', '__pycache__', 'kode_eksport', '.next']
def generer_tre_og_kopier():
kilde_sti = Path(KILDE_MAPPE)
eksport_sti = Path(EKSPORT_MAPPE)
# 1. Opprett eksportmappen hvis den ikke finnes
eksport_sti.mkdir(parents=True, exist_ok=True)
tre_linjer = []
kopierte_filer = 0
print("Skanner filer og genererer tre...")
# 2. Gå gjennom alle mapper og filer
for root, dirs, files in os.walk(kilde_sti):
# Fjern ignorerte mapper så vi ikke går inn i dem
dirs[:] = [d for d in dirs if d not in IGNORER_MAPPER]
# Regn ut innrykk basert på hvor dypt vi er i mappestrukturen
nivaa = root.replace(KILDE_MAPPE, '').count(os.sep)
innrykk = ' ' * 4 * nivaa
mappe_navn = os.path.basename(root)
# Legg til mappen i treet
if mappe_navn:
tre_linjer.append(f"{innrykk}📁 {mappe_navn}/")
else:
tre_linjer.append(f"📁 {kilde_sti.name}/")
sub_innrykk = ' ' * 4 * (nivaa + 1)
# 3. Gå gjennom filene i mappen
for fil in files:
tre_linjer.append(f"{sub_innrykk}📄 {fil}")
fil_sti = Path(root) / fil
# 4. Sjekk om filen har riktig endelse og skal kopieres
if fil_sti.suffix in FILTYPER:
# Lag et unikt filnavn for å unngå overskriving
relativ_sti = fil_sti.relative_to(kilde_sti)
nytt_navn = str(relativ_sti).replace(os.sep, '_').replace('.', '_') + '.txt'
ny_sti = eksport_sti / nytt_navn
# Kopier filen
shutil.copy2(fil_sti, ny_sti)
kopierte_filer += 1
# 5. Lagre filteret til tekstfilen
with open(TRE_FIL, 'w', encoding='utf-8') as f:
f.write('\n'.join(tre_linjer))
print(f"\n✅ Ferdig!")
print(f"📁 Filtre er lagret i: {TRE_FIL}")
print(f"📝 Kopierte {kopierte_filer} kodefiler til: {EKSPORT_MAPPE}")
if __name__ == "__main__":
generer_tre_og_kopier()

View file

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

View file

@ -0,0 +1,217 @@
"use client";
/**
* TEE OFF SYSTEM INSTRUCTIONS - FACILITY CARDS v3.8 (BLOB SEARCH)
* ---------------------------------------------------------------------------
* REGEL 1: Status-badge SKAL vises øverst til venstre FOR ALLE BANER.
* Bruk STATUS_MAP for tekst.
* REGEL 2: DATA-PARSING: Bruk parseJson() for 'course_statuses', 'amenities' og 'nsg_data'.
* REGEL 3: Avstand-pillen skal ha fargen #2d3319 (Mørk oliven) med hvit tekst.
* REGEL 4: NSG (Blå 'N') og Golfamore (Oransje 'G') sirkler skal ha hvit kant (border-2).
* REGEL 5: Bunnen: Antall Hull (grønn pill), Banetype (grå pill), og Ikon-sirkler.
* REGEL 6: Viser dato (f.eks "05. mars 2026") rett til høyre for øverste status-pille.
* REGEL 7: Natural Language Search bruker en "Search Blob" for å støtte delvise
* ord og skrivefeil slik at listen ikke tømmes mens brukeren skriver.
* ---------------------------------------------------------------------------
*/
import { STATUS_MAP, REGIONS } from "@/config/constants";
import { useState, useEffect, useMemo } from 'react';
import Link from 'next/link';
function getDistance(lat1: number, lon1: number, lat2: number, lon2: number) {
try {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon/2) * Math.sin(dLon/2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
} catch (e) { return Infinity; }
}
export default function FacilitySearch({ initialFacilities }: { initialFacilities: any[] }) {
const [searchQuery, setSearchQuery] = useState("");
const [userLocation, setUserLocation] = useState<{ lat: number, lng: number } | null>(null);
const [sortMethod, setSortMethod] = useState<'dist' | 'alpha'>('alpha');
useEffect(() => {
if ("geolocation" in navigator) {
navigator.geolocation.getCurrentPosition(p => {
setUserLocation({ lat: p.coords.latitude, lng: p.coords.longitude });
setSortMethod('dist');
});
}
}, []);
const processed = useMemo(() => {
if (!Array.isArray(initialFacilities)) return [];
// Fyllord som fjernes slik at "Åpne baner i Oslo" blir til søkeordene ["åpne", "oslo"]
const stopWords = new Set(["i", "på", "for", "med", "av", "og"]);
return initialFacilities.map(f => {
// --- ROBUST DATA-PARSING ---
const parseJson = (val: any, fallback: any) => {
if (!val) return fallback;
if (typeof val === 'object') return val;
try { return JSON.parse(val); } catch (e) { return fallback; }
};
const rawStatuses = parseJson(f.course_statuses, []);
const sArr = Array.isArray(rawStatuses) && rawStatuses.length > 0
? rawStatuses
: [{ status: 'ukjent', name: 'Hovedbane' }];
const amenities = parseJson(f.amenities, {});
const nsgData = parseJson(f.nsg_data, {});
const dist = userLocation && f.lat && f.lng ? getDistance(userLocation.lat, userLocation.lng, f.lat, f.lng) : Infinity;
const hasNSG = nsgData && Object.keys(nsgData).length > 0;
const hasGolfamore = f.golfamore === true;
// --- THE SEARCH BLOB ---
// Vi starter med å legge navn, by og fylke i en stor, usynlig tekststreng
let searchableText = `${f.name} ${f.city} ${f.county}`.toLowerCase();
// 1. Injiser statuser i tekststrengen
const hasOpen = sArr.some((c: any) => (c.status || "") === 'aapen');
const hasClosed = sArr.some((c: any) => (c.status || "") === 'stengt');
const hasWinter = sArr.some((c: any) => (c.status || "") === 'aapen_med_vintergreener');
const hasNedlagt = sArr.some((c: any) => (c.status || "") === 'nedlagt');
if (hasOpen) searchableText += " åpen åpne aapen";
if (hasClosed) searchableText += " stengt stengte";
if (hasWinter) searchableText += " vinter vintergreener vinterbane";
if (hasNedlagt) searchableText += " nedlagt nedlagte";
// 2. Injiser spesial-tags
if (hasNSG) searchableText += " nsg norsk seniorgolf";
if (hasGolfamore) searchableText += " golfamore amore";
// 3. Injiser landsdel (f.eks. hvis fylket er Akershus, legger vi til "østlandet")
const fylke = (f.county || "").toLowerCase();
Object.entries(REGIONS).forEach(([regionName, counties]) => {
if (counties.includes(fylke)) {
searchableText += ` ${regionName}`;
}
});
// Splitter brukerens søk inn i enkeltord og fjerner stopWords + ordene "bane"/"baner"
const words = searchQuery
.toLowerCase()
.trim()
.split(/\s+/)
.filter(w => w.length > 0 && !stopWords.has(w) && w !== "bane" && w !== "baner");
// Sjekker at ALLE ordene brukeren har skrevet, finnes et sted i "Search Blob"-en
const matches = words.every(w => searchableText.includes(w));
return { ...f, statuses: sArr, amenities, dist, hasNSG, hasGolfamore, matches };
})
.filter(f => f.matches)
.sort((a, b) => {
if (sortMethod === 'dist' && a.dist !== b.dist) return a.dist - b.dist;
return a.name.localeCompare(b.name, 'nb');
});
}, [searchQuery, initialFacilities, userLocation, sortMethod]);
return (
<div className="max-w-[1400px] mx-auto px-6 py-12 relative z-40">
<div className="text-center mb-6">
<button onClick={() => setSortMethod(sortMethod === 'dist' ? 'alpha' : 'dist')} className="bg-white px-6 py-3 rounded-full shadow-md text-[10px] font-black text-[#8bc34a] uppercase tracking-widest border border-gray-100 transition-colors">
{sortMethod === 'dist' ? "📍 Nærmeste baner først" : "🔠 Alfabetisk visning"} • {processed.length} baner
</button>
</div>
<input className="w-full p-8 rounded-[2.5rem] shadow-2xl mb-16 text-gray-900 border-none ring-1 ring-black/5 text-2xl outline-none focus:ring-4 focus:ring-[#8bc34a]/20 transition-all bg-white" placeholder='Søk baner, fylke, status eller spesial (f.eks "Åpne baner i Akershus" eller "NSG")...' value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
{processed.map((f: any) => {
const sArr = f.statuses; // Sikret via pre-prosesseringen over
// Formater datoen pent: "05. mars 2026"
const lastUpdated = f.status_updated_at
? new Date(f.status_updated_at).toLocaleDateString('nb-NO', { day: '2-digit', month: 'long', year: 'numeric' })
: 'Ukjent';
return (
<Link href={`/golfbaner/${f.slug}`} key={f.id} className="bg-white rounded-[2.5rem] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-gray-100 flex flex-col group relative">
<div className="h-64 relative overflow-hidden bg-gray-100">
<img src={f.image_url || "/Toppbilde-standard.jpg"} className="w-full h-full object-cover transition duration-1000 group-hover:scale-105" alt={f.name} />
{/* Status Badges for ALLE baner på anlegget */}
<div className="absolute top-5 left-5 flex flex-col gap-2 z-20">
{sArr.map((course: any, idx: number) => {
const rawStatus = (course.status || "ukjent").toLowerCase();
let statusColor = "bg-gray-400";
if (rawStatus === 'aapen') statusColor = "bg-[#8bc34a]";
else if (rawStatus.includes('vinter') || rawStatus === 'stenger_snart') statusColor = "bg-[#ff5722]";
else if (rawStatus === 'aapner_snart') statusColor = "bg-amber-500";
else if (rawStatus === 'stengt') statusColor = "bg-red-600";
else if (rawStatus === 'nedlagt') statusColor = "bg-black";
else if (rawStatus === 'under_utvikling') statusColor = "bg-blue-500";
return (
<div key={idx} className="flex items-center gap-3">
<div className={`${statusColor} text-white px-3 py-1.5 rounded-xl text-[9px] font-black uppercase shadow-lg backdrop-blur-sm bg-opacity-90 flex items-center gap-2 max-w-[200px]`}>
{sArr.length > 1 && (
<span className="opacity-80 border-r border-white/30 pr-2 truncate max-w-[90px]" title={course.name}>
{course.name}
</span>
)}
<span>{STATUS_MAP[rawStatus] || rawStatus}</span>
</div>
{/* Dato-pille ved siden av den øverste status-pillen */}
{idx === 0 && (
<div className="bg-white/30 backdrop-blur-sm text-[#11280f]/90 px-3 py-1.5 rounded-xl text-[11px] font-bold shadow-lg">
{lastUpdated}
</div>
)}
</div>
);
})}
</div>
{/* Avstandspille */}
{f.dist !== Infinity && (
<div className="absolute bottom-5 right-5 bg-[#2d3319] text-white px-4 py-2 rounded-2xl text-[10px] font-black shadow-lg z-20">
{Math.round(f.dist)} km unna
</div>
)}
</div>
<div className="p-8 flex flex-col flex-grow">
<h3 className="font-black text-3xl text-[#11280f] mb-1 group-hover:text-[#8bc34a] transition-colors leading-tight">{f.name}</h3>
<p className="text-gray-400 text-[11px] font-bold uppercase tracking-widest mb-8">{f.city} • {f.county}</p>
<div className="mt-auto flex items-center justify-between">
<div className="flex items-center gap-2">
{/* Hull-pille */}
<span className="bg-[#f1f7ed] text-[#8bc34a] px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest">
{f.amenities?.antall_hull || '--'} HULL
</span>
{/* Banetype-pille */}
<span className="bg-gray-50 text-gray-400 px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-gray-100">
{f.banetype || 'SKOGSBANE'}
</span>
</div>
{/* Sirkel-ikoner (NSG / Golfamore) */}
<div className="flex gap-2">
{f.hasNSG && (
<div className="w-9 h-9 bg-blue-600 text-white rounded-full flex items-center justify-center font-black text-sm shadow-lg border-2 border-white translate-y-1">N</div>
)}
{f.hasGolfamore && (
<div className="w-9 h-9 bg-[#ff5722] text-white rounded-full flex items-center justify-center font-black text-sm shadow-lg border-2 border-white translate-y-1">G</div>
)}
</div>
</div>
</div>
</Link>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,130 @@
"use client";
/**
* TEE OFF SYSTEM INSTRUCTIONS - HERO SLIDER v2.4
* ---------------------------------------------------------------------------
* REGEL 1: Kun baner med status 'aapen', 'aapner_snart', 'stenger_snart'
* eller 'aapen_med_vintergreener' skal prioriteres.
* REGEL 2: Baner med status 'nedlagt' eller 'under_utvikling' skal ALDRI vises.
* REGEL 3: Baner med generiske bilder (inneholder 'standard') skal ALDRI vises.
* REGEL 4: MANUELL EKSKLUDERING: Slugs i MANUAL_EXCLUSION_LIST skal aldri vises.
* REGEL 5: Slideren skal vise nøyaktig 5 baner.
* REGEL 6: Maks høyde er låst til 624px. Ingen badges.
* REGEL 7: Typografi: Nedjustert fontstørrelse (4xl mobil / 7xl desktop) for eleganse.
* REGEL 8: Utvalget skal være stabilt i én time (Hourly Seed) før det refreshes.
* ---------------------------------------------------------------------------
*/
import { useState, useEffect, useMemo } from 'react';
import Link from 'next/link';
const MANUAL_EXCLUSION_LIST = [
'alsten-golfklubb', 'askim-golfklubb', 'bergen-golfklubb', 'eidskog-golfklubb',
'eiker-golfklubb', 'floro-golfklubb', 'garder-golfklubb', 'hafjell-golfklubb',
'halden-golfklubb', 'haugesund-golfklubb', 'hinnoy-golfklubb', 'hitra-golfklubb',
'hurum-golfklubb', 'imjelt-pitch-putt', 'karmoy-golfklubb', 'kristiansund-og-omegn-golfklubb',
'lommedalen-golfklubb', 'laerdal-golfklubb', 'moa-golfsenter', 'modum-golfklubb',
'nes-golfklubb-09', 'nittedal-golfklubb', 'selbu-golfklubb', 'stryn-golfklubb',
'sunnfjord-golfklubb', 'tysnes-golfklubb', 'vanylven-golfklubb', 'vesteralen-golfklubb',
'vestlia-golf'
];
export default function HeroSlider({ facilities }: { facilities: any[] }) {
const [currentIndex, setCurrentSlide] = useState(0);
const sliderItems = useMemo(() => {
if (!Array.isArray(facilities) || facilities.length === 0) return [];
const preferredStatuses = ['aapen', 'aapner_snart', 'stenger_snart', 'aapen_med_vintergreener'];
const forbiddenStatuses = ['nedlagt', 'under_utvikling'];
const validCandidates = facilities.filter(f => {
if (MANUAL_EXCLUSION_LIST.includes(f.slug)) return false;
const img = f.image_url || "";
if (!img || img.toLowerCase().includes('standard') || img.length < 5) return false;
const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : [];
const isForbidden = statuses.some((s: any) =>
forbiddenStatuses.includes((s.status || "").toLowerCase())
);
return !isForbidden;
});
const highPriority = validCandidates.filter(f => {
const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : [];
return statuses.some((s: any) => preferredStatuses.includes((s.status || "").toLowerCase()));
});
const fallbackPool = validCandidates.filter(f => !highPriority.includes(f));
const now = new Date();
const hourlySeed = parseInt(`${now.getFullYear()}${now.getMonth()}${now.getDate()}${now.getHours()}`);
const seededShuffle = (arr: any[]) => {
return [...arr].sort((a, b) => ((a.id * hourlySeed) % 100) - ((b.id * hourlySeed) % 100));
};
let selection = seededShuffle(highPriority);
if (selection.length < 5) {
selection = [...selection, ...seededShuffle(fallbackPool)].slice(0, 5);
} else {
selection = selection.slice(0, 5);
}
return selection;
}, [facilities]);
useEffect(() => {
if (sliderItems.length <= 1) return;
const interval = setInterval(() => setCurrentSlide((p) => (p + 1) % sliderItems.length), 8000);
return () => clearInterval(interval);
}, [sliderItems.length]);
if (sliderItems.length === 0) return null;
return (
<section className="relative h-[65vh] max-h-[624px] w-full overflow-hidden bg-[#11280f]">
{sliderItems.map((f, i) => (
<div
key={f.id}
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
i === currentIndex ? 'opacity-100 z-10' : 'opacity-0 z-0'
}`}
>
<Link href={`/golfbaner/${f.slug}`} className="block h-full relative group">
<div className="absolute inset-0 bg-gradient-to-t from-[#11280f] via-[#11280f]/40 to-black/10 z-10" />
<img
src={f.image_url}
alt={f.name}
className="w-full h-full object-cover transition-transform duration-[10s] scale-100 group-hover:scale-105"
/>
<div className="absolute inset-0 z-20 flex items-center">
<div className="max-w-[1400px] mx-auto px-6 w-full">
<div className="max-w-4xl animate-in fade-in slide-in-from-bottom-8 duration-1000">
{/* FONT NEDJUSTERT FRA text-6xl md:text-9xl TIL text-4xl md:text-7xl */}
<h2 className="text-4xl md:text-7xl font-black text-white tracking-tighter drop-shadow-2xl leading-[0.9] mb-4">
{f.name}
</h2>
<p className="text-white/90 text-sm md:text-xl font-bold uppercase tracking-[0.4em] drop-shadow-md">
{f.county} <span className="text-[#8bc34a] mx-2">•</span> {f.city}
</p>
</div>
</div>
</div>
</Link>
</div>
))}
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-30 flex gap-4">
{sliderItems.map((_, i) => (
<button
key={i}
onClick={() => setCurrentSlide(i)}
className={`h-1 transition-all duration-500 rounded-full ${
i === currentIndex ? 'w-16 bg-[#8bc34a]' : 'w-4 bg-white/20'
}`}
/>
))}
</div>
</section>
);
}

View file

@ -0,0 +1,103 @@
"use client";
/**
* TEE OFF ADMIN LOGIN v1.2
* ---------------------------------------------------------------------------
* PLASSERING: frontend/src/app/admin/login/page.tsx
* FUNKSJON: Offentlig tilgjengelig innlogging for administratorer.
* ---------------------------------------------------------------------------
*/
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { API_URL } from "@/config/constants";
export default function AdminLogin() {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState({ username: '', password: '', code: '' });
const [tempToken, setTempToken] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
const res = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: formData.username, password: formData.password })
});
const data = await res.json();
if (res.ok) {
setTempToken(data.temp_token);
setStep(2);
} else {
setError(data.detail || 'Ugyldig pålogging');
}
} catch (err) {
console.error("🔥 DEN EKTE FEILEN ER:", err);
setError('Systemfeil: Kunne ikke koble til API-et');
} finally {
setIsLoading(false);
}
};
const handleVerify2FA = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const res = await fetch(`${API_URL}/auth/verify-2fa`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ temp_token: tempToken, code: formData.code })
});
if (res.ok) {
// VIKTIG: Etter suksess sender vi brukeren til selve dashbordet
router.push('/admin');
router.refresh();
} else {
setError('Ugyldig 2FA-kode');
}
} catch (err) {
setError('Tilkoblingsfeil ved 2FA-verifisering');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-[#f1f7ed] p-6 font-sans">
<div className="max-w-md w-full bg-white rounded-[3rem] shadow-2xl p-12 border border-white">
<div className="flex justify-center mb-10">
<img src="/TeeOff-logo-Retina-1.png" className="h-10 w-auto" alt="TeeOff" />
</div>
<h2 className="text-2xl font-black text-center uppercase tracking-tighter mb-8 text-[#11280f]">
{step === 1 ? "Admin Portalen" : "Tofaktor Sjekk"}
</h2>
<form onSubmit={step === 1 ? handleLogin : handleVerify2FA} className="space-y-4">
{step === 1 ? (
<>
<input type="text" placeholder="Brukernavn eller E-post" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData(prevState => ({...prevState, username: e.target.value}))} required />
<input type="password" placeholder="Passord" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData(prevState => ({...prevState, password: e.target.value}))} required />
</>
) : (
<div className="space-y-4">
<p className="text-[10px] text-gray-400 font-black uppercase text-center tracking-widest">Tast inn 6 siffer fra appen din</p>
<input type="text" placeholder="000 000" className="w-full p-6 text-center text-4xl tracking-[0.3em] font-black bg-gray-50 rounded-3xl border-none ring-2 ring-[#ff5722]/20 outline-none focus:ring-[#ff5722] transition-all text-[#ff5722]" onChange={e => setFormData({...formData, code: e.target.value})} autoFocus required />
</div>
)}
{error && <div className="bg-red-50 p-4 rounded-xl text-red-600 text-[10px] font-black uppercase tracking-widest text-center border border-red-100">⚠️ {error}</div>}
<button type="submit" disabled={isLoading} className={`w-full p-6 rounded-2xl font-black uppercase text-xs tracking-widest text-white transition-all shadow-xl ${step === 1 ? 'bg-[#11280f]' : 'bg-[#ff5722]'}`}>
{isLoading ? "Venter..." : (step === 1 ? "Fortsett" : "Logg inn")}
</button>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,179 @@
"use client";
import { useState, useEffect } from 'react';
import { API_URL } from "@/config/constants";
import Link from 'next/link';
export default function MembershipWasher() {
const [drafts, setDrafts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
const fetchDrafts = () => {
setLoading(true);
fetch(`${API_URL}/admin/membership/drafts`)
.then(res => res.json())
.then(data => {
// Konverter innkommende drafts til editerbare felter lokalt
const editableDrafts = data.map((f: any) => ({
...f,
edit_standard_navn: f.membership_draft?.foreslatt_standard_navn || f.navn_standard_medlemskap || "",
edit_standard_pris: f.membership_draft?.foreslatt_standard_pris || f.standard_medlemskap || "",
edit_standard_kommentar: f.membership_draft?.foreslatt_standard_kommentar || "",
edit_rimeligste_navn: f.membership_draft?.foreslatt_rimeligste_navn || f.navn_rimeligste_alternativ || "",
edit_rimeligste_pris: f.membership_draft?.foreslatt_rimeligste_pris || f.rimeligste_alternativ || "",
}));
setDrafts(editableDrafts);
setLoading(false);
})
.catch(() => setLoading(false));
};
useEffect(() => {
fetchDrafts();
}, []);
const toggleSelectAll = (checked: boolean) => {
if (checked) setSelectedIds(drafts.map(d => d.id));
else setSelectedIds([]);
};
const toggleOne = (id: number) => {
if (selectedIds.includes(id)) setSelectedIds(selectedIds.filter(i => i !== id));
else setSelectedIds([...selectedIds, id]);
};
const updateDraftField = (id: number, field: string, value: any) => {
setDrafts(drafts.map(d => d.id === id ? { ...d, [field]: value } : d));
};
const handleApprove = async () => {
const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({
facility_id: d.id,
navn_standard_medlemskap: d.edit_standard_navn,
standard_medlemskap: Number(d.edit_standard_pris) || null,
standard_medlemskap_kommentarer: d.edit_standard_kommentar,
navn_rimeligste_alternativ: d.edit_rimeligste_navn,
rimeligste_alternativ: Number(d.edit_rimeligste_pris) || null,
}));
if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne.");
setSaving(true);
try {
const res = await fetch(`${API_URL}/admin/membership/approve-bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approvals: toApprove })
});
if (res.ok) {
alert(`${toApprove.length} anlegg ble oppdatert og lagret til live!`);
setSelectedIds([]);
fetchDrafts(); // Oppdaterer listen (fjerner de godkjente)
} else {
alert("Noe gikk galt under lagring.");
}
} catch (e) {
alert("Nettverksfeil");
}
setSaving(false);
};
if (loading) return <div className="p-20 text-center font-black animate-pulse">Laster utkast...</div>;
return (
<div className="min-h-screen bg-[#f1f7ed] p-8 text-[#11280f]">
<div className="max-w-[1400px] mx-auto">
<div className="flex justify-between items-end mb-10 border-b border-gray-200 pb-6">
<div>
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
<h1 className="text-4xl font-black">Medlemskaps-Vaskeriet</h1>
<p className="text-sm text-gray-600 mt-2">Gå gjennom AI-ens forslag, juster hvis nødvendig, og godkjenn for å publisere. Oppdatert-dato settes automatisk i dag.</p>
</div>
<button
onClick={handleApprove}
disabled={saving || selectedIds.length === 0}
className="bg-[#8bc34a] text-white px-8 py-4 rounded-xl font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50 disabled:scale-100"
>
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
</button>
</div>
{drafts.length === 0 ? (
<div className="bg-white p-20 rounded-[2rem] text-center shadow-sm">
<span className="text-6xl mb-4 block">🧹</span>
<h2 className="text-2xl font-black text-gray-400">Alt er rent og pent!</h2>
<p className="text-gray-500">Ingen ventende forslag fra AI-skraperen akkurat nå.</p>
</div>
) : (
<div className="space-y-6">
<div className="bg-white p-4 rounded-2xl shadow-sm flex items-center gap-4">
<input
type="checkbox"
className="w-5 h-5 accent-[#8bc34a] ml-2"
checked={selectedIds.length === drafts.length}
onChange={(e) => toggleSelectAll(e.target.checked)}
/>
<span className="font-black uppercase tracking-widest text-xs text-gray-500">Velg Alle</span>
</div>
{drafts.map(draft => (
<div key={draft.id} className={`bg-white p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/5' : 'border-transparent'}`}>
<div className="flex gap-6 items-start">
<div className="pt-2">
<input
type="checkbox"
className="w-6 h-6 accent-[#8bc34a] cursor-pointer"
checked={selectedIds.includes(draft.id)}
onChange={() => toggleOne(draft.id)}
/>
</div>
<div className="flex-grow space-y-4">
{/* OPPDATERT: Navn + ID Badge */}
<div className="flex justify-between items-center border-b pb-4">
<h3 className="text-2xl font-black flex items-center gap-3">
{draft.name}
<span className="text-xs font-mono font-bold bg-gray-100 text-gray-400 px-2 py-1 rounded-md">ID: {draft.id}</span>
</h3>
<a href={draft.medlemskap_url} target="_blank" className="text-xs font-bold text-blue-600 hover:underline bg-blue-50 px-4 py-2 rounded-lg">Sjekk Klubbens Nettside ↗</a>
</div>
{draft.membership_draft?.ai_begrunnelse && (
<div className="bg-blue-50/50 p-4 rounded-xl text-sm italic text-blue-900 border border-blue-100">
<strong>🤖 AI Begrunnelse:</strong> {draft.membership_draft.ai_begrunnelse}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-2">
{/* Standard */}
<div className="space-y-3">
<h4 className="text-xs font-black uppercase tracking-widest text-gray-400">Standard Medlemskap (Ubegrenset)</h4>
<div className="flex gap-2">
<input className="w-2/3 p-3 rounded-xl border border-gray-200 font-bold focus:border-[#8bc34a] outline-none" value={draft.edit_standard_navn} onChange={e => updateDraftField(draft.id, 'edit_standard_navn', e.target.value)} placeholder="Navn (eks. Hovedmedlem)" />
<input className="w-1/3 p-3 rounded-xl border border-gray-200 font-bold text-right focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_standard_pris} onChange={e => updateDraftField(draft.id, 'edit_standard_pris', e.target.value)} placeholder="Pris" />
</div>
<input className="w-full p-3 rounded-xl border border-gray-200 text-sm focus:border-[#8bc34a] outline-none" value={draft.edit_standard_kommentar} onChange={e => updateDraftField(draft.id, 'edit_standard_kommentar', e.target.value)} placeholder="Kommentar (F.eks: Inkluderer ikke treningsavgift)" />
<p className="text-[10px] text-gray-400">Gammel pris var: {draft.standard_medlemskap ? `kr ${draft.standard_medlemskap} (${draft.navn_standard_medlemskap})` : 'Ikke registrert'}</p>
</div>
{/* Rimeligste */}
<div className="space-y-3">
<h4 className="text-xs font-black uppercase tracking-widest text-gray-400">Rimeligste (Betaler Greenfee)</h4>
<div className="flex gap-2">
<input className="w-2/3 p-3 rounded-xl border border-gray-200 font-bold focus:border-[#8bc34a] outline-none" value={draft.edit_rimeligste_navn} onChange={e => updateDraftField(draft.id, 'edit_rimeligste_navn', e.target.value)} placeholder="Navn (eks. Greenfeemedlem)" />
<input className="w-1/3 p-3 rounded-xl border border-gray-200 font-bold text-right focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_rimeligste_pris} onChange={e => updateDraftField(draft.id, 'edit_rimeligste_pris', e.target.value)} placeholder="Pris" />
</div>
<p className="text-[10px] text-gray-400 mt-2">Gammel pris var: {draft.rimeligste_alternativ ? `kr ${draft.rimeligste_alternativ} (${draft.navn_rimeligste_alternativ})` : 'Ikke registrert'}</p>
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,452 @@
"use client";
/**
* TEE OFF ADMIN DASHBOARD v1.9 - RESPONSIVT MED AI-HVISKER, KILL SWITCH, FILTER & FULL EDIT
* ---------------------------------------------------------------------------
* PLASSERING: frontend/src/app/admin/page.tsx
* FUNKSJON: Live-oppdatering, massevalg, redigering, meny og smart-filtrering.
* ---------------------------------------------------------------------------
*/
import { useState, useEffect, useMemo } from 'react';
import { API_URL } from "@/config/constants";
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
import Link from 'next/link';
export default function AdminDashboard() {
const [facilities, setFacilities] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedFacilities, setSelectedFacilities] = useState<number[]>([]);
const [isScraping, setIsScraping] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [editingFacility, setEditingFacility] = useState<any | null>(null);
// NYTT: State for å holde styr på hvilket filter som er aktivt
const [statusFilter, setStatusFilter] = useState('alle');
const [editForm, setEditForm] = useState({
scrape_status_url: '',
scrape_status_selector: '',
scrape_method: '',
ai_instruction: '',
courses: [] as any[]
});
const [isSaving, setIsSaving] = useState(false);
const fetchFacilities = () => {
fetch(`${API_URL}/facilities`)
.then(res => res.json())
.then(data => {
setFacilities(Array.isArray(data) ? data : []);
setLoading(false);
})
.catch(() => setLoading(false));
};
useEffect(() => {
fetchFacilities();
}, []);
useEffect(() => {
let interval: NodeJS.Timeout;
if (isScraping) {
interval = setInterval(() => {
fetchFacilities();
}, 10000);
}
return () => clearInterval(interval);
}, [isScraping]);
// Filtreringslogikken som kjører automatisk når facilities eller filteret endres
const filteredFacilities = useMemo(() => {
if (statusFilter === 'alle') return facilities;
return facilities.map(facility => {
if (!facility.course_statuses) return facility;
// Filtrer banene innad i hvert anlegg
const filteredCourses = facility.course_statuses.filter((cs: any) => {
const s = cs.status || 'ukjent';
if (statusFilter === 'aapne') {
return s === 'aapen';
}
if (statusFilter === 'ikke_stengt') {
return ['aapen', 'aapen_med_vintergreener', 'aapner_snart'].includes(s);
}
if (statusFilter === 'stengt') {
return s === 'stengt' || s === 'nedlagt';
}
if (statusFilter === 'ukjent_feil') {
return s === 'ukjent' || s === 'NOT_FOUND';
}
return true;
});
// Returner anlegget kun med de banene som matcher
return { ...facility, course_statuses: filteredCourses };
}).filter(facility => facility.course_statuses && facility.course_statuses.length > 0);
}, [facilities, statusFilter]);
// "Velg alle" gjelder kun de anleggene som er synlige i filteret
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedFacilities(filteredFacilities.map(f => f.id));
} else {
setSelectedFacilities([]);
}
};
const handleSelectOne = (id: number, checked: boolean) => {
if (checked) {
setSelectedFacilities([...selectedFacilities, id]);
} else {
setSelectedFacilities(selectedFacilities.filter(facilityId => facilityId !== id));
}
};
const handleRunScrapers = async () => {
if (isScraping) {
setIsScraping(false);
return;
}
setIsScraping(true);
try {
const response = await fetch(`${API_URL}/admin/run-scraper`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ facility_ids: selectedFacilities })
});
if (!response.ok) throw new Error("Kunne ikke starte skraping");
const timeoutMs = Math.max(selectedFacilities.length * 40 * 1000, 60000);
setSelectedFacilities([]);
setTimeout(() => setIsScraping(false), timeoutMs);
} catch (error) {
console.error(error);
alert("Feil ved start av skraperen.");
setIsScraping(false);
}
};
const openEditModal = (facility: any) => {
setEditingFacility(facility);
setEditForm({
scrape_status_url: facility.scrape_status_url || '',
scrape_status_selector: facility.scrape_status_selector || '',
scrape_method: facility.scrape_method || 'css_selector',
ai_instruction: facility.ai_instruction || '',
courses: facility.course_statuses ? facility.course_statuses.map((c: any) => ({id: c.id, name: c.name, status: c.status})) : []
});
};
const handleSaveEdit = async () => {
setIsSaving(true);
try {
const response = await fetch(`${API_URL}/admin/facilities/${editingFacility.id}/scrape-settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm)
});
if (!response.ok) throw new Error("Feil ved lagring");
setEditingFacility(null);
fetchFacilities();
} catch (error) {
alert("Kunne ikke lagre endringene.");
console.error(error);
} finally {
setIsSaving(false);
}
};
if (loading) return <div className="p-20 text-center font-black animate-pulse">LASTER DASHBORD...</div>;
return (
<div className="flex min-h-screen bg-[#f1f7ed] font-sans relative overflow-hidden">
{/* REDIGER-MODAL FOR SKRAPING */}
{editingFacility && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]">
<div className="bg-[#11280f] text-white p-6 shrink-0">
<h3 className="text-xl font-black uppercase tracking-widest">Skrape-innstillinger</h3>
<p className="text-sm text-[#7ca982]">{editingFacility.name}</p>
</div>
<div className="p-8 space-y-6 overflow-y-auto flex-grow">
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Scrape URL</label>
<input
type="text"
value={editForm.scrape_status_url}
onChange={(e) => setEditForm({...editForm, scrape_status_url: e.target.value})}
className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors"
placeholder="f.eks. https://golfklubb.no/banestatus"
/>
</div>
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Skrapemetode</label>
<select
value={editForm.scrape_method}
onChange={(e) => setEditForm({...editForm, scrape_method: e.target.value})}
className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors"
>
<option value="css_selector">Standard (CSS)</option>
<option value="llm_parse">✨ Gemini AI (LLM)</option>
<option value="iframe_golfbox">Golfbox iframe</option>
<option value="click_then_css">Auto-klikk + CSS</option>
<option value="manual">🚨 Manuell (Ikke skrap)</option>
</select>
</div>
{editForm.scrape_method === 'llm_parse' && (
<div className="animate-fade-in">
<label className="block text-xs font-bold text-[#8bc34a] uppercase tracking-widest mb-2">✨ AI-Hviskeren (Instruks til Gemini)</label>
<textarea
value={editForm.ai_instruction || ''}
onChange={(e) => setEditForm({...editForm, ai_instruction: e.target.value})}
className="w-full border-2 border-[#8bc34a]/30 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors"
placeholder="F.eks: Ignorer info om korthullsbanen. Banen er åpen."
rows={3}
/>
<p className="text-[10px] text-gray-400 mt-1">Hjelper Gemini hvis nettsiden er forvirrende.</p>
</div>
)}
{editForm.scrape_method === 'manual' && (
<div className="bg-red-50 border border-red-100 rounded-xl p-4 animate-fade-in">
<label className="block text-xs font-black text-red-500 uppercase tracking-widest mb-4">🚨 Sett Status Manuelt</label>
<div className="space-y-4">
{editForm.courses.map((course: any, idx: number) => (
<div key={course.id} className="flex justify-between items-center bg-white p-3 rounded-lg shadow-sm">
<span className="text-xs font-bold text-gray-700 uppercase tracking-widest truncate mr-2" title={course.name}>{course.name}</span>
<select
value={course.status || 'ukjent'}
onChange={(e) => {
const newCourses = [...editForm.courses];
newCourses[idx].status = e.target.value;
setEditForm({...editForm, courses: newCourses});
}}
className="border border-gray-200 rounded-lg p-2 text-xs font-bold focus:outline-none focus:border-red-400 shrink-0"
>
<option value="aapen">🟢 Åpen</option>
<option value="aapen_med_vintergreener">🟡 Vintergreener</option>
<option value="aapner_snart">🟡 Åpner Snart</option>
<option value="stengt">🔴 Stengt</option>
<option value="stenger_snart">🔴 Stenger Snart</option>
<option value="under_utvikling">🔨 Under Utvikling</option>
<option value="nedlagt">⚫ Nedlagt</option>
<option value="ukjent">⚪ Ukjent</option>
</select>
</div>
))}
</div>
</div>
)}
{(editForm.scrape_method === 'css_selector' || editForm.scrape_method === 'click_then_css' || editForm.scrape_method === 'iframe_golfbox') && (
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">CSS Selector</label>
<input
type="text"
value={editForm.scrape_status_selector}
onChange={(e) => setEditForm({...editForm, scrape_status_selector: e.target.value})}
className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors font-mono"
placeholder="f.eks. .status-text"
/>
</div>
)}
</div>
<div className="bg-gray-50 p-6 flex justify-end gap-4 shrink-0">
<button onClick={() => setEditingFacility(null)} className="px-6 py-3 rounded-xl text-xs font-bold uppercase tracking-widest text-gray-500 hover:bg-gray-200 transition-colors">Avbryt</button>
<button onClick={handleSaveEdit} disabled={isSaving} className="bg-[#8bc34a] text-white px-6 py-3 rounded-xl text-xs font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50">
{isSaving ? 'Lagrer...' : 'Lagre endringer'}
</button>
</div>
</div>
</div>
)}
{/* SIDEBAR */}
<aside className={`bg-[#11280f] text-white flex flex-col transition-all duration-300 shrink-0 ${isSidebarCollapsed ? 'w-16 p-4' : 'w-64 p-8'} hidden md:flex`}>
<div className={`flex items-center mb-10 ${isSidebarCollapsed ? 'justify-center' : 'justify-between'}`}>
{!isSidebarCollapsed && <h1 className="text-2xl font-black uppercase tracking-tighter">TeeOff</h1>}
<button onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)} className="text-2xl hover:text-[#8bc34a] transition-colors" title="Skjul/Vis meny">
</button>
</div>
<nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow">
<div className={`text-white border-l-4 border-[#8bc34a] py-1 ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4'}`} title="Scraping Monitor">
{isSidebarCollapsed ? 'SM' : 'Scraping Monitor'}
</div>
<Link href="/admin/medlemskap" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Medlemskap">
{isSidebarCollapsed ? 'M' : 'Medlemskap'}
</Link>
<div className={`hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Bildegalleri">
{isSidebarCollapsed ? 'B' : 'Bildegalleri'}
</div>
</nav>
<div className={`mt-auto pt-8 border-t border-white/10 ${isSidebarCollapsed ? 'text-center' : ''}`}>
<button onClick={() => window.location.href='/'} className={`text-[10px] font-black uppercase tracking-widest text-red-400 hover:text-red-300 ${isSidebarCollapsed ? 'writing-vertical' : ''}`} title="Logg ut">
{isSidebarCollapsed ? 'UT' : 'Logg ut'}
</button>
</div>
</aside>
{/* HOVEDINNHOLD */}
<main className="flex-1 min-w-0 p-4 md:p-8 lg:p-10 h-screen overflow-y-auto">
<div className="md:hidden flex justify-between items-center mb-6 bg-[#11280f] text-white p-4 rounded-2xl">
<h1 className="text-xl font-black uppercase tracking-tighter">TeeOff Admin</h1>
<span className="text-xs font-bold text-[#8bc34a]">MONITOR</span>
</div>
<div className="bg-white rounded-[2rem] shadow-2xl p-6 lg:p-10 border border-white">
<header className="flex flex-col xl:flex-row justify-between items-start xl:items-center gap-6 mb-6">
<div>
<h2 className="text-3xl md:text-4xl font-black tracking-tighter text-[#11280f] mb-2">Scraping Monitor</h2>
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Sjekker status på {filteredFacilities.length} anlegg (av {facilities.length})</p>
</div>
<button
onClick={handleRunScrapers}
disabled={selectedFacilities.length === 0 && !isScraping}
className={`text-white px-6 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl transition-all whitespace-nowrap
${isScraping
? 'bg-yellow-500 animate-pulse cursor-pointer hover:bg-yellow-600'
: 'bg-[#8bc34a] hover:scale-105 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed'
}`}
>
{isScraping ? '🤖 Skraper... Klikk for å avslutte' : `Kjør valgte skrapere (${selectedFacilities.length})`}
</button>
</header>
<div className="flex flex-wrap items-center gap-4 bg-gray-50 p-4 rounded-2xl border border-gray-100 mb-8">
<label htmlFor="statusFilter" className="text-xs font-bold text-gray-500 uppercase tracking-widest">Filtrer på status:</label>
<select
id="statusFilter"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="border-2 border-gray-200 rounded-xl p-2 text-sm font-bold text-[#11280f] focus:border-[#8bc34a] focus:outline-none transition-colors cursor-pointer"
>
<option value="alle">Vis alle anlegg</option>
<option value="aapne">🟢 Kun åpne baner</option>
<option value="ikke_stengt">🟡 Ikke stengt (Åpne/Vintergreen/Snart)</option>
<option value="stengt">🔴 Kun stengte baner</option>
<option value="ukjent_feil">⚪ Ukjent / Skrapefeil (Krever tilsyn)</option>
</select>
</div>
<div className="overflow-x-auto pb-4">
<table className="w-full text-left border-collapse min-w-[800px]">
<thead>
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-300 border-b border-gray-50">
<th className="pb-4 pl-4 w-10">
<input
type="checkbox"
className="w-4 h-4 cursor-pointer accent-[#8bc34a]"
checked={selectedFacilities.length === filteredFacilities.length && filteredFacilities.length > 0}
onChange={handleSelectAll}
/>
</th>
<th className="pb-4 w-12 text-center">ID</th>
<th className="pb-4 pr-6">Anlegg</th>
<th className="pb-4">Konfigurasjon</th>
<th className="pb-4">Metode</th>
<th className="pb-4">Siste Sjekk</th>
<th className="pb-4">Banestatus</th>
<th className="pb-4 text-right pr-4">Handling</th>
</tr>
</thead>
<tbody className="text-sm font-bold text-[#11280f]">
{filteredFacilities.map((f: any) => (
<tr key={f.id} className="border-b border-gray-50 group hover:bg-gray-50/50 transition-colors">
<td className="py-6 pl-4 w-10">
<input
type="checkbox"
className="w-4 h-4 cursor-pointer accent-[#8bc34a]"
checked={selectedFacilities.includes(f.id)}
onChange={(e) => handleSelectOne(f.id, e.target.checked)}
/>
</td>
<td className="py-6 text-center text-xs font-mono text-gray-400">
#{f.id}
</td>
<td className="py-6 pr-6">
<div className="font-black text-base md:text-lg whitespace-nowrap">{f.name}</div>
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
</td>
<td className="py-6 pr-4">
<div className="text-[10px] text-blue-600 truncate max-w-[150px] mb-1">
{f.scrape_status_url ? f.scrape_status_url : <span className="text-red-400 italic">Mangler URL</span>}
</div>
<div className="text-[9px] font-mono text-gray-300 truncate max-w-[150px]">{f.scrape_status_selector}</div>
</td>
<td className="py-6 pr-4">
<ScrapeMethodSelect facility={f} />
</td>
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">
{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
</td>
<td className="py-6 pr-4">
<div className="flex flex-col gap-1">
{f.course_statuses && f.course_statuses.map((cs: any, idx: number) => {
let badgeColor = "bg-gray-100 text-gray-500";
if (cs.status === "aapen") badgeColor = "bg-green-100 text-green-700";
if (cs.status === "stengt" || cs.status === "nedlagt") badgeColor = "bg-red-100 text-red-700";
if (cs.status === "aapen_med_vintergreener" || cs.status === "aapner_snart") badgeColor = "bg-yellow-100 text-yellow-700";
return (
<div key={idx} className="flex items-center gap-2">
<span className="text-[9px] uppercase tracking-widest text-gray-400 truncate max-w-[80px]" title={cs.name}>
{cs.name}
</span>
<span className={`px-2 py-0.5 rounded-md text-[9px] font-black uppercase tracking-widest whitespace-nowrap ${badgeColor}`}>
{cs.status || 'UKJENT'}
</span>
</div>
)
})}
</div>
</td>
<td className="py-6 text-right pr-4">
<div className="flex flex-col gap-2 items-end">
<button
onClick={() => openEditModal(f)}
className="bg-gray-100 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-[#11280f] hover:bg-gray-200 transition-all whitespace-nowrap"
>
Skraper
</button>
<Link
href={`/admin/rediger/${f.slug}`}
className="bg-[#11280f] px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-white hover:bg-[#8bc34a] transition-all whitespace-nowrap text-center"
>
Rediger alt
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</main>
</div>
);
}

View file

@ -0,0 +1,616 @@
"use client";
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
// KOMPONENT 1: MultiSelect for samarbeidende klubber
const MultiSelect = ({ label, options, selected, onChange }: { label: string, options: any[], selected: string[], onChange: (s: string[]) => void }) => {
const toggle = (val: string) => {
if (selected.includes(val)) onChange(selected.filter(x => x !== val));
else onChange([...selected, val]);
};
return (
<div className="flex flex-col gap-2 mb-8 col-span-1 md:col-span-2">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">{label}</label>
<div className="p-4 rounded-2xl border-2 border-gray-300 bg-white shadow-sm max-h-64 overflow-y-auto grid grid-cols-1 md:grid-cols-2 gap-2">
{options.map(opt => (
<label key={opt.slug} className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded-lg cursor-pointer border border-transparent hover:border-gray-200 transition-all">
<input type="checkbox" checked={selected.includes(opt.slug)} onChange={() => toggle(opt.slug)} className="w-5 h-5 accent-[#8bc34a]" />
<span className="text-sm font-bold text-gray-700">{opt.name}</span>
</label>
))}
</div>
</div>
);
};
// KOMPONENT 2: Viser flate JSON-objekter (som fasiliteter) som rader med Nøkkel og Verdi
const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any, onChange: (v: any) => void }) => {
const entries = Object.entries(value || {});
const updateKey = (oldKey: string, newKey: string, val: any) => {
const newObj: any = {};
for (const [k, v] of entries) {
if (k === oldKey) {
if (newKey.trim()) newObj[newKey] = val;
} else {
newObj[k] = v;
}
}
onChange(newObj);
};
const updateVal = (key: string, val: string) => {
onChange({ ...value, [key]: val });
};
const removeKey = (key: string) => {
const newObj = { ...value };
delete newObj[key];
onChange(newObj);
};
const addRow = () => {
const tempKey = `ny_rad_${Date.now()}`;
onChange({ ...value, [tempKey]: "" });
};
return (
<div className="flex flex-col gap-4 mb-8 bg-gray-100 p-6 md:p-8 rounded-[2rem] border border-gray-200 shadow-sm">
<label className="text-sm font-black uppercase tracking-widest text-[#11280f]">{label}</label>
<div className="space-y-3">
{entries.map(([k, v]) => (
<div key={k} className="flex gap-3 items-center">
<input
className="w-1/3 p-4 rounded-xl border-2 border-gray-300 text-sm font-bold text-black bg-white focus:border-[#8bc34a] outline-none shadow-sm"
placeholder="Nøkkel (f.eks proshop)"
defaultValue={k.startsWith('ny_rad_') ? '' : k}
onBlur={e => updateKey(k, e.target.value, v)}
/>
<input
className="w-full p-4 rounded-xl border-2 border-gray-300 text-base font-medium text-black bg-white focus:border-[#8bc34a] outline-none shadow-sm"
placeholder="Verdi (f.eks Ja, eller et navn)"
value={String(v)}
onChange={e => updateVal(k, e.target.value)}
/>
<button onClick={() => removeKey(k)} className="p-4 bg-red-100 text-red-700 hover:bg-red-200 hover:text-red-900 rounded-xl font-black text-lg transition-colors border border-red-200">✕</button>
</div>
))}
</div>
<button onClick={addRow} className="mt-2 text-left text-sm font-black text-[#8bc34a] hover:text-[#11280f] transition-colors bg-white px-6 py-3 rounded-xl border-2 border-[#8bc34a] self-start">+ Legg til ny rad</button>
</div>
);
};
// KOMPONENT 3: Viser Arrays med objekter (som Greenfee-lister) som små pene kort
const ListObjectEditor = ({ label, value, templateKeys, onChange }: { label: string, value: any[], templateKeys: string[], onChange: (v: any[]) => void }) => {
const items = Array.isArray(value) ? value : [];
const updateField = (index: number, key: string, val: string | number) => {
const newItems = [...items];
const parsedVal = (!isNaN(Number(val)) && val !== "") ? Number(val) : val;
newItems[index] = { ...newItems[index], [key]: parsedVal };
onChange(newItems);
};
const addRow = () => {
const newItem: any = {};
templateKeys.forEach(k => newItem[k] = "");
onChange([...items, newItem]);
};
const removeRow = (index: number) => {
const newItems = items.filter((_, i) => i !== index);
onChange(newItems);
};
return (
<div className="flex flex-col gap-4 mb-8 bg-gray-100 p-6 md:p-8 rounded-[2rem] border border-gray-200 shadow-sm">
<label className="text-sm font-black uppercase tracking-widest text-[#11280f]">{label}</label>
<div className="space-y-6">
{items.map((item, idx) => (
<div key={idx} className="flex flex-col bg-white p-6 rounded-2xl border-2 border-gray-300 shadow-sm relative group hover:border-[#8bc34a] transition-colors">
<button onClick={() => removeRow(idx)} className="absolute top-4 right-4 w-8 h-8 flex items-center justify-center bg-red-100 text-red-700 hover:bg-red-200 hover:text-red-900 rounded-full text-sm font-black transition-colors border border-red-200">✕</button>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pr-10">
{templateKeys.map(key => (
<div key={key} className="flex flex-col gap-2">
<label className="text-xs uppercase font-black text-gray-600 tracking-wider">{key.replace(/_/g, ' ')}</label>
<input
className="p-3 rounded-lg border-2 border-gray-300 text-base font-bold text-black bg-gray-50 focus:bg-white focus:border-[#8bc34a] outline-none transition-colors"
value={item[key] || ""}
onChange={e => updateField(idx, key, e.target.value)}
/>
</div>
))}
</div>
</div>
))}
</div>
<button onClick={addRow} className="mt-2 text-left text-sm font-black text-[#8bc34a] hover:text-[#11280f] transition-colors bg-white px-6 py-3 rounded-xl border-2 border-[#8bc34a] self-start">+ Legg til nytt element</button>
</div>
);
};
// KOMPONENT 4: DEN NYE SCOREKORT-BYGGEREN
const ScorecardBuilder = ({ course, onChange }: { course: any, onChange: (c: any) => void }) => {
const ALL_KEYS = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest'];
const [holes, setHoles] = useState<any[]>(() => {
const h = course.holes || [];
if (h.length === 0) {
return Array.from({length: 18}, (_, i) => ({ hole_number: i+1, par: '', hcp_index: '', lengths: {} }));
}
return h.sort((a: any, b: any) => a.hole_number - b.hole_number);
});
const [activeKeys, setActiveKeys] = useState<string[]>(() => {
const keys = new Set<string>();
holes.forEach(h => {
if (h.lengths) Object.keys(h.lengths).forEach(k => keys.add(k));
});
return ALL_KEYS.filter(k => keys.has(k));
});
const [tees, setTees] = useState<any>(() => {
const herrer = course.tee_boxes?.herrer || [];
const damer = course.tee_boxes?.damer || [];
const initialTees = { herrer: {} as any, damer: {} as any };
activeKeys.forEach((key, idx) => {
initialTees.herrer[key] = herrer[idx] || { navn_utslag: '', baneverdi: '', slopeverdi: '' };
initialTees.damer[key] = damer[idx] || { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' };
});
return initialTees;
});
const syncToParent = (newHoles: any[], newKeys: string[], newTees: any) => {
const updatedTeeBoxes = {
herrer: newKeys.map(k => newTees.herrer[k] || {}),
damer: newKeys.map(k => newTees.damer[k] || {})
};
onChange({
...course,
holes: newHoles,
tee_boxes: updatedTeeBoxes
});
};
const toggleKey = (key: string) => {
const newKeys = activeKeys.includes(key)
? activeKeys.filter(k => k !== key)
: ALL_KEYS.filter(k => activeKeys.includes(k) || k === key);
setActiveKeys(newKeys);
const newTees = { ...tees };
if (!newTees.herrer[key]) newTees.herrer[key] = { navn_utslag: '', baneverdi: '', slopeverdi: '' };
if (!newTees.damer[key]) newTees.damer[key] = { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' };
setTees(newTees);
syncToParent(holes, newKeys, newTees);
};
const updateTee = (gender: 'herrer'|'damer', key: string, field: string, value: string) => {
const newTees = { ...tees };
newTees[gender][key] = { ...newTees[gender][key], [field]: value };
setTees(newTees);
syncToParent(holes, activeKeys, newTees);
};
const updateHole = (index: number, field: string, value: string, lengthKey: string | null = null) => {
const newHoles = [...holes];
if (lengthKey) {
newHoles[index].lengths = { ...newHoles[index].lengths, [lengthKey]: value === '' ? '' : Number(value) };
} else {
newHoles[index][field] = value === '' ? '' : Number(value);
}
setHoles(newHoles);
syncToParent(newHoles, activeKeys, tees);
};
const addHole = () => {
const newHoles = [...holes, { hole_number: holes.length + 1, par: '', hcp_index: '', lengths: {} }];
setHoles(newHoles);
syncToParent(newHoles, activeKeys, tees);
};
const removeLastHole = () => {
const newHoles = holes.slice(0, -1);
setHoles(newHoles);
syncToParent(newHoles, activeKeys, tees);
};
return (
<div className="flex flex-col gap-4 mt-6">
<div className="flex flex-wrap gap-4 items-center bg-gray-100 p-4 rounded-xl border-2 border-gray-200">
<span className="text-xs font-black uppercase tracking-widest text-gray-600">Aktive Utslagskolonner:</span>
{ALL_KEYS.map(k => (
<label key={k} className="flex items-center gap-2 text-sm font-bold cursor-pointer text-black">
<input
type="checkbox"
checked={activeKeys.includes(k)}
onChange={() => toggleKey(k)}
className="w-5 h-5 accent-[#8bc34a]"
/>
{k.toUpperCase()}
</label>
))}
</div>
<div className="overflow-x-auto rounded-2xl border-2 border-gray-300 shadow-sm bg-white pb-2">
<table className="w-full text-center text-sm min-w-[800px] border-collapse">
<thead>
<tr className="bg-gray-100 text-gray-700 text-xs font-black uppercase tracking-widest border-b-2 border-gray-300">
<th className="p-3 border-r border-gray-200">Hull</th>
<th className="p-3 border-r border-gray-200">Par</th>
<th className="p-3 border-r border-gray-300">HCP</th>
{activeKeys.map(k => <th key={k} className="p-3 border-r border-gray-300 w-32">{k}</th>)}
</tr>
{/* Herrer */}
<tr className="bg-blue-50 border-b border-gray-300">
<th colSpan={3} className="p-3 text-right text-[10px] font-black text-blue-900 uppercase tracking-widest border-r border-gray-300">
Herrer (Navn / CR / Slope)
</th>
{activeKeys.map(k => (
<td key={k} className="p-2 border-r border-gray-300 align-top">
<div className="flex flex-col gap-1">
<input placeholder="Eks: Gul" className="w-full p-2 text-xs font-bold text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.navn_utslag || ''} onChange={e => updateTee('herrer', k, 'navn_utslag', e.target.value)} />
<div className="flex gap-1">
<input placeholder="CR" className="w-1/2 p-2 text-xs text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.baneverdi || ''} onChange={e => updateTee('herrer', k, 'baneverdi', e.target.value)} />
<input placeholder="Slope" className="w-1/2 p-2 text-xs text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.slopeverdi || ''} onChange={e => updateTee('herrer', k, 'slopeverdi', e.target.value)} />
</div>
</div>
</td>
))}
</tr>
{/* Damer */}
<tr className="bg-red-50 border-b-4 border-gray-400">
<th colSpan={3} className="p-3 text-right text-[10px] font-black text-red-900 uppercase tracking-widest border-r border-gray-300">
Damer (Navn / CR / Slope)
</th>
{activeKeys.map(k => (
<td key={k} className="p-2 border-r border-gray-300 align-top">
<div className="flex flex-col gap-1">
<input placeholder="Eks: Rød" className="w-full p-2 text-xs font-bold text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.navn_utslag_damer || ''} onChange={e => updateTee('damer', k, 'navn_utslag_damer', e.target.value)} />
<div className="flex gap-1">
<input placeholder="CR" className="w-1/2 p-2 text-xs text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.baneverdi_damer || ''} onChange={e => updateTee('damer', k, 'baneverdi_damer', e.target.value)} />
<input placeholder="Slope" className="w-1/2 p-2 text-xs text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.slopeverdi_damer || ''} onChange={e => updateTee('damer', k, 'slopeverdi_damer', e.target.value)} />
</div>
</div>
</td>
))}
</tr>
</thead>
<tbody>
{holes.map((h, idx) => (
<tr key={idx} className="border-b border-gray-200 hover:bg-gray-50">
<td className="p-2 font-black text-lg text-gray-800 border-r border-gray-200">{h.hole_number}</td>
<td className="p-2 border-r border-gray-200"><input type="number" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.par || ''} onChange={e => updateHole(idx, 'par', e.target.value)} /></td>
<td className="p-2 border-r border-gray-300"><input type="number" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.hcp_index || ''} onChange={e => updateHole(idx, 'hcp_index', e.target.value)} /></td>
{activeKeys.map(k => (
<td key={k} className="p-2 border-r border-gray-300 bg-gray-50/50">
<input type="number" placeholder="Lengde" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-mono font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.lengths?.[k] || ''} onChange={e => updateHole(idx, 'lengths', e.target.value, k)} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex gap-4 px-2">
<button onClick={addHole} className="text-sm font-black text-[#8bc34a] hover:text-[#11280f] px-4 py-2 border-2 border-[#8bc34a] rounded-xl">+ Legg til hull</button>
<button onClick={removeLastHole} className="text-sm font-black text-red-500 hover:text-red-700 px-4 py-2 border-2 border-red-500 rounded-xl">- Slett siste hull</button>
</div>
</div>
);
};
export default function EditFacilityClient({ initialData, allFacilities }: { initialData: any, allFacilities: any[] }) {
const router = useRouter();
const [formData, setFormData] = useState(initialData);
const [activeTab, setActiveTab] = useState('generelt');
const [saving, setSaving] = useState(false);
// Trekk ut unike arkitekter fra alle anlegg
const uniqueArchitects = Array.from(new Set(allFacilities.map(f => f.architect).filter(Boolean))).sort();
// Sørg for at cooperating_clubs er et array
const [coopClubs, setCoopClubs] = useState<string[]>(
Array.isArray(initialData.cooperating_clubs) ? initialData.cooperating_clubs :
(typeof initialData.cooperating_clubs === 'string' ? JSON.parse(initialData.cooperating_clubs) : [])
);
const handleChange = (field: string, value: any) => {
setFormData((prev: any) => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch(`/api/admin/facilities/${initialData.id}/full`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (res.ok) {
alert("Lagret suksessfullt!");
router.refresh();
} else {
alert("Noe gikk galt under lagring.");
}
} catch (e) {
alert("Nettverksfeil.");
}
setSaving(false);
};
const tabs = [
{ id: 'generelt', label: 'Generelt' },
{ id: 'lokasjon', label: 'Lokasjon & Kontakt' },
{ id: 'linker', label: 'Lenker & Media' },
{ id: 'okonomi', label: 'Økonomi & Medlemskap' },
{ id: 'baner', label: 'Baner & Scorekort' }
];
const Input = ({ field, label, type = "text" }: { field: string, label: string, type?: string }) => {
// Håndter dato-formatet (YYYY-MM-DD) slik at HTML5 date picker forstår det
let displayValue = formData[field] || "";
if (type === 'date' && displayValue) {
displayValue = displayValue.split('T')[0]; // Kutter vekk klokkeslettet
}
return (
<div className="flex flex-col gap-2 mb-8">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">{label}</label>
{type === 'textarea' ? (
<textarea
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base shadow-sm focus:border-[#8bc34a] outline-none transition-all"
rows={4}
value={displayValue}
onChange={e => handleChange(field, e.target.value)}
/>
) : (
<input
type={type}
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none transition-all"
value={displayValue}
onChange={e => handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)}
/>
)}
</div>
);
};
return (
<div className="max-w-[1400px] mx-auto p-4 md:p-8 relative z-40 bg-white min-h-screen">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-10 pb-6 border-b border-gray-200 gap-6">
<div>
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
<h1 className="text-4xl font-black text-[#11280f]">Rediger: <span className="text-[#8bc34a]">{initialData.name}</span></h1>
</div>
<button
onClick={handleSave}
disabled={saving}
className="bg-[#11280f] text-white px-8 py-4 rounded-full font-black uppercase tracking-widest hover:bg-[#8bc34a] transition-colors shadow-xl disabled:opacity-50 w-full md:w-auto"
>
{saving ? "Lagrer..." : "Lagre endringer"}
</button>
</div>
<div className="flex flex-col md:flex-row gap-10">
{/* SIDEBAR MENY */}
<div className="w-full md:w-1/4 flex flex-col gap-3">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`p-4 rounded-2xl text-left font-black uppercase text-sm tracking-widest transition-all ${activeTab === tab.id ? 'bg-[#8bc34a] text-white shadow-lg translate-x-2' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{tab.label}
</button>
))}
</div>
{/* SKJEMA OMRÅDE */}
<div className="w-full md:w-3/4">
{activeTab === 'generelt' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
<div className="col-span-1 md:col-span-2"><Input field="name" label="Anleggsnavn" /></div>
{/* NYTT: Viktig beskjed / Kursiv intro */}
<div className="col-span-1 md:col-span-2"><Input field="footnote" label="Viktig beskjed (Kursiv intro-tekst over beskrivelsen)" type="textarea" /></div>
<div className="col-span-1 md:col-span-2"><Input field="description" label="Hovedbeskrivelse" type="textarea" /></div>
<Input field="banetype" label="Banetype (f.eks Park/Skog)" />
<Input field="season" label="Sesong (f.eks April-Oktober)" />
<Input field="established_year" label="Byggeår" type="number" />
{/* NYTT: Arkitekt med forslag */}
<div className="flex flex-col gap-2 mb-8">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Arkitekt</label>
<input
list="architect-list"
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none transition-all"
value={formData.architect || ""}
onChange={e => handleChange('architect', e.target.value)}
placeholder="Velg eller skriv inn ny..."
/>
<datalist id="architect-list">
<option value="Ukjent" />
{uniqueArchitects.map((arch: any) => <option key={arch} value={arch} />)}
</datalist>
</div>
<Input field="length_meters" label="Totallengde (meter)" type="number" />
{/* NYTT: Samarbeidende klubber */}
<MultiSelect
label="Samarbeidende Klubber (Gjestespill etc.)"
options={allFacilities.filter(f => f.id !== initialData.id)}
selected={coopClubs}
onChange={(val) => {
setCoopClubs(val);
handleChange('cooperating_clubs', val);
}}
/>
</div>
)}
{activeTab === 'lokasjon' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
<div className="col-span-1 md:col-span-2"><Input field="address" label="Gateadresse" /></div>
<Input field="zipcode" label="Postnummer" />
<Input field="city" label="Poststed / By" />
<Input field="county" label="Fylke" />
<Input field="phone" label="Telefon" />
<Input field="email" label="E-post" />
<Input field="lat" label="Breddegrad (Latitude)" type="number" />
<Input field="lng" label="Lengdegrad (Longitude)" type="number" />
</div>
)}
{activeTab === 'linker' && (
<div className="flex flex-col">
<Input field="website_url" label="Nettside URL" />
<Input field="golfbox_booking_url" label="Golfbox Booking URL" />
<Input field="golfbox_tournament_url" label="Golfbox Turnering URL" />
<Input field="baneguide_url" label="Baneguide URL" />
<Input field="flyfoto_url" label="Flyfoto URL" />
<Input field="weather_url" label="Vær URL (YR)" />
<Input field="webcam_url" label="Webkamera URL" />
<Input field="video_url" label="Video URL (YouTube/Vimeo)" />
{/* NYTT: Sosiale Medier lagt inn som liste-editor her */}
<ListObjectEditor
label="Sosiale Medier (Legg inn f.eks facebook, instagram, linkedin)"
value={formData.social_links}
templateKeys={['platform', 'url']}
onChange={(v) => handleChange('social_links', v)}
/>
</div>
)}
{activeTab === 'okonomi' && (
<div className="flex flex-col">
{/* NYTT FELT FOR MANUELL DATO */}
<div className="mb-6">
<Input field="membership_updated_at" label="Sist Oppdatert (Dato for Medlemskapspriser)" type="date" />
</div>
<div className="bg-gray-100 p-6 rounded-2xl mb-8 border border-gray-200">
<h3 className="font-black uppercase tracking-widest text-gray-800 mb-6 pb-2 border-b-2 border-gray-200">Medlemskap</h3>
<Input field="navn_standard_medlemskap" label="Navn på standard medlemskap" />
<Input field="standard_medlemskap" label="Pris standard (kun tall)" type="number" />
<Input field="standard_medlemskap_kommentarer" label="Kommentar standard" />
<Input field="navn_rimeligste_alternativ" label="Navn på rimeligste" />
<Input field="rimeligste_alternativ" label="Pris rimeligste (kun tall)" type="number" />
<Input field="medlemskap_url" label="Lenke til medlemskapsside" />
</div>
<div className="bg-gray-100 p-6 rounded-2xl border border-gray-200">
<h3 className="font-black uppercase tracking-widest text-gray-800 mb-6 pb-2 border-b-2 border-gray-200">Veien til Golf (VTG)</h3>
<Input field="vtg_pris" label="Pris VTG kurs (kun tall)" type="number" />
<Input field="vtg_lenke" label="Lenke til VTG påmelding" />
</div>
{/* Tilbud (tidligere under Avansert) */}
<div className="mt-8 border-t-2 border-gray-200 pt-8">
<KeyValueEditor label="Fasiliteter (Proshop, Kafé etc.)" value={formData.amenities} onChange={(v) => handleChange('amenities', v)} />
<KeyValueEditor label="Norsk Seniorgolf (NSG)" value={formData.nsg_data} onChange={(v) => handleChange('nsg_data', v)} />
<KeyValueEditor label="Golfamore Info" value={formData.golfamore_data} onChange={(v) => handleChange('golfamore_data', v)} />
<ListObjectEditor
label="Greenfee Priser"
value={formData.greenfee}
templateKeys={['banenavn', 'priskategori', 'pris_voksne', 'pris_junior']}
onChange={(v) => handleChange('greenfee', v)}
/>
<ListObjectEditor
label="Golfpakker"
value={formData.golfpakker}
templateKeys={['navn', 'pris', 'beskrivelse']}
onChange={(v) => handleChange('golfpakker', v)}
/>
</div>
</div>
)}
{/* BANER & SCOREKORT MED NY GRAFISK BYGGER */}
{activeTab === 'baner' && (
<div className="flex flex-col gap-8">
<div className="bg-[#f1f7ed] p-6 rounded-2xl border-2 border-[#7ca982] mb-4">
<h3 className="font-black text-[#11280f] text-lg uppercase tracking-widest mb-2">Baner og Scorekort</h3>
<p className="text-sm text-gray-800 font-medium">Bruk det interaktive skjemaet under for å redigere lengder, par og utslag.</p>
</div>
{formData.courses?.map((course: any, cIdx: number) => (
<div key={course.id || cIdx} className="bg-gray-100 p-8 rounded-[2rem] border-2 border-gray-200 shadow-sm mb-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4 border-b-2 border-gray-200 pb-4">
<h4 className="text-2xl font-black text-black">{course.name}</h4>
<span className={`px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest ${course.is_main_course ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-300 text-gray-700'}`}>
{course.is_main_course ? 'Hovedbane' : 'Sekundærbane'}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
<div className="flex flex-col gap-2 mb-6">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Banenavn</label>
<input className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.name || ""} onChange={e => {
const newCourses = [...formData.courses];
newCourses[cIdx] = {...course, name: e.target.value};
handleChange('courses', newCourses);
}} />
</div>
<div className="flex flex-col gap-2 mb-6">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Status</label>
<select className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.status || "ukjent"} onChange={e => {
const newCourses = [...formData.courses];
newCourses[cIdx] = {...course, status: e.target.value};
handleChange('courses', newCourses);
}}>
<option value="aapen">🟢 Åpen</option>
<option value="aapen_med_vintergreener">🟡 Vintergreener</option>
<option value="aapner_snart">🟡 Åpner Snart</option>
<option value="stengt">🔴 Stengt</option>
<option value="nedlagt">⚫ Nedlagt</option>
<option value="ukjent">⚪ Ukjent</option>
</select>
</div>
<div className="flex flex-col gap-2 mb-6">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Total Par (Bane)</label>
<input type="number" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.par || ""} onChange={e => {
const newCourses = [...formData.courses];
newCourses[cIdx] = {...course, par: Number(e.target.value)};
handleChange('courses', newCourses);
}} />
</div>
<div className="flex flex-col gap-2 mb-6">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Utløpsdato Slope</label>
<input type="date" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.slope_valid_until ? course.slope_valid_until.split('T')[0] : ""} onChange={e => {
const newCourses = [...formData.courses];
newCourses[cIdx] = {...course, slope_valid_until: e.target.value};
handleChange('courses', newCourses);
}} />
</div>
</div>
{/* DET NYE SCOREKORTET INKLUDERES HER */}
<ScorecardBuilder
course={course}
onChange={(updatedCourse) => {
const newCourses = [...formData.courses];
newCourses[cIdx] = updatedCourse;
handleChange('courses', newCourses);
}}
/>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,20 @@
import { API_URL } from "@/config/constants";
import EditFacilityClient from "./EditFacilityClient";
export default async function EditFacilityPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
// Henter anlegget vi skal redigere
const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: 'no-store' });
const facility = await res.json();
// Henter ALLE anlegg slik at vi kan bygge lister for samarbeid og arkitekter
const allRes = await fetch(`${API_URL}/facilities`, { cache: 'no-store' });
const allFacilities = await allRes.json();
if (!facility || facility.error) {
return <div className="p-20 text-center font-bold text-2xl">Fant ikke anlegget...</div>;
}
return <EditFacilityClient initialData={facility} allFacilities={allFacilities} />;
}

View file

@ -0,0 +1,206 @@
"use client";
import { useState } from 'react';
import { STATUS_MAP } from "@/config/constants";
// Designerens definisjon av fargetemaer - Nå med kraftigere tints for kolonnene
const getTeeTheme = (label: string) => {
const name = label.toLowerCase();
if (name.includes("svart") || name.includes("black")) {
return { header: "bg-gray-900 text-white", col: "bg-gray-100", text: "text-gray-900" };
}
if (name.includes("hvit") || name.includes("white")) {
return { header: "bg-white text-gray-800 border border-gray-300", col: "bg-gray-50", text: "text-gray-700" };
}
if (name.includes("gul") || name.includes("yellow")) {
return { header: "bg-yellow-400 text-yellow-950", col: "bg-yellow-50", text: "text-yellow-900" };
}
if (name.includes("blå") || name.includes("bla") || name.includes("blue")) {
return { header: "bg-blue-600 text-white", col: "bg-blue-50", text: "text-blue-900" };
}
if (name.includes("rød") || name.includes("rod") || name.includes("red")) {
return { header: "bg-red-500 text-white", col: "bg-red-50", text: "text-red-900" };
}
if (name.includes("grønn") || name.includes("gronn") || name.includes("green")) {
return { header: "bg-emerald-500 text-white", col: "bg-emerald-50", text: "text-emerald-900" };
}
// DEFAULT: Nøytral grå for utslag med tall (f.eks "52", "45")
return { header: "bg-gray-200 text-gray-700", col: "bg-gray-100/60", text: "text-gray-600" };
};
export default function CourseDisplay({ course }: { course: any }) {
const [hcp, setHcp] = useState("15.0");
const [gender, setGender] = useState<'herrer' | 'damer'>('herrer');
const [selectedTeeIndex, setSelectedTeeIndex] = useState(0);
const allHoles = course.holes || [];
const holesOut = allHoles.filter((h: any) => h.hole_number <= 9);
const holesIn = allHoles.filter((h: any) => h.hole_number > 9);
const hasInHoles = holesIn.length > 0;
const lengthKeys = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest'];
const availableTees = course.tee_boxes?.[gender] || [];
const activeColumns = lengthKeys
.filter(k => allHoles.some((h: any) => h.lengths?.[k]))
.map((key, idx) => {
const info = availableTees[idx];
const label = info?.navn_utslag || info?.navn_utslag_damer || key.toUpperCase();
return { key, label, theme: getTeeTheme(label) };
});
// Kalkulering av SpH
const activeTee = availableTees[selectedTeeIndex];
let playingHandicap = 0;
if (activeTee && hcp) {
const exactHcp = Number(hcp.replace(',', '.'));
const slope = Number(activeTee.slopeverdi || activeTee.slopeverdi_damer || 113);
const cr = Number(String(activeTee.baneverdi || activeTee.baneverdi_damer || course.par).replace(',', '.'));
playingHandicap = Math.round((exactHcp * (slope / 113)) + (cr - course.par));
}
const getExtraStrokes = (hcpIndex: number) => {
if (!hcpIndex || isNaN(playingHandicap)) return 0;
const base = Math.floor(playingHandicap / 18);
const rem = playingHandicap % 18;
return base + (hcpIndex <= rem ? 1 : 0);
};
const sumPar = (holes: any[]) => holes.reduce((acc, h) => acc + (h.par || 0), 0);
const sumLen = (holes: any[], key: string) => holes.reduce((acc, h) => acc + (h.lengths?.[key] || 0), 0);
// Formater utløpsdato
const slopeExpiry = course.slope_valid_until
? new Date(course.slope_valid_until).toLocaleDateString('nb-NO', { year: 'numeric', month: 'short', day: 'numeric' })
: 'Ukjent';
return (
<div className="bg-white rounded-[3rem] shadow-sm border border-gray-200 overflow-hidden mb-12">
{/* HEADER / KALKULATOR */}
<div className="p-8 md:p-12 flex flex-col md:flex-row justify-between items-center gap-8 border-b border-gray-100 bg-white">
<div className="text-center md:text-left">
<h2 className="text-5xl font-black text-[#11280f] tracking-tighter">{course.name}</h2>
<p className="text-[#7ca982] font-black uppercase text-xs tracking-[0.2em] mt-2 mb-1">
Par {course.par} • {course.length_meters || '--'} meter
</p>
<p className="text-gray-400 text-[10px] font-bold uppercase tracking-widest">
Rating utløper: {slopeExpiry}
</p>
</div>
<div className="flex items-center gap-6 bg-gray-50 p-6 rounded-[2.5rem] border border-gray-100">
<div className="flex flex-col"><span className="text-[9px] font-black text-[#7ca982] uppercase ml-1">Kjønn</span>
<select value={gender} onChange={e => { setGender(e.target.value as any); setSelectedTeeIndex(0); }} className="bg-transparent text-[#11280f] font-black outline-none border-b-2 border-[#7ca982]/30 pb-1 cursor-pointer">
<option value="herrer">HERRER</option><option value="damer">DAMER</option>
</select>
</div>
<div className="flex flex-col"><span className="text-[9px] font-black text-[#7ca982] uppercase ml-1">Utslag</span>
<select value={selectedTeeIndex} onChange={e => setSelectedTeeIndex(Number(e.target.value))} className="bg-transparent text-[#11280f] font-black outline-none border-b-2 border-[#7ca982]/30 pb-1 cursor-pointer">
{availableTees.map((t: any, i: number) => (<option key={i} value={i}>{t.navn_utslag || t.navn_utslag_damer}</option>))}
</select>
</div>
<div className="flex flex-col"><span className="text-[9px] font-black text-[#7ca982] uppercase ml-1">Ditt HCP</span>
<input type="text" value={hcp} onChange={e => setHcp(e.target.value)} className="w-12 bg-transparent text-[#11280f] font-black text-center border-b-2 border-[#7ca982]/30" />
</div>
<div className="pl-6 border-l border-gray-200 text-center">
<p className="text-[9px] uppercase font-black text-[#7ca982] mb-1">SpH</p>
<p className="text-4xl font-black text-[#11280f] leading-none">{playingHandicap || 0}</p>
</div>
</div>
</div>
{/* SCOREKORT TABELL */}
<div className="overflow-x-auto">
<table className="w-full text-center border-collapse table-fixed min-w-[850px]">
<thead>
<tr className="bg-white text-[10px] text-gray-400 font-black uppercase">
<th className="w-20 p-5 text-left pl-10 border-b border-gray-100">Hull</th>
<th className="w-16 p-5 border-l border-gray-100 border-b border-gray-100">Par</th>
<th className="w-16 p-5 border-l border-gray-100 border-b border-gray-100">HCP</th>
<th className="w-24 p-5 border-l border-gray-100 border-b border-gray-100 bg-[#7ca982]/10 text-[#7ca982]">Mottatt</th>
<th className="w-24 p-5 border-l border-gray-100 border-b border-gray-100 bg-[#7ca982]/20 text-[#11280f]">Din Par</th>
{activeColumns.map((col, i) => (
<th key={i} className={`p-5 border-l border-white font-black ${col.theme.header}`}>{col.label}</th>
))}
</tr>
</thead>
<tbody className="font-bold text-[#11280f]">
{/* UT-RUNDE */}
{holesOut.map((h: any) => {
const extra = getExtraStrokes(h.hcp_index);
return (
<tr key={h.id} className="border-t border-gray-100 group hover:bg-white transition-colors">
<td className="p-4 text-left pl-10 font-black text-lg text-gray-800">{h.hole_number}</td>
<td className="p-4 border-l border-gray-100 bg-white">{h.par}</td>
<td className="p-4 border-l border-gray-100 text-gray-300 text-xs font-mono">{h.hcp_index}</td>
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/5 text-[#7ca982] font-mono">{extra > 0 ? `+${extra}` : '-'}</td>
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/10 text-lg font-mono">{h.par + extra}</td>
{activeColumns.map((col, i) => (
<td key={i} className={`p-4 border-l border-white font-mono transition-all ${col.theme.col} ${col.theme.text}`}>
{h.lengths?.[col.key] || '--'}
</td>
))}
</tr>
);
})}
{/* UT RAD */}
<tr className="bg-[#f1f7ed]/50 text-[#11280f] font-black border-y border-gray-200">
<td className="p-4 text-left pl-10 uppercase tracking-widest text-[10px] text-gray-400">Ut</td>
<td className="p-4 border-l border-gray-100">{sumPar(holesOut)}</td>
<td colSpan={3} className="border-l border-gray-100 bg-white"></td>
{activeColumns.map((col, i) => (
<td key={i} className={`p-4 border-l border-white font-mono ${col.theme.col} text-gray-900`}>{sumLen(holesOut, col.key)}</td>
))}
</tr>
{/* INN-RUNDE */}
{hasInHoles && holesIn.map((h: any) => {
const extra = getExtraStrokes(h.hcp_index);
return (
<tr key={h.id} className="border-t border-gray-100 group hover:bg-white transition-colors">
<td className="p-4 text-left pl-10 font-black text-lg text-gray-800">{h.hole_number}</td>
<td className="p-4 border-l border-gray-100 bg-white">{h.par}</td>
<td className="p-4 border-l border-gray-100 text-gray-300 text-xs font-mono">{h.hcp_index}</td>
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/5 text-[#7ca982] font-mono">{extra > 0 ? `+${extra}` : '-'}</td>
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/10 text-lg font-mono">{h.par + extra}</td>
{activeColumns.map((col, i) => (
<td key={i} className={`p-4 border-l border-white font-mono transition-all ${col.theme.col} ${col.theme.text}`}>
{h.lengths?.[col.key] || '--'}
</td>
))}
</tr>
);
})}
{/* INN RAD */}
{hasInHoles && (
<tr className="bg-[#f1f7ed]/50 text-[#11280f] font-black border-y border-gray-200">
<td className="p-4 text-left pl-10 uppercase tracking-widest text-[10px] text-gray-400">Inn</td>
<td className="p-4 border-l border-gray-100">{sumPar(holesIn)}</td>
<td colSpan={3} className="border-l border-gray-100 bg-white"></td>
{activeColumns.map((col, i) => (
<td key={i} className={`p-4 border-l border-white font-mono ${col.theme.col} text-gray-900`}>{sumLen(holesIn, col.key)}</td>
))}
</tr>
)}
{/* TOTAL RAD */}
<tr className="bg-[#11280f] text-white text-xl font-black">
<td className="p-8 text-left pl-10 uppercase tracking-tighter">Totalt</td>
<td className="p-8 border-l border-white/10">{sumPar(allHoles)}</td>
<td colSpan={3} className="border-l border-white/10 bg-[#1a3a17]"></td>
{activeColumns.map((col, i) => (
<td key={i} className={`p-8 border-l border-white/10 font-mono ${col.theme.header.split(' ')[0]}`}>
{sumLen(allHoles, col.key)}
</td>
))}
</tr>
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,435 @@
"use client";
/**
* TEE OFF DETAIL VIEW - COMPLETE v3.22
* ---------------------------------------------------------------------------
* FIX: Gjenopprettet "Turneringer" i den flytende knapperaden over bildet.
* FIX: Byttet plass på tekst og sidebar (Tekst øverst på mobil).
* FIX: Økt padding (pb-32) i Hero-teksten på mobil for å unngå krasj med knapper.
* FIX: Alle 4 kontaktpunkter i sidebar er klikkbare (tel:0047 fix inkludert).
* NEW: Sosiale Medier, Footnote og Samarbeidende klubber integrert.
* REGEL: Beholder monokrome ikoner, 22/78 layout og robust JSON-parsing.
* ---------------------------------------------------------------------------
*/
import { useState, useEffect } from 'react';
import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants";
import Link from 'next/link';
import CourseDisplay from './CourseDisplay';
const formatPhoneForUrl = (phone: string) => {
if (!phone) return "";
return phone.replace('+', '00').replace(/\s/g, '');
};
const renderValue = (val: string) => {
if (!val) return "Nei";
const hasLink = val.includes('<a');
return (
<span
className={hasLink ? "text-[#ff5722] font-bold hover:underline" : "text-[#11280f]"}
dangerouslySetInnerHTML={{ __html: val }}
/>
);
};
const Icon = ({ children, className = "w-5 h-5" }: { children: React.ReactNode, className?: string }) => (
<svg
className={`${className} flex-shrink-0 text-current`}
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ width: '20px', height: '20px' }}
>
{children}
</svg>
);
const ICONS = {
web: <><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></>,
phone: <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />,
mail: <><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" /><polyline points="22,6 12,13 2,6" /></>,
pin: <><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" /><circle cx="12" cy="10" r="3" /></>,
booking: <><path d="M3 10h18M7 15h.01M11 15h.01M15 15h.01M7 19h.01M11 19h.01M15 19h.01M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2zM16 3v4M8 3v4"/></>,
trophy: <><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22M18 2H6v7a6 6 0 0 0 12 0V2z"/></>,
guide: <><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 4.5A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1-2.5-2.5V4.5z"/></>,
camera: <><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></>,
webcam: <><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></>,
chart: <><path d="M18 20V10M12 20V4M6 20v-6"/></>,
weather: <><path d="M12 2v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="M20 12h2"/><path d="m19.07 4.93-1.41 1.41"/><path d="M15.947 12.65a4 4 0 0 0-5.925-4.128"/><path d="M13 22H7a5 5 0 1 1 4.9-6H13a3 3 0 0 1 0 6Z"/></>
};
const SOCIAL_ICONS: Record<string, React.ReactNode> = {
facebook: <path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />,
instagram: <><rect x="2" y="2" width="20" height="20" rx="5" ry="5" /><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" /><line x1="17.5" y1="6.5" x2="17.51" y2="6.5" /></>,
twitter: <path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z" />,
x: <path d="M4 4l16 16M4 20L20 4" />,
linkedin: <><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" /><rect x="2" y="9" width="4" height="12" /><circle cx="4" cy="4" r="2" /></>,
youtube: <><path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33 2.78 2.78 0 0 0 1.94 2c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.33 29 29 0 0 0-.46-5.33z" /><polygon points="9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02" /></>,
tiktok: <path d="M9 12a4 4 0 1 0 4 4V2a5 5 0 0 0 5 5h-2a3 3 0 0 1-3-3V16a2 2 0 1 1-2-2v-2z" />,
snapchat: <path d="M12 2C8.5 2 6 5 6 8.5c0 1.5.5 3 1.5 4-1 .5-2.5 1-3.5 1-.5 0-1 .5-1 1s.5 1 1.5 1h15c1 0 1.5-.5 1.5-1s-.5-1-1-1c-1 0-2.5-.5-3.5-1 1-1 1.5-2.5 1.5-4C18 5 15.5 2 12 2zm0 15c-3 0-5-1-5-1s.5 1.5 1.5 2h7C16.5 17.5 17 16 17 16s-2 1-5 1z" />
};
export default function FacilityDetailView({ facility }: { facility: any }) {
const [showBackToTop, setShowBackToTop] = useState(false);
const [currentSlide, setCurrentSlide] = useState(0);
const parseJson = (val: any, fallback: any) => {
if (!val) return fallback;
if (typeof val === 'object') return val;
try { return JSON.parse(val); } catch (e) { return fallback; }
};
const rawCourses = parseJson(facility.courses, []);
const activeCourses = Array.isArray(rawCourses) ? rawCourses.filter((c: any) => c.holes && (typeof c.holes === 'string' || c.holes.length > 0)) : [];
const amenities = parseJson(facility.amenities, {});
const galleryRaw = parseJson(facility.gallery, []);
const gallery = galleryRaw.length > 0 ? galleryRaw : [facility.image_url || FALLBACK_IMAGE];
const greenfeeRaw = parseJson(facility.greenfee, []);
const shotzoom = parseJson(facility.shotzoom, []);
const golfamoreData = parseJson(facility.golfamore_data, {});
const nsgData = parseJson(facility.nsg_data, {});
const socialLinksRaw = parseJson(facility.social_links, []);
const socialLinks = Array.isArray(socialLinksRaw) ? socialLinksRaw : [];
const coopClubsRaw = parseJson(facility.cooperating_clubs, []);
const cooperatingClubs = Array.isArray(coopClubsRaw) ? coopClubsRaw : [];
const hasGolfamore = facility.golfamore === true;
const hasNSG = facility.nsg_url || (nsgData && Object.keys(nsgData).length > 0);
const groupedGreenfee: Record<string, any[]> = greenfeeRaw.reduce((acc: any, curr: any) => {
const bane = curr.banenavn || "Gjestespill";
if (!acc[bane]) acc[bane] = [];
acc[bane].push(curr);
return acc;
}, {});
const sidebarLinkClass = "flex items-center gap-4 text-[#11280f] hover:text-[#ff5722] transition-colors group";
const resourceBtnClass = "flex justify-between items-center p-5 bg-gray-50 rounded-2xl text-[11px] font-black uppercase text-[#11280f] hover:bg-[#ff5722] hover:text-white transition-all group";
useEffect(() => {
if (gallery.length <= 1) return;
const timer = setInterval(() => setCurrentSlide((p) => (p + 1) % gallery.length), 5000);
return () => clearInterval(timer);
}, [gallery.length]);
useEffect(() => {
const handleScroll = () => setShowBackToTop(window.scrollY > 500);
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const scrollTo = (id: string) => {
const el = document.getElementById(id);
if (el) window.scrollTo({ top: el.getBoundingClientRect().top + window.pageYOffset - 80, behavior: 'smooth' });
};
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('nb-NO', { day: 'numeric', month: 'long', year: 'numeric' }) : null;
const weatherImg = facility.weather_url?.replace("/graf/dag/", "/innhold/").replace(/\/$/, "") + "/meteogram.svg";
return (
<main className="min-h-screen bg-[#f1f7ed] pb-20 relative font-sans text-[#11280f]">
{/* 1. HERO SLIDER */}
<div className="h-[55vh] min-h-[450px] relative overflow-hidden bg-[#11280f]">
{gallery.map((img: string, i: number) => (
<img key={i} src={img} className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-1000 ${i === currentSlide ? 'opacity-100 z-10' : 'opacity-0 z-0'}`} alt="" />
))}
<div className="absolute inset-0 bg-gradient-to-t from-[#11280f]/90 via-transparent to-black/10 z-20" />
{/* BANESTATUS BADGES */}
<div className="absolute top-8 right-8 z-40 flex flex-col items-end gap-2">
<div className="flex flex-wrap justify-end gap-2">
{activeCourses.map((c: any) => (
<span key={c.id} className="px-3 py-1.5 rounded-lg text-[10px] font-black uppercase bg-[#7ca982] text-white shadow-xl">
{c.name.toUpperCase()}: {STATUS_MAP[c.status] || c.status}
</span>
))}
</div>
{facility.status_updated_at && (
<span className="text-white/60 text-[10px] uppercase font-black tracking-widest bg-black/20 px-2 py-1 rounded">
Sist oppdatert: {formatDate(facility.status_updated_at)}
</span>
)}
</div>
{/* FLYTENDE HURTIGKNAPPER */}
<div className="absolute bottom-8 right-8 z-40 flex gap-2.5 bg-black/30 backdrop-blur-md p-2 rounded-2xl border border-white/10 shadow-2xl text-[#11280f]">
{facility.website_url && <a href={facility.website_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.web} /></a>}
{facility.golfbox_booking_url && <a href={facility.golfbox_booking_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.booking} /></a>}
{facility.golfbox_tournament_url && <a href={facility.golfbox_tournament_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.trophy} /></a>}
<a href={`https://www.google.com/maps/search/?api=1&query=${facility.lat},${facility.lng}`} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.pin} /></a>
{facility.weather_url && <a href={facility.weather_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.weather} /></a>}
</div>
{/* HERO TEXT */}
<div className="relative z-30 max-w-[1200px] mx-auto px-6 w-full h-full flex flex-col justify-end pb-32 md:pb-12">
{facility.logo_url && (
<div className="hidden md:block mb-8 w-24 h-24 bg-white p-2 rounded-2xl shadow-2xl border-4 border-white/20 overflow-hidden"><img src={facility.logo_url} className="w-full h-full object-contain" alt="Logo" /></div>
)}
<h1 className="text-5xl md:text-8xl font-black text-white mb-3 tracking-tighter drop-shadow-2xl">{facility.name}</h1>
<p className="text-[#7ca982] uppercase tracking-[0.4em] font-black text-xs md:text-sm pl-1">{facility.county} • {facility.city}</p>
</div>
</div>
{/* 2. STICKY NAV */}
<nav className="sticky top-0 z-50 bg-white/95 backdrop-blur-md border-b border-gray-100 shadow-sm overflow-hidden">
<div className="max-w-[1200px] mx-auto px-6 flex justify-between md:justify-start gap-4 md:gap-10 h-16 items-center text-[10px] font-black uppercase tracking-widest text-gray-400">
<button onClick={() => scrollTo('intro')}>Info</button>
<button onClick={() => scrollTo('weather')}>Vær</button>
<button onClick={() => scrollTo('details')}>Detaljer</button>
<button onClick={() => scrollTo('map')}>Kart</button>
{facility.video_url && <button onClick={() => scrollTo('video')}>Video</button>}
<button onClick={() => scrollTo('prices')}>Priser</button>
<button onClick={() => scrollTo('scorecards')}>Scorekort</button>
</div>
</nav>
<div className="max-w-[1200px] mx-auto px-0 md:px-6 space-y-4 md:space-y-12 mt-0 md:mt-12">
{/* 3. INTRO & SIDEBAR */}
<section id="intro" className="flex flex-col lg:flex-row gap-0 md:gap-8 items-stretch">
{/* HOVEDINNHOLD (78%) */}
<div className="lg:w-[78%] bg-white p-10 md:p-16 md:rounded-[3rem] shadow-sm border-b md:border-none">
{facility.footnote && (
<div className="mb-8 pb-8 border-b border-gray-50 italic text-[#ff5722] text-lg font-serif">
{facility.footnote}
</div>
)}
<div className="leading-relaxed text-lg md:text-xl text-gray-600" dangerouslySetInnerHTML={{ __html: facility.description || 'Ingen beskrivelse tilgjengelig.' }} />
</div>
{/* SIDEBAR (22%) */}
<div className="lg:w-[22%] bg-white p-10 md:rounded-[3rem] shadow-sm flex flex-col order-last lg:order-none">
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-10">Kontakt & Adresse</h3>
<div className="flex-grow space-y-7 text-sm font-bold">
<a href={facility.website_url} target="_blank" className={sidebarLinkClass}><Icon children={ICONS.web} /> Besøk nettsiden</a>
<a href={`tel:${formatPhoneForUrl(facility.phone)}`} className={sidebarLinkClass}>
<Icon children={ICONS.phone} /> {facility.phone || 'Ikke oppgitt'}
</a>
<a href={`mailto:${facility.email}`} className={sidebarLinkClass}>
<Icon children={ICONS.mail} /> <span className="truncate">{facility.email || 'Ikke oppgitt'}</span>
</a>
<div className="pt-2 border-t border-gray-50 mt-4">
<a href={`https://www.google.com/maps/search/?api=1&query=${facility.lat},${facility.lng}`} target="_blank" className={sidebarLinkClass + " pt-4 leading-tight items-start"}>
<Icon children={ICONS.pin} /> <span className="text-gray-400 group-hover:text-[#ff5722] transition-colors">{facility.address}<br/>{facility.city}</span>
</a>
</div>
</div>
{/* SOSIALE MEDIER IKONER */}
{socialLinks.length > 0 && (
<div className="pt-6 border-t border-gray-50 mt-6 flex flex-wrap gap-3">
{socialLinks.map((social: any, idx: number) => {
const platform = (social.platform || '').toLowerCase().trim();
// Finn riktig ikon, fall tilbake til en generell link-pil
const iconData = SOCIAL_ICONS[platform] || <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3" />;
return (
<a key={idx} href={social.url} target="_blank" rel="noreferrer" title={social.platform} className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center text-[#11280f] hover:bg-[#ff5722] hover:text-white transition-all shadow-sm">
<Icon children={iconData} className="w-4 h-4 text-current" />
</a>
);
})}
</div>
)}
<div className="mt-10 pt-6 border-t border-gray-50">
<Link href={`/`} className="text-[10px] font-black uppercase tracking-widest text-[#7ca982] hover:text-[#11280f] transition-all flex items-center gap-1">
Se alle baner i {facility.county} →
</Link>
</div>
</div>
</section>
{/* 4. 3-KOLONNE INFO */}
<section id="details" className="grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-8">
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm">
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter">Andre Ressurser</h3>
<div className="space-y-2.5">
{facility.golfbox_booking_url && (
<a href={facility.golfbox_booking_url} target="_blank" className={resourceBtnClass}>
<span className="flex items-center gap-3"><Icon children={ICONS.booking} className="group-hover:text-white" /> Book Starttid</span><span>→</span>
</a>
)}
{facility.golfbox_tournament_url && (
<a href={facility.golfbox_tournament_url} target="_blank" className={resourceBtnClass}>
<span className="flex items-center gap-3"><Icon children={ICONS.trophy} className="group-hover:text-white" /> Turneringer</span><span>→</span>
</a>
)}
{facility.baneguide_url && (
<a href={facility.baneguide_url} target="_blank" className={resourceBtnClass}>
<span className="flex items-center gap-3"><Icon children={ICONS.guide} className="group-hover:text-white" /> Baneguide</span><span>→</span>
</a>
)}
{facility.flyfoto_url && (
<a href={facility.flyfoto_url} target="_blank" className={resourceBtnClass}>
<span className="flex items-center gap-3"><Icon children={ICONS.camera} className="group-hover:text-white" /> Flyfoto</span><span>→</span>
</a>
)}
{facility.webcam_url && (
<a href={facility.webcam_url} target="_blank" className={resourceBtnClass}>
<span className="flex items-center gap-3"><Icon children={ICONS.webcam} className="group-hover:text-white" /> Webkamera</span><span>→</span>
</a>
)}
{shotzoom.map((sz: any, i: number) => (
<a key={i} href={sz.shotzoom_url} target="_blank" className={resourceBtnClass}>
<span className="flex items-center gap-3"><Icon children={ICONS.chart} className="group-hover:text-white" /> Statistikk: {sz.shotzoom_beskrivelse?.replace(/ ?/g, ' ').trim().toUpperCase()}</span><span>→</span>
</a>
))}
</div>
</div>
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm text-sm font-bold text-gray-700 flex flex-col">
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter text-[#11280f]">Banen</h3>
<div className="space-y-5 flex-grow">
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Hull:</span><span>{amenities.antall_hull || '--'}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Lengde:</span><span>{facility.length_meters ? `${facility.length_meters}m` : '--'}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Sesong:</span><span>{facility.season || '--'}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Byggeår:</span><span>{facility.established_year || '--'}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Banetype:</span><span>{facility.banetype || 'Park/Skog'}</span></div>
<div className="flex justify-between"><span className="text-gray-400">Arkitekt:</span><span className="text-right truncate ml-4">{facility.architect || '--'}</span></div>
</div>
</div>
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm text-sm font-bold text-gray-700">
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter text-[#11280f]">Andre Tilbud</h3>
<div className="space-y-4">
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Drivingrange:</span><span>{amenities.drivingrange || 'Nei'}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Nærspill:</span><span>{amenities.treningsgreen || 'Ja'}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Proshop:</span><span className="text-right ml-4">{renderValue(amenities.proshop)}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Kølleutleie:</span><span>{amenities.kolleutleie || 'Ja'}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Bilutleie:</span><span>{amenities.bilutleie || 'Nei'}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Simulator:</span><span className="text-right ml-4">{renderValue(amenities.simulator)}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Kafé:</span><span className="text-right ml-4">{renderValue(amenities.kafe)}</span></div>
{/* Golfamore og NSG */}
<div className="flex justify-between border-b border-gray-50 pb-2">
<span className="text-gray-400">Golfamore:</span>
<span className="text-right ml-4">
{hasGolfamore ? <span className="text-[#ff5722] font-black">{golfamoreData.gyldighet || "Ja"}</span> : "Nei"}
</span>
</div>
<div className="flex justify-between border-b border-gray-50 pb-2">
<span className="text-gray-400">Seniorgolf (NSG):</span>
<span className="text-right ml-4">
{hasNSG && facility.nsg_url
? <a href={facility.nsg_url} target="_blank" className="text-blue-600 font-black hover:underline">Ja (Vis Avtale)</a>
: (hasNSG ? <span className="text-blue-600 font-black">Ja</span> : "Nei")
}
</span>
</div>
{/* SAMARBEIDENDE KLUBBER */}
{cooperatingClubs.length > 0 && (
<div className="pt-2">
<span className="text-gray-400 block mb-2">Samarbeider med:</span>
<div className="flex flex-wrap gap-2">
{cooperatingClubs.map((slug: string) => (
<Link key={slug} href={`/golfbaner/${slug}`} className="px-3 py-1 bg-gray-100 rounded-lg text-[10px] uppercase font-black tracking-widest hover:bg-[#8bc34a] hover:text-white transition-colors">
{slug.replace('-golfklubb', '').replace(/-/g, ' ')}
</Link>
))}
</div>
</div>
)}
</div>
</div>
</section>
{/* 5. VÆR SEKSJON */}
<section id="weather" className="bg-white p-0 md:p-12 md:rounded-[3rem] shadow-sm border-b md:border-none overflow-hidden text-center">
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-[0.2em] py-8 md:py-0 md:mb-10 flex items-center justify-center gap-3"><Icon children={ICONS.weather} /> Vær for {facility.name}</h3>
<div className="w-full flex justify-center px-4 md:px-0">
{facility.weather_url ? ( <img src={weatherImg} className="w-full h-auto block max-w-5xl" alt="Vær" /> ) : <p className="text-center py-24 text-gray-300 italic text-sm">Værvarsel ikke tilgjengelig</p>}
</div>
</section>
{/* 6. KART SEKSJON */}
<section id="map" className="space-y-6">
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Kart <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-xl h-[450px] md:h-[650px] border-y-4 md:border-[12px] border-white bg-gray-100">
<iframe width="100%" height="100%" style={{ border: 0 }} src={`https://maps.google.com/maps?q=${facility.lat},${facility.lng}&t=k&z=15&ie=UTF8&iwloc=&output=embed`} allowFullScreen />
</div>
</section>
{/* 7. VIDEO SEKSJON */}
{facility.video_url && (
<section id="video" className="space-y-6">
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Video <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-2xl aspect-video bg-black border-y-4 md:border-[12px] border-white">
<iframe src={facility.video_url} className="w-full h-full" allowFullScreen />
</div>
</section>
)}
{/* 8. PRISER & GJESTESPILL */}
<section id="prices" className="grid grid-cols-1 lg:grid-cols-2 gap-0 md:gap-8">
<div className="bg-white p-10 md:p-14 md:rounded-[3rem] shadow-sm">
<h3 className="text-2xl font-black mb-10 uppercase tracking-tighter">Gjestespill</h3>
<div className="space-y-10">
{Object.keys(groupedGreenfee).length > 0 ? (
Object.entries(groupedGreenfee).map(([bane, priser], idx) => (
<div key={idx} className="space-y-4">
{!(bane === "Gjestespill" && Object.keys(groupedGreenfee).length === 1) && (
<h4 className="text-lg font-black uppercase tracking-tighter text-[#11280f] border-b-2 border-gray-50 pb-2">{bane}</h4>
)}
<div className="space-y-2">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Voksne</p>
{priser.map((g, i) => (
<div key={i} className="flex justify-between py-2 border-b border-gray-50/50 text-sm font-bold">
<span className="text-gray-500">{g.priskategori}</span>
<span>kr {g.pris_voksne || '--'},-</span>
</div>
))}
</div>
{priser.some(g => g.pris_junior) && (
<div className="space-y-2 pt-4">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Junior</p>
{priser.map((g, i) => (
<div key={i} className="flex justify-between py-2 border-b border-gray-50/50 text-sm font-bold">
<span className="text-gray-500">{g.priskategori}</span>
<span>kr {g.pris_junior || '--'},-</span>
</div>
))}
</div>
)}
</div>
))
) : <p className="text-gray-400 italic py-6">Ingen priser funnet.</p>}
</div>
<p className="mt-10 text-[10px] text-gray-300 font-black uppercase tracking-widest italic">Krav: {facility.guest_requirements || 'Klubbhandicap'}</p>
</div>
<div className="bg-white p-10 md:p-14 md:rounded-[3rem] shadow-sm flex flex-col justify-between">
<div>
<h3 className="text-2xl font-black mb-10 uppercase tracking-tighter">Medlemskap</h3>
<div className="bg-[#f1f7ed] p-12 md:rounded-[2.5rem] mb-8 text-center border border-[#7ca982]/10">
<p className="text-[#7ca982] text-[11px] font-black uppercase mb-3 tracking-widest">{facility.navn_standard_medlemskap || "Standard"}</p>
<p className="text-6xl font-black text-[#11280f]">kr {facility.standard_medlemskap || '--'},-</p>
{facility.standard_medlemskap_kommentarer && <p className="text-[10px] text-gray-400 mt-4 uppercase font-bold italic leading-tight">{facility.standard_medlemskap_kommentarer}</p>}
</div>
{facility.navn_rimeligste_alternativ && (
<div className="px-8 py-5 bg-gray-50 rounded-2xl border border-gray-100 flex justify-between items-center text-sm font-bold"><span className="text-gray-400 uppercase text-[10px] tracking-widest">{facility.navn_rimeligste_alternativ}</span><span className="text-[#11280f]">kr {facility.rimeligste_alternativ},-</span></div>
)}
</div>
<a href={facility.medlemskap_url} target="_blank" className="mt-10 block w-full text-center bg-[#11280f] text-white p-7 rounded-2xl font-black uppercase text-xs tracking-widest shadow-xl hover:bg-black transition-all">Se alle alternativer</a>
</div>
</section>
{/* 9. SCOREKORT SEKSJON */}
<section id="scorecards" className="pt-10 space-y-20 overflow-hidden">
<h3 className="text-center text-3xl md:text-5xl font-black uppercase tracking-tighter">Scorekort</h3>
<div className="w-full flex flex-col items-center gap-20">
{activeCourses.map((c: any) => (
<div key={c.id} className="w-full overflow-x-auto md:overflow-visible no-scrollbar">
<div className="min-w-[800px] md:min-w-0"><CourseDisplay course={c} /></div>
</div>
))}
</div>
</section>
</div>
{showBackToTop && (
<button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className="fixed bottom-8 right-8 w-14 h-14 bg-[#11280f] text-white rounded-full shadow-2xl flex items-center justify-center text-2xl z-[100] border-4 border-white/20 hover:scale-110 transition-all">↑</button>
)}
</main>
);
}

View file

@ -0,0 +1,17 @@
// page.tsx
import { API_URL } from "@/config/constants";
import FacilityDetailView from "./FacilityDetailView";
export default async function GolfCoursePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: 'no-store' });
const facility = await res.json();
if (!facility || facility.error) {
return <div className="p-20 text-center font-bold text-2xl">Fant ikke golfbanen...</div>;
}
// Vi sender dataene til den navngitte komponenten
return <FacilityDetailView facility={facility} />;
}

View file

@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
import Header from "@/components/Header";
export const metadata: Metadata = {
title: "TeeOff.no - Din guide til norske golfbaner",
description: "Oppdatert banestatus, priser og informasjon om alle norske golfanlegg.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="nb">
<body className="antialiased bg-[#f1f7ed]">
<Header />
{children}
</body>
</html>
);
}

View file

@ -0,0 +1,40 @@
import HeroSlider from './HeroSlider';
import FacilitySearch from './FacilitySearch';
import { API_URL } from '@/config/constants';
export const dynamic = 'force-dynamic';
export default async function Home() {
let facilities = [];
try {
const res = await fetch(`${API_URL}/facilities`, {
next: { revalidate: 0 },
cache: 'no-store'
});
if (!res.ok) {
const errorData = await res.json();
console.error("API Error Body:", errorData);
throw new Error(`API returnerte status ${res.status}`);
}
facilities = await res.json();
} catch (error) {
console.error("Kritisk feil ved henting av data:", error);
facilities = [];
}
// Sikrer at vi alltid sender en array til komponentene
const safeData = Array.isArray(facilities) ? facilities : [];
return (
<main className="min-h-screen bg-[#f1f7ed]">
{/* Wrapper slideren i en div som skjuler den på mobil (hidden) og viser den på PC (md:block) */}
<div className="hidden md:block">
<HeroSlider facilities={safeData} />
</div>
<FacilitySearch initialFacilities={safeData} />
</main>
);
}

View file

@ -0,0 +1,45 @@
"use client";
import { useState } from 'react';
import Link from 'next/link';
export default function Header() {
const [isOpen, setIsOpen] = useState(false);
return (
<header className="sticky top-0 z-[100] bg-white/95 backdrop-blur-md border-b border-gray-100 shadow-sm">
<div className="max-w-[1400px] mx-auto px-6 h-20 flex items-center justify-between">
{/* LOGO */}
<Link href="/" className="h-10 md:h-12 transition-transform hover:scale-105 active:scale-95">
<img src="/TeeOff-logo-Retina-1.png" alt="TeeOff.no" className="h-full w-auto object-contain" />
</Link>
{/* DESKTOP NAV */}
<nav className="hidden md:flex items-center gap-8 text-[11px] font-black uppercase tracking-widest text-gray-500">
<Link href="/" className="hover:text-[#8bc34a]">Hjem</Link>
<Link href="/golfbaner" className="hover:text-[#8bc34a]">Finn Bane</Link>
<Link href="/medlemskap" className="hover:text-[#8bc34a]">Medlemskap</Link>
<Link href="/om-oss" className="hover:text-[#8bc34a]">Om oss</Link>
<Link href="/admin/login" className="px-5 py-2 bg-[#ff5722] text-white rounded-xl hover:bg-black transition-all font-black uppercase text-[10px] tracking-widest">Admin</Link>
</nav>
{/* HAMBURGER (Mobil) */}
<button onClick={() => setIsOpen(!isOpen)} className="md:hidden p-2 text-[#11280f]">
<div className="w-6 h-0.5 bg-current mb-1.5 transition-all"></div>
<div className="w-6 h-0.5 bg-current mb-1.5"></div>
<div className="w-6 h-0.5 bg-current"></div>
</button>
</div>
{/* MOBIL MENY OVERLAY */}
{isOpen && (
<div className="md:hidden absolute top-20 left-0 w-full bg-white border-b border-gray-100 p-6 flex flex-col gap-6 shadow-2xl animate-in slide-in-from-top duration-300">
<Link onClick={() => setIsOpen(false)} href="/" className="text-lg font-black uppercase text-[#11280f]">Hjem</Link>
<Link onClick={() => setIsOpen(false)} href="/golfbaner" className="text-lg font-black uppercase text-[#11280f]">Finn Bane</Link>
<Link onClick={() => setIsOpen(false)} href="/medlemskap" className="text-lg font-black uppercase text-[#11280f]">Medlemskap</Link>
<Link onClick={() => setIsOpen(false)} href="/logg-inn" className="text-[#ff5722] font-black uppercase">Admin Logg inn</Link>
</div>
)}
</header>
);
}

View file

@ -0,0 +1,71 @@
"use client";
import { useState } from 'react';
// Tilpass interface til de dataene du allerede har i frontend
interface Facility {
id: number;
scrape_method?: string;
scrape_status_url?: string;
scrape_status_selector?: string;
}
export default function ScrapeMethodSelect({ facility }: { facility: Facility }) {
// Setter standardverdi til 'css_selector' hvis den er tom i databasen
const [method, setMethod] = useState(facility.scrape_method || 'css_selector');
const [isLoading, setIsLoading] = useState(false);
const [statusColor, setStatusColor] = useState('bg-transparent'); // For å gi visuell feedback
const handleMethodChange = async (newMethod: string) => {
setMethod(newMethod);
setIsLoading(true);
setStatusColor('bg-yellow-200'); // Lyser gult mens den lagrer
try {
// Husk å endre URL-en hvis API-et ditt ligger på et annet domene
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/api/admin/facilities/${facility.id}/scrape-settings`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
// Hvis du bruker JWT i headers i stedet for cookies, legg det til her:
// 'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
scrape_method: newMethod,
scrape_status_url: facility.scrape_status_url, // Beholder eksisterende
scrape_status_selector: facility.scrape_status_selector // Beholder eksisterende
})
});
if (!response.ok) {
throw new Error('Feil ved lagring');
}
// Suksess! Lyser grønt et kort sekund
setStatusColor('bg-green-300');
setTimeout(() => setStatusColor('bg-transparent'), 2000);
} catch (error) {
console.error(error);
setStatusColor('bg-red-300'); // Lyser rødt ved feil
alert("Kunne ikke oppdatere skrapemetode.");
} finally {
setIsLoading(false);
}
};
return (
<select
value={method}
onChange={(e) => handleMethodChange(e.target.value)}
disabled={isLoading}
className={`border rounded p-1 text-sm transition-colors duration-300 ${statusColor} ${isLoading ? 'opacity-50' : ''}`}
>
<option value="css_selector">Standard (CSS)</option>
<option value="llm_parse">✨ Gemini AI (LLM)</option>
<option value="iframe_golfbox">Golfbox iframe</option>
<option value="click_then_css">Auto-klikk + CSS</option>
<option value="">Ingen (Avslått)</option>
</select>
);
}

View file

@ -0,0 +1,42 @@
/**
* TEE OFF CONFIG CONSTANTS v1.3
* ---------------------------------------------------------------------------
* REGEL 1: ALDRI trunker eller fjern data fra denne filen.
* REGEL 2: Håndterer både intern Docker-kommunikasjon og ekstern browser-kommunikasjon.
* REGEL 3: Inneholder alle regionale mappinger for Norge.
* ---------------------------------------------------------------------------
*/
const isBrowser = typeof window !== 'undefined';
// Intern URL for server-to-server (Docker-internt)
const INTERNAL_API = process.env.API_URL || "http://api:8000/api";
// Relativ sti for browseren.
// Ved å bruke '/api' sørger vi for at nettleseren bruker samme protokoll (https)
// og domene (nye.teeoff.no) som resten av siden.
const EXTERNAL_API = "/api";
export const API_URL = isBrowser ? EXTERNAL_API : INTERNAL_API;
export const FALLBACK_IMAGE = "/Toppbilde-standard.jpg";
export const TEEOFF_LOGO = "/TeeOff-logo-Retina-1.png";
export const STATUS_MAP: Record<string, string> = {
"ukjent": "Ukjent status",
"aapen": "Åpen",
"aapen_med_vintergreener": "Vintergreener",
"stengt": "Stengt",
"nedlagt": "Nedlagt",
"under_utvikling": "Under utvikling",
"aapner_snart": "Åpner snart",
"stenger_snart": "Stenger snart"
};
export const REGIONS: Record<string, string[]> = {
"nord-norge": ["finnmark", "troms", "nordland"],
"midt-norge": ["nord-trøndelag", "sør-trøndelag", "trøndelag"],
"vestlandet": ["møre og romsdal", "sogn og fjordane", "hordaland", "rogaland", "vestland"],
"sørlandet": ["vest-agder", "aust-agder", "agder"],
"østlandet": ["telemark", "vestfold", "østfold", "buskerud", "hedmark", "oppland", "oslo", "akershus", "innlandet", "viken"]
};

View file

@ -0,0 +1,36 @@
/**
* TEE OFF SECURITY MIDDLEWARE v1.1
* ---------------------------------------------------------------------------
* REGEL: Beskytter alle ruter under /admin (unntatt /admin/login).
* FUNKSJON: Sjekker for admin_session cookie og omdirigerer hvis den mangler.
* RETTING: Flyttet NextRequest til next/server for å fikse build-error.
* ---------------------------------------------------------------------------
*/
import { NextResponse, type NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const session = request.cookies.get('admin_session');
// 1. Tillat alltid tilgang til innloggingssiden
if (pathname.startsWith('/admin/login')) {
return NextResponse.next();
}
// 2. Beskytt alle andre ruter under /admin
if (pathname.startsWith('/admin')) {
if (!session) {
// Ingen sesjon funnet -> Send til innlogging
const loginUrl = new URL('/admin/login', request.url);
return NextResponse.redirect(loginUrl);
}
}
return NextResponse.next();
}
// Definer hvilke ruter middleware skal kjøre på
export const config = {
matcher: ['/admin/:path*'],
};

View file

@ -0,0 +1,44 @@
import asyncio
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
print("🔍 Går til Tjøme Golfklubb...")
await page.goto('https://tjomegolfklubb.no/', wait_until="domcontentloaded")
await asyncio.sleep(3)
btn_count = await page.locator("a:has-text('Banestatus')").count()
print(f"🤖 Fant {btn_count} lenker med teksten 'Banestatus'.")
try:
# Tvinger roboten til å velge den knappen som faktisk er SYNLIG på skjermen
btn = page.locator("a:has-text('Banestatus'):visible").first
await btn.click(timeout=5000)
print("🖱️ Klikket på den synlige Banestatus-knappen!")
await asyncio.sleep(2)
except Exception as e:
print(f"⚠️ Klarte ikke klikke: {str(e).splitlines()[0]}")
# Henter ut både synlig tekst og "skjult" tekst i koden
synlig_tekst = await page.locator("body").inner_text()
all_tekst = await page.locator("body").text_content()
print("\n--- RESULTAT ---")
if "stengt" in synlig_tekst.lower():
print("✅ Suksess! Fant ordet 'stengt' i den SYNLIGE teksten.")
elif "stengt" in all_tekst.lower():
print("🫣 Fant ordet 'stengt' gjemt i HTML-koden (Panelet åpnet seg ikke skikkelig for roboten).")
idx = all_tekst.lower().find("stengt")
# Fjerner linjeskift for penere utskrift
utdrag = all_tekst[max(0, idx-30):idx+80].replace('\n', ' ')
print(f" Tekstutdrag: '...{utdrag}...'")
else:
print("❌ Fant verken 'stengt' eller 'åpen' på hele siden.")
print(f" (Teksten den leste startet slik: {synlig_tekst[:80].replace(chr(10), ' ')}...)")
print("----------------\n")
await browser.close()
asyncio.run(main())