handleSelectOne(f.id, e.target.checked)} />
#{f.id}
@@ -408,7 +405,7 @@ export default function AdminDashboard() {
Rimeligste: {f.rimeligste_alternativ ? `${f.rimeligste_alternativ},-` : '---'}
- {hasMemDraft ? Ja : - }
+ {hasMemDraft ? Ja, vask! : - }
{f.membership_updated_at ? new Date(f.membership_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
>
)}
@@ -417,13 +414,16 @@ export default function AdminDashboard() {
<>
-
+
{f.greenfee && f.greenfee.length > 0 ? f.greenfee.map((g: any, i: number) => (
-
{g.banenavn}: V: {g.pris_voksne} J: {g.pris_junior}
- )) : '---'}
+
+ {g.banenavn}
+ V: {g.pris_voksne} J: {g.pris_junior}
+
+ )) : 'Ingen priser'}
- {hasGfDraft ? Ja : - }
+ {hasGfDraft ? Ja, vask! : - }
{f.greenfee_updated_at ? new Date(f.greenfee_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
>
)}
@@ -433,21 +433,24 @@ export default function AdminDashboard() {
- Pris: {f.vtg_pris ? `${f.vtg_pris},-` : '---'}
- {f.vtg_beskrivelse || 'Ingen beskrivelse'}
+ Pris: {f.vtg_pris ? `${f.vtg_pris},-` : '---'}
+ {f.vtg_beskrivelse || 'Ingen beskrivelse registrert.'}
+
+ {f.vtg_datoer && f.vtg_datoer.length > 0 ? `📅 ${f.vtg_datoer.length} kursdato(er)` : '📅 Ingen datoer registrert'}
+
- {hasVtgDraft ? Ja : - }
+ {hasVtgDraft ? Ja, vask! : - }
{f.vtg_updated_at ? new Date(f.vtg_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
>
)}
- {activeTab === 'banestatus' && 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">Ekspert-skraper }
- {activeTab === 'medlemskap' && hasMemDraft && Vaskeriet}
- {activeTab === 'greenfee' && hasGfDraft && Vaskeriet}
- {activeTab === 'vtg' && hasVtgDraft && Vaskeriet}
+ {activeTab === 'banestatus' && 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">Innstillinger }
+ {activeTab === 'medlemskap' && hasMemDraft && Gå til Vaskeri}
+ {activeTab === 'greenfee' && hasGfDraft && Gå til Vaskeri}
+ {activeTab === 'vtg' && hasVtgDraft && Gå til Vaskeri}
Rediger alt
diff --git a/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx b/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx
index 57af68b..3904d16 100644
--- a/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx
+++ b/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx
@@ -110,7 +110,7 @@ const ListObjectEditor = ({ label, value, templateKeys, onChange }: { label: str
{items.map((item, idx) => (
-
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">✕
+
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 z-10">✕
{templateKeys.map(key => (
@@ -495,39 +495,60 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
{activeTab === 'okonomi' && (
-
- Sist Oppdatert (Dato for Medlemskapspriser)
- handleChange('membership_updated_at', e.target.value)} />
-
-
+ {/* MEDLEMSKAP */}
-
-
Veien til Golf (VTG)
-
Pris VTG kurs (kun tall) handleChange('vtg_pris', Number(e.target.value))} />
-
Lenke til VTG påmelding handleChange('vtg_lenke', e.target.value)} />
+ {/* GREENFEE */}
+
+
Greenfee / Gjestespill
+
+
handleChange('greenfee', v)}
+ />
+
+
+ {/* VEIEN TIL GOLF (VTG) */}
+
+
Veien til Golf (VTG)
+
+
Pris VTG kurs (kun tall) handleChange('vtg_pris', Number(e.target.value))} />
+
Lenke til VTG påmelding handleChange('vtg_lenke', e.target.value)} />
+
Beskrivelse / Hva er inkludert
+
+
handleChange('vtg_datoer', v)}
+ />
handleChange('amenities', v)} />
handleChange('nsg_data', v)} />
handleChange('golfamore_data', v)} />
-
- handleChange('greenfee', v)}
- />
+ {/* HER ER GOLFPAKKENE SOM JEG MISTET I FORRIGE RUNDE */}
str:
+ """Henter ut hoveddomenet (f.eks. 'tyrifjord-golfklubb.no') fra en URL."""
+ try:
+ domain = urlparse(url.strip()).netloc.lower()
+ if domain.startswith("www."):
+ domain = domain[4:]
+ return domain
+ except:
+ return ""
+
+async def run_import():
+ print("🚀 Starter URL-importør...")
+ conn = await asyncpg.connect(DB_URL)
+
+ try:
+ # Hent alle eksisterende anlegg og deres domener
+ facilities = await conn.fetch("SELECT id, name, website_url, scrape_status_url FROM facilities")
+
+ # Bygg en ordbok: { 'domenenavn.no': facility_id } for superraskt oppslag
+ domain_map = {}
+ for f in facilities:
+ # Prøv å hente domene fra website_url
+ if f['website_url']:
+ domain = extract_domain(f['website_url'])
+ if domain: domain_map[domain] = f['id']
+ # Prøv også scrape_status_url for sikkerhets skyld
+ if f['scrape_status_url']:
+ domain = extract_domain(f['scrape_status_url'])
+ if domain: domain_map[domain] = f['id']
+
+ print(f"📋 Fant {len(domain_map)} unike domener i databasen.")
+
+ # Gå gjennom fil for fil
+ for filename, db_field in FILES_TO_IMPORT.items():
+ print(f"\n▶️ BEHANDLER: {filename} -> Setter felt: {db_field}")
+
+ if not os.path.exists(filename):
+ print(f" ⚠️ Filen '{filename}' ble ikke funnet. Hopper over.")
+ continue
+
+ with open(filename, 'r', encoding='utf-8') as file:
+ lines = [line.strip() for line in file.readlines() if line.strip()]
+
+ matched_count = 0
+ unmatched = []
+
+ for line in lines:
+ # Hvis det er flere URL-er på samme linje separert med komma,
+ # matcher vi basert på den FØRSTE URL-en.
+ first_url = line.split(',')[0].strip()
+ domain = extract_domain(first_url)
+
+ # Hvis vi fant en match i databasen!
+ if domain in domain_map:
+ fac_id = domain_map[domain]
+
+ # Oppdater databasen med HELE linjen (for å bevare ev. komma-lenker)
+ await conn.execute(f"""
+ UPDATE facilities
+ SET {db_field} = $1
+ WHERE id = $2
+ """, line, fac_id)
+ matched_count += 1
+ else:
+ unmatched.append(line)
+
+ print(f" ✅ Matchet og oppdatert {matched_count} anlegg.")
+
+ if unmatched:
+ print(f" ❌ Følgende {len(unmatched)} URL-er fant ingen match i databasen og må legges inn manuelt:")
+ for url in unmatched:
+ print(f" - {url}")
+
+ finally:
+ await conn.close()
+ print("\n🏁 Import fullført!")
+
+if __name__ == "__main__":
+ asyncio.run(run_import())
\ No newline at end of file
diff --git a/kode_eksport_1/backend_main_py.txt b/kode_eksport_1/backend_main_py.txt
index 502f1ef..cfce462 100644
--- a/kode_eksport_1/backend_main_py.txt
+++ b/kode_eksport_1/backend_main_py.txt
@@ -62,6 +62,23 @@ class MembershipDraftApproval(BaseModel):
class BulkApprovalRequest(BaseModel):
approvals: List[MembershipDraftApproval]
+class QuickEditRequest(BaseModel):
+ field: str
+ value: str
+
+class GreenfeeApproval(BaseModel):
+ facility_id: int
+ greenfee: List[dict]
+
+
+class VtgApproval(BaseModel):
+ facility_id: int
+ vtg_pris: int | None
+ vtg_beskrivelse: str | None
+ vtg_datoer: List[dict] | None
+
+class BulkVtgRequest(BaseModel):
+ approvals: List[VtgApproval]
# --- FUNKSJONER ---
def format_row(row):
"""
@@ -81,7 +98,7 @@ def format_row(row):
json_list_fields = [
'course_statuses', 'courses', 'gallery', 'greenfee',
- 'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs'
+ 'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs', 'vtg_datoer'
]
json_dict_fields = [
'amenities', 'vtg', 'nsg_data', 'golfamore_data', 'membership_draft'
@@ -137,6 +154,17 @@ def run_scrape_worker(facility_ids: List[int]):
except Exception as e:
print(f"🔥 UFORUTSETT FEIL UNDER BAKGRUNNSSKRAPING: {e}")
+def run_membership_worker(facility_ids: List[int]):
+ """Kjører medlemskap-skraperen i bakgrunnen."""
+ print(f"🔄 STARTER MEDLEMSKAP-SKRAPING FOR IDER: {facility_ids}")
+ try:
+ ids_arg = ",".join(map(str, facility_ids))
+ command = f"python -u scrape_membership.py --ids {ids_arg}"
+ subprocess.run(command, shell=True, check=True)
+ print(f"✅ MEDLEMSKAP-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
+ except Exception as e:
+ print(f"🔥 FEIL UNDER MEDLEMSKAP-SKRAPING: {e}")
+
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -344,7 +372,8 @@ async def update_facility_full(facility_id: int, request: Request):
'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'
+ 'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at',
+ 'greenfee_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector', 'vtg_lenke'
]
update_data = {k: v for k, v in data.items() if k in allowed_fields}
@@ -420,6 +449,17 @@ async def run_scraper_endpoint(request: ScrapeRunRequest, background_tasks: Back
return {"status": "queued", "message": f"Skraping for {len(request.facility_ids)} anlegg ble lagt i kø."}
+@app.post("/api/admin/run-membership-scraper")
+async def run_membership_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
+ """Tar imot IDer for medlemskapsskraping og legger jobben i kø."""
+ if not request.facility_ids:
+ raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
+
+ print(f"📡 API mottok forespørsel om medlemskapsskraping for IDer: {request.facility_ids}")
+ background_tasks.add_task(run_membership_worker, request.facility_ids)
+
+ return {"status": "queued", "message": f"Medlemskapsskraping 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."""
@@ -473,6 +513,126 @@ async def approve_membership_bulk(request: BulkApprovalRequest):
approval.facility_id)
return {"status": "success", "message": f"{len(request.approvals)} anlegg ble oppdatert med nye priser!"}
+@app.patch("/api/admin/facilities/{facility_id}/quick-edit")
+async def quick_edit_facility(facility_id: int, request: QuickEditRequest):
+ """Lyn-redigering av enkle URL-felter fra admin-dashbordet."""
+ # Sikkerhet: Tillat KUN disse tre feltene for hurtigredigering
+ allowed_fields = ['scrape_status_url', 'medlemskap_url', 'scrape_status_selector']
+ if request.field not in allowed_fields:
+ raise HTTPException(status_code=400, detail="Ugyldig felt for hurtigredigering.")
+
+ async with app.state.pool.acquire() as conn:
+ # F-string her er trygt fordi request.field er sjekket mot allowed_fields-listen
+ await conn.execute(f"UPDATE facilities SET {request.field} = $1 WHERE id = $2",
+ request.value, facility_id)
+ return {"status": "success"}
+
+# --- GREENFEE "VASKERI" ENDEPUNKTER ---
+
+@app.get("/api/admin/greenfee/drafts")
+async def get_greenfee_drafts():
+ """Henter alle anlegg som har et ventende greenfee-forslag fra AI-skraperen."""
+ async with app.state.pool.acquire() as conn:
+ rows = await conn.fetch("""
+ SELECT id, name, slug, greenfee_url, greenfee, greenfee_draft
+ FROM facilities
+ WHERE greenfee_draft IS NOT NULL
+ AND greenfee_draft::text != '{}'
+ ORDER BY name ASC
+ """)
+ return [format_row(row) for row in rows]
+
+class BulkGreenfeeRequest(BaseModel):
+ approvals: List[GreenfeeApproval]
+
+@app.post("/api/admin/greenfee/approve-bulk")
+async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
+ """Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet."""
+ async with app.state.pool.acquire() as conn:
+ async with conn.transaction():
+ for approval in request.approvals:
+ await conn.execute("""
+ UPDATE facilities
+ SET greenfee = $1::jsonb,
+ greenfee_updated_at = NOW(),
+ greenfee_draft = NULL
+ WHERE id = $2
+ """, json.dumps(approval.greenfee), approval.facility_id)
+ return {"status": "success"}
+
+def run_greenfee_worker(facility_ids: List[int]):
+ """Kjører greenfee-skraperen i bakgrunnen."""
+ print(f"🔄 STARTER GREENFEE-SKRAPING FOR IDER: {facility_ids}")
+ try:
+ import subprocess
+ ids_arg = ",".join(map(str, facility_ids))
+ command = f"python -u scrape_greenfee.py --ids {ids_arg}"
+ subprocess.run(command, shell=True, check=True)
+ print(f"✅ GREENFEE-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
+ except Exception as e:
+ print(f"🔥 FEIL UNDER GREENFEE-SKRAPING: {e}")
+
+@app.post("/api/admin/run-greenfee-scraper")
+async def run_greenfee_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
+ """Tar imot IDer for greenfeeskraping og legger jobben i kø."""
+ if not request.facility_ids:
+ raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
+ background_tasks.add_task(run_greenfee_worker, request.facility_ids)
+ return {"status": "queued", "message": "Skraping startet"}
+
+# --- VEIEN TIL GOLF (VTG) "VASKERI" ENDEPUNKTER ---
+
+@app.get("/api/admin/vtg/drafts")
+async def get_vtg_drafts():
+ """Henter alle anlegg som har et ventende VTG-forslag."""
+ async with app.state.pool.acquire() as conn:
+ rows = await conn.fetch("""
+ SELECT id, name, slug, vtg_lenke, vtg_pris, vtg_beskrivelse, vtg_datoer, vtg_draft
+ FROM facilities
+ WHERE vtg_draft IS NOT NULL
+ AND vtg_draft::text != '{}'
+ ORDER BY name ASC
+ """)
+ return [format_row(row) for row in rows]
+
+@app.post("/api/admin/vtg/approve-bulk")
+async def approve_vtg_bulk(request: BulkVtgRequest):
+ """Godkjenner AI-forslag for VTG, setter oppdatert-dato og sletter utkastet."""
+ async with app.state.pool.acquire() as conn:
+ async with conn.transaction():
+ for approval in request.approvals:
+ datoer_json = json.dumps(approval.vtg_datoer) if approval.vtg_datoer is not None else '[]'
+ await conn.execute("""
+ UPDATE facilities
+ SET vtg_pris = $1,
+ vtg_beskrivelse = $2,
+ vtg_datoer = $3::jsonb,
+ vtg_updated_at = NOW(),
+ vtg_draft = NULL
+ WHERE id = $4
+ """, approval.vtg_pris, approval.vtg_beskrivelse, datoer_json, approval.facility_id)
+ return {"status": "success"}
+
+def run_vtg_worker(facility_ids: List[int]):
+ """Kjører VTG-skraperen i bakgrunnen."""
+ print(f"🔄 STARTER VTG-SKRAPING FOR IDER: {facility_ids}")
+ try:
+ import subprocess
+ ids_arg = ",".join(map(str, facility_ids))
+ command = f"python -u scrape_vtg.py --ids {ids_arg}"
+ subprocess.run(command, shell=True, check=True)
+ print(f"✅ VTG-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
+ except Exception as e:
+ print(f"🔥 FEIL UNDER VTG-SKRAPING: {e}")
+
+@app.post("/api/admin/run-vtg-scraper")
+async def run_vtg_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
+ """Tar imot IDer for VTG-skraping og legger jobben i kø."""
+ if not request.facility_ids:
+ raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
+ background_tasks.add_task(run_vtg_worker, request.facility_ids)
+ return {"status": "queued", "message": "Skraping startet"}
+
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
\ No newline at end of file
diff --git a/kode_eksport_1/backend_scrape_greenfee_py.txt b/kode_eksport_1/backend_scrape_greenfee_py.txt
new file mode 100644
index 0000000..edf796f
--- /dev/null
+++ b/kode_eksport_1/backend_scrape_greenfee_py.txt
@@ -0,0 +1,173 @@
+"""
+TEE OFF - GREENFEE-SKRAPER MED GEMINI AI
+---------------------------------------------------------------------------
+Henter alle greenfee-varianter fra en (eller flere) URL-er og strukturerer
+dem i en JSON-liste. Finner også avtaleklubber/vennskapsklubber.
+---------------------------------------------------------------------------
+"""
+
+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
+
+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!")
+
+genai.configure(api_key=GEMINI_API_KEY)
+model = genai.GenerativeModel('gemini-2.5-flash')
+
+async def fetch_page_text(url: str, browser) -> str:
+ url = url.strip()
+ if not url.startswith("http"):
+ return ""
+
+ print(f" 🌐 Laster inn: {url}")
+ try:
+ page = await browser.new_page()
+ await page.goto(url, wait_until="domcontentloaded", timeout=15000)
+ html_content = await page.content()
+ await page.close()
+
+ soup = BeautifulSoup(html_content, 'html.parser')
+ for script in soup(["script", "style", "nav", "footer", "header"]):
+ script.extract()
+
+ return soup.get_text(separator=' ', strip=True)
+ except Exception as e:
+ print(f" ❌ Feil ved lasting av {url}: {e}")
+ return ""
+
+def analyze_greenfee_with_gemini(text: str, club_name: str) -> dict:
+ print(f" 🧠 Sender {len(text)} tegn til Gemini for greenfee-analyse...")
+
+ prompt = f"""
+Du er en ekspert på norske golfklubber og prissetting.
+Din oppgave er å lese teksten hentet fra nettsidene til "{club_name}" og hente ut TO ting:
+1. ALLE varianter av greenfee-priser.
+2. Navn på eventuelle vennskapsklubber/avtaleklubber (hvis nevnt).
+
+REGLER FOR GREENFEE:
+- Trekk ut absolutt alle priskategorier du finner (f.eks. "Hverdag høysesong", "Helg før kl 14", "Gjest av medlem", "9 hull kveld", osv.).
+- Finn både voksenpris og juniorpris for hver kategori.
+- HVIS juniorpris er oppgitt som en regel (f.eks. "Juniorer betaler halv pris" eller "50% rabatt for junior"), MÅ du selv regne ut prisen og skrive inn heltallet.
+- "banenavn": Bruk navnet på banen hvis det er spesifisert (f.eks. "18-hullsbanen", "Korthullsbanen"). Hvis ikke spesifisert, bruk "{club_name}".
+- Priser SKAL være tall (integer). Sett pris til null (null) hvis den ikke finnes.
+
+REGLER FOR AVTALEKLUBBER:
+- Let etter overskrifter som "Vennskapsklubber", "Avtaleklubber", "Gjestespill", "Samarbeidsklubber".
+- Trekk ut kun navnene på klubbene i en liste (f.eks. ["Haga GK", "Oslo GK"]). La listen være tom hvis du ikke finner noen.
+
+TEKST FRA NETTSIDEN:
+{text}
+
+OPPGAVE:
+Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur:
+{{
+ "foreslatt_greenfee": [
+ {{
+ "banenavn": "Navn på banen",
+ "priskategori": "F.eks: Hverdag Gjest av Medlem",
+ "pris_voksne": 600,
+ "pris_junior": 300
+ }}
+ ],
+ "foreslatt_avtaleklubber": [
+ "Klubb 1 GK",
+ "Klubb 2 GK"
+ ],
+ "ai_begrunnelse": "Kort forklaring, f.eks: 'Fant et komplekst prissystem for høy/lavsesong. Regnet ut juniorpriser til 50% som angitt i teksten. Fant 3 samarbeidsklubber nederst.'"
+}}
+"""
+
+ try:
+ response = model.generate_content(prompt)
+ raw_response = response.text.strip()
+
+ 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_greenfee_scraper(facility_ids=None):
+ print("🚀 Starter Greenfee-skraperen...")
+ conn = await asyncpg.connect(DB_URL)
+
+ try:
+ query = "SELECT id, name, greenfee_url FROM facilities WHERE greenfee_url IS NOT NULL AND greenfee_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.")
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+
+ for facility in facilities:
+ fac_id = facility['id']
+ name = facility['name']
+ urls_raw = facility['greenfee_url']
+
+ print(f"\n▶️ Behandler Greenfee for: {name} (ID: {fac_id})")
+
+ urls = [u.strip() for u in urls_raw.split(',')]
+ combined_text = ""
+
+ for idx, url in enumerate(urls, 1):
+ page_text = await fetch_page_text(url, browser)
+ if page_text:
+ combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
+
+ if len(combined_text) < 50:
+ print(" ⚠️ Fant for lite tekst, hopper over.")
+ continue
+
+ draft_data = analyze_greenfee_with_gemini(combined_text[:25000], name)
+
+ if not draft_data:
+ continue
+
+ funnet_priser = len(draft_data.get('foreslatt_greenfee', []))
+ funnet_klubber = len(draft_data.get('foreslatt_avtaleklubber', []))
+ print(f" ✅ AI fant {funnet_priser} greenfee-varianter og {funnet_klubber} avtaleklubber.")
+
+ await conn.execute("""
+ UPDATE facilities
+ SET greenfee_draft = $1::jsonb
+ WHERE id = $2
+ """, json.dumps(draft_data), fac_id)
+
+ print(" 💾 Greenfee-utkast lagret i databasen!")
+
+ await browser.close()
+
+ finally:
+ await conn.close()
+ print("\n🏁 Skraping fullført.")
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Skrap greenfeepriser 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_greenfee_scraper(ids_to_scrape))
\ No newline at end of file
diff --git a/kode_eksport_1/backend_scrape_membership_py.txt b/kode_eksport_1/backend_scrape_membership_py.txt
index f177dcd..2fb99fe 100644
--- a/kode_eksport_1/backend_scrape_membership_py.txt
+++ b/kode_eksport_1/backend_scrape_membership_py.txt
@@ -1,9 +1,9 @@
"""
-TEE OFF - MEDLEMSKAPSSKRAPER MED GEMINI AI
+TEE OFF - MEDLEMSKAPSSKRAPER MED GEMINI AI (MULTI-URL VERSJON)
---------------------------------------------------------------------------
-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).
+Går til oppgitte medlemskaps-URLer (støtter flere URLer adskilt med komma),
+henter ut tekst, og bruker Gemini til å summere og finne 'Standard' og
+'Rimeligste' medlemskap.
---------------------------------------------------------------------------
"""
@@ -17,7 +17,6 @@ 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")
@@ -26,72 +25,70 @@ 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
+model = genai.GenerativeModel('gemini-2.5-flash')
-async def fetch_page_text(url: str) -> str:
- """Bruker Playwright for å hente all synlig tekst fra nettsiden."""
+async def fetch_page_text(url: str, browser) -> str:
+ """Bruker Playwright for å hente all synlig tekst fra EN nettside."""
+ url = url.strip()
+ if not url.startswith("http"):
+ return ""
+
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)
+ page = await browser.new_page()
+ await page.goto(url, wait_until="domcontentloaded", timeout=15000)
+ html_content = await page.content()
+ await page.close()
+
+ soup = BeautifulSoup(html_content, 'html.parser')
+ for script in soup(["script", "style", "nav", "footer", "header"]):
+ script.extract()
- # 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]
+ text = soup.get_text(separator=' ', strip=True)
+ return text
except Exception as e:
- print(f" ❌ Feil ved lasting av side: {e}")
+ print(f" ❌ Feil ved lasting av {url}: {e}")
return ""
def analyze_with_gemini(text: str, club_name: str) -> dict:
- """Sender teksten til Gemini for å trekke ut priser."""
+ """Sender den kombinerte teksten til Gemini for å trekke ut og evt. summere 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.
+Du er en ekspert på norske golfklubber. Din oppgave er å lese teksten hentet fra nettsidene til "{club_name}" og finne to spesifikke priser.
+
+VIKTIG REGEL OM NORSK GOLF:
+Mange steder er "Klubbkontingent/Medlemskap" og "Spillerett/Årskort" to forskjellige ting.
+For å få spille ubegrenset (Fritt spill) MÅ man betale BEGGE DELER. Hvis du ser at prisene for kontingent og spillerett er oppgitt hver for seg, SKAL DU SUMMERE disse to summene og bruke totalen som "Standard pris".
+
+ALDERSPREMISS FOR BEGGE PRISER:
+Vi forutsetter at personen som skal ha medlemskap er en VOKSEN GOLFER PÅ MINST 35 ÅR. Du må ALDRI velge priser som gjelder for barn, junior, ung voksen (f.eks. 20-29 år), student eller senior/pensjonist.
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.)
+1. "Standard medlemskap": Hva er TOTALPRISEN (inkludert evt. spillerett/årskort) for en voksen person (35+ år) for å spille SÅ MYE VEDKOMMENDE ØNSKER (Fritt spill) i år?
+2. "Rimeligste alternativ": Det absolutt billigste alternativet FOR EN VOKSEN PERSON (35+ år) som gir medlemskap i klubben (golfkortet), forutsatt at man betaler greenfee for hver runde. (Ofte kalt Greenfeemedlem, Postkassemedlem, Fjernmedlem, eller kun "Klubbkontingent for voksne" uten spillerett).
-TEKST FRA NETTSIDEN:
+TEKST FRA NETTSIDEN(E):
{text}
OPPGAVE:
-Returner KUN et gyldig JSON-objekt med følgende struktur (og ingenting annet, ingen markdown):
+Returner KUN et gyldig JSON-objekt med følgende struktur:
{{
- "foreslatt_standard_navn": "Navnet på medlemskapet (eks: Hovedmedlem Voksen)",
+ "foreslatt_standard_navn": "Navn (eks: Hovedmedlem Voksen inkl. spillerett)",
"foreslatt_standard_pris": 1234,
- "foreslatt_standard_kommentar": "Kort evt kommentar (eks: Inkluderer ikke 500kr i dugnadsavgift)",
- "foreslatt_rimeligste_navn": "Navnet (eks: Greenfeemedlemskap)",
+ "foreslatt_standard_kommentar": "Kort kommentar (eks: Måtte summere kontingent på 900 og årskort på 5000)",
+ "foreslatt_rimeligste_navn": "Navn (eks: Greenfeemedlemskap Voksen)",
"foreslatt_rimeligste_pris": 500,
- "ai_begrunnelse": "Kort forklaring på hvorfor du valgte disse to, f.eks: 'Valgte Hovedmedlem for fritt spill og Greenfeemedlem fordi...'."
+ "ai_begrunnelse": "Kort forklaring på utregningen din."
}}
-
-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").
+Merk: Prisene SKAL være tall (integer), ikke tekst. Sett til null hvis du ikke finner det.
"""
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("```"):
@@ -103,13 +100,10 @@ Merk: Hvis prisene mangler, sett pris til null og skriv "Fant ikke" i navnet. Pr
return None
async def run_scraper(facility_ids=None):
- """Hovedfunksjon som henter fra DB, skraper, og lagrer utkast."""
- print("🚀 Starter Medlemskaps-skraperen...")
-
+ print("🚀 Starter Medlemskaps-skraperen (Støtter multi-URL)...")
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))})"
@@ -117,35 +111,46 @@ async def run_scraper(facility_ids=None):
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']
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
- 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
+ for facility in facilities:
+ fac_id = facility['id']
+ name = facility['name']
+ urls_raw = facility['medlemskap_url']
- # 2. Analyser med Gemini
- draft_data = analyze_with_gemini(page_text, name)
-
- if not draft_data:
- continue
+ print(f"\n▶️ Behandler: {name} (ID: {fac_id})")
- # 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!")
+ # Sjekker om det er flere URL-er adskilt med komma
+ urls = [u.strip() for u in urls_raw.split(',')]
+ combined_text = ""
+
+ for idx, url in enumerate(urls, 1):
+ page_text = await fetch_page_text(url, browser)
+ if page_text:
+ combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
+
+ if len(combined_text) < 50:
+ print(" ⚠️ Fant for lite tekst, hopper over.")
+ continue
+
+ # Kutter teksten for å ikke overbelaste Gemini (ca 25000 tegn maks)
+ draft_data = analyze_with_gemini(combined_text[:25000], name)
+
+ if not draft_data:
+ continue
+
+ 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!")
+
+ await browser.close()
finally:
await conn.close()
diff --git a/kode_eksport_1/backend_scrape_vtg_py.txt b/kode_eksport_1/backend_scrape_vtg_py.txt
new file mode 100644
index 0000000..797545d
--- /dev/null
+++ b/kode_eksport_1/backend_scrape_vtg_py.txt
@@ -0,0 +1,161 @@
+"""
+TEE OFF - VEIEN TIL GOLF (VTG) SKRAPER MED GEMINI AI
+---------------------------------------------------------------------------
+Henter pris, beskrivelse (inkl. lånekøller/medlemskap) og kursdatoer fra VTG-sider.
+Støtter kommaseparerte URL-er.
+---------------------------------------------------------------------------
+"""
+
+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
+
+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!")
+
+genai.configure(api_key=GEMINI_API_KEY)
+model = genai.GenerativeModel('gemini-2.5-flash')
+
+async def fetch_page_text(url: str, browser) -> str:
+ url = url.strip()
+ if not url.startswith("http"):
+ return ""
+
+ print(f" 🌐 Laster inn: {url}")
+ try:
+ page = await browser.new_page()
+ await page.goto(url, wait_until="domcontentloaded", timeout=15000)
+ html_content = await page.content()
+ await page.close()
+
+ soup = BeautifulSoup(html_content, 'html.parser')
+ for script in soup(["script", "style", "nav", "footer", "header"]):
+ script.extract()
+
+ return soup.get_text(separator=' ', strip=True)
+ except Exception as e:
+ print(f" ❌ Feil ved lasting av {url}: {e}")
+ return ""
+
+def analyze_vtg_with_gemini(text: str, club_name: str) -> dict:
+ print(f" 🧠 Sender {len(text)} tegn til Gemini for VTG-analyse...")
+
+ prompt = f"""
+Du er en ekspert på norske golfklubber. Din oppgave er å lese en lang tekst fra nettsidene til "{club_name}" og koke dette ned til essensen om deres "Veien til Golf" (VTG) nybegynnerkurs.
+
+OPPGAVER:
+1. Finn standardprisen for VTG-kurset for en vanlig voksen person. (Returner KUN tallet).
+2. Skriv en KOMPRIMERT, selgende beskrivelse (maks 3-4 setninger). Du MÅ inkludere informasjon om:
+ - Er lån av køller/utstyr inkludert i kurset?
+ - Inkluderer prisen et medlemskap/spillerett i klubben (og ev. for hvor lenge)?
+ - Hva er omfanget? (F.eks. "12 timer praksis pluss e-læring").
+ Ignorer uvesentlig støy og lange historiske utgreiinger.
+3. Finn alle kommende kursdatoer. Finn startdato/sluttdato for hvert kurs, og noter status ("Ledig", "Fulltegnet", "Venteliste").
+
+TEKST FRA NETTSIDEN:
+{text}
+
+OPPGAVE:
+Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur:
+{{
+ "foreslatt_vtg_pris": 1990,
+ "foreslatt_vtg_beskrivelse": "Kurset går over 12 timer inkludert obligatorisk e-læring. Lån av golfkøller er inkludert under hele kurset, og prisen gir deg også fritt spill og medlemskap ut året.",
+ "foreslatt_vtg_datoer": [
+ {{"dato": "12.-14. mai", "status": "Fulltegnet"}},
+ {{"dato": "5.-7. juni", "status": "Ledig"}}
+ ],
+ "ai_begrunnelse": "Fant voksenpris på 1990,-. Teksten nevnte eksplisitt at medlemskap ut året er med i prisen, og at man får låne utstyr."
+}}
+Merk: Sett foreslatt_vtg_pris til null (null) hvis du ikke finner den. Hvis du ikke finner datoer, la listen være tom [].
+"""
+
+ try:
+ response = model.generate_content(prompt)
+ raw_response = response.text.strip()
+
+ 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_vtg_scraper(facility_ids=None):
+ print("🚀 Starter Veien til Golf (VTG) skraperen...")
+ conn = await asyncpg.connect(DB_URL)
+
+ try:
+ query = "SELECT id, name, vtg_lenke FROM facilities WHERE vtg_lenke IS NOT NULL AND vtg_lenke != ''"
+ 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.")
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+
+ for facility in facilities:
+ fac_id = facility['id']
+ name = facility['name']
+ urls_raw = facility['vtg_lenke']
+
+ print(f"\n▶️ Behandler VTG for: {name} (ID: {fac_id})")
+
+ urls = [u.strip() for u in urls_raw.split(',')]
+ combined_text = ""
+
+ for idx, url in enumerate(urls, 1):
+ page_text = await fetch_page_text(url, browser)
+ if page_text:
+ combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
+
+ if len(combined_text) < 50:
+ print(" ⚠️ Fant for lite tekst, hopper over.")
+ continue
+
+ draft_data = analyze_vtg_with_gemini(combined_text[:25000], name)
+
+ if not draft_data:
+ continue
+
+ print(f" ✅ AI fant pris: {draft_data.get('foreslatt_vtg_pris')}, og {len(draft_data.get('foreslatt_vtg_datoer', []))} datoer.")
+
+ await conn.execute("""
+ UPDATE facilities
+ SET vtg_draft = $1::jsonb
+ WHERE id = $2
+ """, json.dumps(draft_data), fac_id)
+
+ print(" 💾 VTG-utkast lagret i databasen!")
+
+ await browser.close()
+
+ finally:
+ await conn.close()
+ print("\n🏁 Skraping fullført.")
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Skrap VTG 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_vtg_scraper(ids_to_scrape))
\ No newline at end of file
diff --git a/kode_eksport_1/frontend_src_app_admin_greenfee_page_tsx.txt b/kode_eksport_1/frontend_src_app_admin_greenfee_page_tsx.txt
new file mode 100644
index 0000000..ed8a782
--- /dev/null
+++ b/kode_eksport_1/frontend_src_app_admin_greenfee_page_tsx.txt
@@ -0,0 +1,203 @@
+"use client";
+import { useState, useEffect } from 'react';
+import { API_URL } from "@/config/constants";
+import Link from 'next/link';
+
+export default function GreenfeeWasher() {
+ const [drafts, setDrafts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedIds, setSelectedIds] = useState([]);
+ const [saving, setSaving] = useState(false);
+
+ const fetchDrafts = () => {
+ setLoading(true);
+ fetch(`${API_URL}/admin/greenfee/drafts`)
+ .then(res => res.json())
+ .then(data => {
+ const editableDrafts = data.map((f: any) => {
+ // JSONB fra Postgres kan noen ganger komme som en streng,
+ // vi må sikre at vi parser det hvis det trengs
+ let parsedDraft = f.greenfee_draft;
+ if (typeof parsedDraft === 'string') {
+ try { parsedDraft = JSON.parse(parsedDraft); }
+ catch (e) { console.error("Kunne ikke parse JSON", e); }
+ }
+
+ // Hent ut selve listen (fallback til tom liste hvis noe er feil)
+ const greenfeeList = parsedDraft?.foreslatt_greenfee || [];
+
+ return {
+ ...f,
+ greenfee_draft: parsedDraft, // Lagre den parsede versjonen for visning
+ edit_greenfee: greenfeeList // Dette er arrayet som binder seg til input-feltene
+ };
+ });
+ 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 removeRow = (facilityId: number, rowIndex: number) => {
+ setDrafts(drafts.map(d => {
+ if (d.id === facilityId) {
+ const newRows = [...d.edit_greenfee];
+ newRows.splice(rowIndex, 1);
+ return { ...d, edit_greenfee: newRows };
+ }
+ return d;
+ }));
+ };
+
+ const updateField = (facilityId: number, rowIndex: number, field: string, value: string | number) => {
+ setDrafts(drafts.map(d => {
+ if (d.id === facilityId) {
+ const newRows = [...d.edit_greenfee];
+ newRows[rowIndex] = { ...newRows[rowIndex], [field]: value };
+ return { ...d, edit_greenfee: newRows };
+ }
+ return d;
+ }));
+ };
+
+ const handleApprove = async () => {
+ const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({
+ facility_id: d.id,
+ greenfee: d.edit_greenfee.map((row: any) => ({
+ banenavn: row.banenavn || "",
+ priskategori: row.priskategori || "",
+ pris_voksne: Number(row.pris_voksne) || null,
+ pris_junior: Number(row.pris_junior) || null
+ }))
+ }));
+
+ if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne.");
+
+ setSaving(true);
+ try {
+ const res = await fetch(`${API_URL}/admin/greenfee/approve-bulk`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ approvals: toApprove })
+ });
+ if (res.ok) {
+ alert(`${toApprove.length} anlegg oppdatert!`);
+ setSelectedIds([]);
+ fetchDrafts();
+ } else {
+ alert("Noe gikk galt under lagring.");
+ }
+ } catch (e) {
+ alert("Nettverksfeil");
+ }
+ setSaving(false);
+ };
+
+ if (loading) return Laster utkast...
;
+
+ return (
+
+
+
+
+
← Tilbake til oversikten
+
Greenfee-Vaskeriet
+
Sjekk at prisene gir mening før publisering.
+
+
+ {saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
+
+
+
+ {drafts.length === 0 ? (
+
+ 🧹
+
Alt er rent og pent!
+
+ ) : (
+
+
+ 0} onChange={(e) => toggleSelectAll(e.target.checked)} />
+ Velg Alle
+
+
+ {drafts.map(draft => (
+
+
+
toggleOne(draft.id)} />
+
+
+
+ {draft.greenfee_draft?.ai_begrunnelse && (
+
+ 🤖 AI Begrunnelse: {draft.greenfee_draft.ai_begrunnelse}
+
+ )}
+
+ {draft.greenfee_draft?.foreslatt_avtaleklubber?.length > 0 && (
+
+ 🤝 AI fant disse avtaleklubbene i teksten: {draft.greenfee_draft.foreslatt_avtaleklubber.join(', ')}
+
+ )}
+
+
+
+
Slik ser det ut i databasen nå:
+
+ {draft.greenfee && draft.greenfee.length > 0 ? draft.greenfee.map((g: any, i: number) => (
+
+ {g.banenavn} - {g.priskategori}
+ V: {g.pris_voksne || '-'} | J: {g.pris_junior || '-'}
+
+ )) : "Ingen priser registrert."}
+
+
+
+
+
Nytt forslag å godkjenne:
+
+ {draft.edit_greenfee && draft.edit_greenfee.map((row: any, idx: number) => (
+
+ updateField(draft.id, idx, 'banenavn', e.target.value)} placeholder="Bane" />
+ updateField(draft.id, idx, 'priskategori', e.target.value)} placeholder="Kategori" />
+ updateField(draft.id, idx, 'pris_voksne', e.target.value)} placeholder="Voksen" />
+ updateField(draft.id, idx, 'pris_junior', e.target.value)} placeholder="Junior" />
+ removeRow(draft.id, idx)} className="text-red-400 hover:text-red-600 px-2 opacity-0 group-hover:opacity-100 transition-opacity" title="Slett rad">✕
+
+ ))}
+
{
+ const newDrafts = [...drafts];
+ const draftIndex = newDrafts.findIndex(d => d.id === draft.id);
+ newDrafts[draftIndex].edit_greenfee.push({ banenavn: '', priskategori: '', pris_voksne: '', pris_junior: '' });
+ setDrafts(newDrafts);
+ }} className="text-xs font-bold text-[#8bc34a] hover:underline mt-2 inline-block">
+ + Legg til manuell rad
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/kode_eksport_1/frontend_src_app_admin_page_tsx.txt b/kode_eksport_1/frontend_src_app_admin_page_tsx.txt
index e74d9ea..1bb56fa 100644
--- a/kode_eksport_1/frontend_src_app_admin_page_tsx.txt
+++ b/kode_eksport_1/frontend_src_app_admin_page_tsx.txt
@@ -1,10 +1,6 @@
"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.
- * ---------------------------------------------------------------------------
+ * TEE OFF ADMIN DASHBOARD v4.0 - KONTROLLPANEL
*/
import { useState, useEffect, useMemo } from 'react';
@@ -12,6 +8,39 @@ import { API_URL } from "@/config/constants";
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
import Link from 'next/link';
+const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: number, field: string, initialValue: string, onSave: (id: number, field: string, val: string) => void }) => {
+ const [isEditing, setIsEditing] = useState(false);
+ const [value, setValue] = useState(initialValue || '');
+
+ const handleSave = () => {
+ setIsEditing(false);
+ if (value !== initialValue) {
+ onSave(facilityId, field, value);
+ }
+ };
+
+ if (isEditing) {
+ return (
+
+ );
+ }
+
+ return (
+ setIsEditing(true)} title="Klikk for å redigere URL">
+
+ {initialValue ? initialValue : Mangler URL }
+
+
✏️
+
+ );
+};
+
export default function AdminDashboard() {
const [facilities, setFacilities] = useState([]);
const [loading, setLoading] = useState(true);
@@ -19,18 +48,9 @@ export default function AdminDashboard() {
const [isScraping, setIsScraping] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [editingFacility, setEditingFacility] = useState(null);
-
- // NYTT: State for å holde styr på hvilket filter som er aktivt
+ const [activeTab, setActiveTab] = useState<'banestatus' | 'medlemskap' | 'greenfee' | 'vtg'>('banestatus');
const [statusFilter, setStatusFilter] = useState('alle');
-
- const [editForm, setEditForm] = useState({
- scrape_status_url: '',
- scrape_status_selector: '',
- scrape_method: '',
- ai_instruction: '',
- courses: [] as any[]
- });
-
+ const [editForm, setEditForm] = useState({ scrape_status_url: '', scrape_status_selector: '', scrape_method: '', ai_instruction: '', courses: [] as any[] });
const [isSaving, setIsSaving] = useState(false);
const fetchFacilities = () => {
@@ -43,92 +63,76 @@ export default function AdminDashboard() {
.catch(() => setLoading(false));
};
- useEffect(() => {
- fetchFacilities();
- }, []);
+ useEffect(() => { fetchFacilities(); }, []);
useEffect(() => {
let interval: NodeJS.Timeout;
- if (isScraping) {
- interval = setInterval(() => {
- fetchFacilities();
- }, 10000);
- }
+ if (isScraping) interval = setInterval(() => fetchFacilities(), 10000);
return () => clearInterval(interval);
}, [isScraping]);
- // Filtreringslogikken som kjører automatisk når facilities eller filteret endres
+ useEffect(() => { setSelectedFacilities([]); }, [activeTab]);
+
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';
- }
+ 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) => {
- if (e.target.checked) {
- setSelectedFacilities(filteredFacilities.map(f => f.id));
- } else {
- setSelectedFacilities([]);
- }
+ 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));
+ if (checked) setSelectedFacilities([...selectedFacilities, id]);
+ else setSelectedFacilities(selectedFacilities.filter(facilityId => facilityId !== id));
+ };
+
+ const handleQuickEdit = async (id: number, field: string, value: string) => {
+ setFacilities(facilities.map(f => f.id === id ? { ...f, [field]: value } : f));
+ try {
+ const res = await fetch(`${API_URL}/admin/facilities/${id}/quick-edit`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ field, value })
+ });
+ if (!res.ok) throw new Error("Feil ved lagring");
+ } catch (e) {
+ alert("Kunne ikke lagre endringen i databasen.");
+ fetchFacilities();
}
};
const handleRunScrapers = async () => {
- if (isScraping) {
- setIsScraping(false);
- return;
- }
-
+ if (isScraping) { setIsScraping(false); return; }
setIsScraping(true);
+ const endpoint = activeTab === 'banestatus' ? '/admin/run-scraper' :
+ activeTab === 'medlemskap' ? '/admin/run-membership-scraper' :
+ activeTab === 'greenfee' ? '/admin/run-greenfee-scraper' :
+ '/admin/run-vtg-scraper';
try {
- const response = await fetch(`${API_URL}/admin/run-scraper`, {
+ const response = await fetch(`${API_URL}${endpoint}`, {
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.");
+ alert(`Feil ved start av ${activeTab}-skraperen.`);
setIsScraping(false);
}
};
@@ -152,25 +156,20 @@ export default function AdminDashboard() {
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);
- }
+ } finally { setIsSaving(false); }
};
- if (loading) return LASTER DASHBORD...
;
+ if (loading) return LASTER KONTROLLPANEL...
;
return (
- {/* REDIGER-MODAL FOR SKRAPING */}
+ {/* REDIGER-MODAL FOR BANESTATUS */}
{editingFacility && (
@@ -178,26 +177,14 @@ export default function AdminDashboard() {
Skrape-innstillinger
{editingFacility.name}
-
- Scrape URL
- 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"
- />
+ Scrape URL (Banestatus)
+ 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" />
-
Skrapemetode
- 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"
- >
+ 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">
Standard (CSS)
✨ Gemini AI (LLM)
Golfbox iframe
@@ -205,21 +192,12 @@ export default function AdminDashboard() {
🚨 Manuell (Ikke skrap)
-
{editForm.scrape_method === 'llm_parse' && (
✨ AI-Hviskeren (Instruks til Gemini)
-
)}
-
{editForm.scrape_method === 'manual' && (
🚨 Sett Status Manuelt
@@ -227,15 +205,7 @@ export default function AdminDashboard() {
{editForm.courses.map((course: any, idx: number) => (
{course.name}
- {
- 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"
- >
+ { 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">
🟢 Åpen
🟡 Vintergreener
🟡 Åpner Snart
@@ -250,22 +220,13 @@ export default function AdminDashboard() {
)}
-
{(editForm.scrape_method === 'css_selector' || editForm.scrape_method === 'click_then_css' || editForm.scrape_method === 'iframe_golfbox') && (
CSS Selector
- 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"
- />
+ 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" />
)}
-
-
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
@@ -280,20 +241,24 @@ export default function AdminDashboard() {
{!isSidebarCollapsed &&
TeeOff }
- setIsSidebarCollapsed(!isSidebarCollapsed)} className="text-2xl hover:text-[#8bc34a] transition-colors" title="Skjul/Vis meny">
- ☰
-
+ setIsSidebarCollapsed(!isSidebarCollapsed)} className="text-2xl hover:text-[#8bc34a] transition-colors" title="Skjul/Vis meny">☰
-
- {isSidebarCollapsed ? 'SM' : 'Scraping Monitor'}
-
-
- {isSidebarCollapsed ? 'M' : 'Medlemskap'}
+
+ {isSidebarCollapsed ? 'KP' : 'Kontrollpanel'}
-
- {isSidebarCollapsed ? 'B' : 'Bildegalleri'}
+
+
Datavask
+
+ {isSidebarCollapsed ? 'M' : 'Medlemskap'}
+
+
+ {isSidebarCollapsed ? 'G' : 'Greenfee'}
+
+
+ {isSidebarCollapsed ? 'V' : 'VTG'}
+
@@ -306,142 +271,193 @@ export default function AdminDashboard() {
{/* HOVEDINNHOLD */}
-
-
-
TeeOff Admin
- MONITOR
-
-
-
+
-
-
Filtrer på status:
-
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"
- >
- Vis alle anlegg
- 🟢 Kun åpne baner
- 🟡 Ikke stengt (Åpne/Vintergreen/Snart)
- 🔴 Kun stengte baner
- ⚪ Ukjent / Skrapefeil (Krever tilsyn)
-
+ {/* VELDIG SYNLIGE FANER */}
+
+ setActiveTab('banestatus')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'banestatus' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Banestatus
+ setActiveTab('medlemskap')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'medlemskap' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Medlemskap
+ setActiveTab('greenfee')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'greenfee' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Greenfee
+ setActiveTab('vtg')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'vtg' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>VTG-Kurs
+
+ {activeTab === 'banestatus' && (
+
+ Filtrer på status:
+ 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">
+ Vis alle anlegg
+ 🟢 Kun åpne baner
+ 🟡 Ikke stengt (Åpne/Vintergreen/Snart)
+ 🔴 Kun stengte baner
+ ⚪ Ukjent / Skrapefeil
+
+
+ )}
-
+
-
-
- 0}
- onChange={handleSelectAll}
- />
-
+
+ 0} onChange={handleSelectAll} />
ID
Anlegg
- Konfigurasjon
- Metode
- Siste Sjekk
- Banestatus
+
+ {activeTab === 'banestatus' && (
+ <>
+ Konfigurasjon (URL & Selektor)
+ Metode
+ Siste Sjekk
+ Banestatus
+ >
+ )}
+ {activeTab === 'medlemskap' && (
+ <>
+ Medlemskap-side (Klikk for å redigere)
+ Nåværende Priser
+ Nytt Utkast?
+ Sist Vasket
+ >
+ )}
+ {activeTab === 'greenfee' && (
+ <>
+ Greenfee-side (Klikk for å redigere)
+ Aktive priser
+ Nytt Utkast?
+ Sist Vasket
+ >
+ )}
+ {activeTab === 'vtg' && (
+ <>
+ VTG-side (Klikk for å redigere)
+ Registrert Informasjon
+ Nytt Utkast?
+ Sist Vasket
+ >
+ )}
Handling
+
- {filteredFacilities.map((f: any) => (
-
-
- handleSelectOne(f.id, e.target.checked)}
- />
-
-
-
- #{f.id}
-
+ {filteredFacilities.map((f: any) => {
+ const hasMemDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0;
+ const hasGfDraft = f.greenfee_draft && Object.keys(f.greenfee_draft).length > 0;
+ const hasVtgDraft = f.vtg_draft && Object.keys(f.vtg_draft).length > 0;
+ const isHighlighted = (activeTab === 'medlemskap' && hasMemDraft) || (activeTab === 'greenfee' && hasGfDraft) || (activeTab === 'vtg' && hasVtgDraft);
+
+ return (
+
+ handleSelectOne(f.id, e.target.checked)} />
+ #{f.id}
+
+ {f.name}
+ {f.city}
+
-
- {f.name}
- {f.city}
-
-
-
- {f.scrape_status_url ? f.scrape_status_url : Mangler URL }
-
- {f.scrape_status_selector}
-
-
-
-
-
- {f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
-
-
-
- {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 (
-
-
- {cs.name}
-
-
- {cs.status || 'UKJENT'}
-
+ {activeTab === 'banestatus' && (
+ <>
+
+
+ {f.scrape_status_selector}
+
+
+
{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
+
+
+ {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 (
+
+ {cs.name}
+ {cs.status || 'UKJENT'}
+
+ )
+ })}
- )
- })}
-
-
-
-
-
- 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
-
-
- Rediger alt
-
-
-
+
+ >
+ )}
-
- ))}
+ {activeTab === 'medlemskap' && (
+ <>
+
+
+
+ Standard: {f.standard_medlemskap ? `${f.standard_medlemskap},-` : '---'}
+ Rimeligste: {f.rimeligste_alternativ ? `${f.rimeligste_alternativ},-` : '---'}
+
+
+ {hasMemDraft ? Ja, vask! : - }
+ {f.membership_updated_at ? new Date(f.membership_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
+ >
+ )}
+
+ {activeTab === 'greenfee' && (
+ <>
+
+
+
+ {f.greenfee && f.greenfee.length > 0 ? f.greenfee.map((g: any, i: number) => (
+
+ {g.banenavn}
+ V: {g.pris_voksne} J: {g.pris_junior}
+
+ )) : 'Ingen priser'}
+
+
+ {hasGfDraft ? Ja, vask! : - }
+ {f.greenfee_updated_at ? new Date(f.greenfee_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
+ >
+ )}
+
+ {activeTab === 'vtg' && (
+ <>
+
+
+
+ Pris: {f.vtg_pris ? `${f.vtg_pris},-` : '---'}
+ {f.vtg_beskrivelse || 'Ingen beskrivelse registrert.'}
+
+ {f.vtg_datoer && f.vtg_datoer.length > 0 ? `📅 ${f.vtg_datoer.length} kursdato(er)` : '📅 Ingen datoer registrert'}
+
+
+
+ {hasVtgDraft ? Ja, vask! : - }
+ {f.vtg_updated_at ? new Date(f.vtg_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
+ >
+ )}
+
+
+
+ {activeTab === 'banestatus' && 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">Innstillinger }
+ {activeTab === 'medlemskap' && hasMemDraft && Gå til Vaskeri}
+ {activeTab === 'greenfee' && hasGfDraft && Gå til Vaskeri}
+ {activeTab === 'vtg' && hasVtgDraft && Gå til Vaskeri}
+
+ Rediger alt
+
+
+
+ );
+ })}
diff --git a/kode_eksport_1/frontend_src_app_admin_rediger_[slug]_EditFacilityClient_tsx.txt b/kode_eksport_1/frontend_src_app_admin_rediger_[slug]_EditFacilityClient_tsx.txt
index 23cca76..3904d16 100644
--- a/kode_eksport_1/frontend_src_app_admin_rediger_[slug]_EditFacilityClient_tsx.txt
+++ b/kode_eksport_1/frontend_src_app_admin_rediger_[slug]_EditFacilityClient_tsx.txt
@@ -110,7 +110,7 @@ const ListObjectEditor = ({ label, value, templateKeys, onChange }: { label: str
{items.map((item, idx) => (
-
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">✕
+
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 z-10">✕
{templateKeys.map(key => (
@@ -352,33 +352,13 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
{ 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
+ // Hjelpefunksjon for å hente ut verdi (spesielt formatert for dato)
+ const getValue = (field: string, type: string) => {
+ let val = formData[field] || "";
+ if (type === 'date' && val) {
+ val = val.split('T')[0];
}
-
- return (
-
- {label}
- {type === 'textarea' ? (
- handleChange(field, e.target.value)}
- />
- ) : (
- handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)}
- />
- )}
-
- );
+ return val;
};
return (
@@ -415,24 +395,42 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
{activeTab === 'generelt' && (
-
+
+ Anleggsnavn
+ handleChange('name', e.target.value)} />
+
- {/* NYTT: Viktig beskjed / Kursiv intro */}
-
+
+ Viktig beskjed (Kursiv intro-tekst)
+ handleChange('footnote', e.target.value)} />
+
-
+
+ Hovedbeskrivelse
+ handleChange('description', e.target.value)} />
+
-
-
-
+
+ Banetype (f.eks Park/Skog)
+ handleChange('banetype', e.target.value)} />
+
+
+
+ Sesong (f.eks April-Oktober)
+ handleChange('season', e.target.value)} />
+
+
+
+ Byggeår
+ handleChange('established_year', Number(e.target.value))} />
+
- {/* NYTT: Arkitekt med forslag */}
Arkitekt
handleChange('architect', e.target.value)}
placeholder="Velg eller skriv inn ny..."
/>
@@ -442,9 +440,11 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
-
+
+ Totallengde (meter)
+ handleChange('length_meters', Number(e.target.value))} />
+
- {/* NYTT: Samarbeidende klubber */}
f.id !== initialData.id)}
@@ -459,29 +459,31 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
{activeTab === 'lokasjon' && (
)}
{activeTab === 'linker' && (
-
-
-
-
-
-
-
-
+
Nettside URL handleChange('website_url', e.target.value)} />
+
Golfbox Booking URL handleChange('golfbox_booking_url', e.target.value)} />
+
Golfbox Turnering URL handleChange('golfbox_tournament_url', e.target.value)} />
+
Baneguide URL handleChange('baneguide_url', e.target.value)} />
+
Flyfoto URL handleChange('flyfoto_url', e.target.value)} />
+
Vær URL (YR) handleChange('weather_url', e.target.value)} />
+
Webkamera URL handleChange('webcam_url', e.target.value)} />
+
Video URL (YouTube/Vimeo) handleChange('video_url', e.target.value)} />
- {/* NYTT: Sosiale Medier lagt inn som liste-editor her */}
- {/* NYTT FELT FOR MANUELL DATO */}
-
-
-
-
+ {/* MEDLEMSKAP */}
-
-
Veien til Golf (VTG)
-
-
-
-
- {/* Tilbud (tidligere under Avansert) */}
-
-
handleChange('amenities', v)} />
- handleChange('nsg_data', v)} />
- handleChange('golfamore_data', v)} />
-
+ {/* GREENFEE */}
+
+
Greenfee / Gjestespill
+
handleChange('greenfee', v)}
/>
+
+
+ {/* VEIEN TIL GOLF (VTG) */}
+
+
Veien til Golf (VTG)
+
+
Pris VTG kurs (kun tall) handleChange('vtg_pris', Number(e.target.value))} />
+
Lenke til VTG påmelding handleChange('vtg_lenke', e.target.value)} />
+
Beskrivelse / Hva er inkludert handleChange('vtg_beskrivelse', e.target.value)} />
+
+
handleChange('vtg_datoer', v)}
+ />
+
+
+
+
handleChange('amenities', v)} />
+ handleChange('nsg_data', v)} />
+ handleChange('golfamore_data', v)} />
+ {/* HER ER GOLFPAKKENE SOM JEG MISTET I FORRIGE RUNDE */}
)}
- {/* BANER & SCOREKORT MED NY GRAFISK BYGGER */}
{activeTab === 'baner' && (
diff --git a/kode_eksport_1/frontend_src_app_admin_vtg_page_tsx.txt b/kode_eksport_1/frontend_src_app_admin_vtg_page_tsx.txt
new file mode 100644
index 0000000..11303a1
--- /dev/null
+++ b/kode_eksport_1/frontend_src_app_admin_vtg_page_tsx.txt
@@ -0,0 +1,208 @@
+"use client";
+import { useState, useEffect } from 'react';
+import { API_URL } from "@/config/constants";
+import Link from 'next/link';
+
+export default function VtgWasher() {
+ const [drafts, setDrafts] = useState
([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedIds, setSelectedIds] = useState([]);
+ const [saving, setSaving] = useState(false);
+
+ const fetchDrafts = () => {
+ setLoading(true);
+ fetch(`${API_URL}/admin/vtg/drafts`)
+ .then(res => res.json())
+ .then(data => {
+ const editableDrafts = data.map((f: any) => {
+ let parsedDraft = f.vtg_draft;
+ if (typeof parsedDraft === 'string') {
+ try { parsedDraft = JSON.parse(parsedDraft); }
+ catch (e) { console.error("Kunne ikke parse JSON", e); }
+ }
+
+ return {
+ ...f,
+ vtg_draft: parsedDraft,
+ edit_pris: parsedDraft?.foreslatt_vtg_pris || f.vtg_pris || '',
+ edit_beskrivelse: parsedDraft?.foreslatt_vtg_beskrivelse || f.vtg_beskrivelse || '',
+ edit_datoer: parsedDraft?.foreslatt_vtg_datoer || []
+ };
+ });
+ 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 updateField = (facilityId: number, field: string, value: any) => {
+ setDrafts(drafts.map(d => d.id === facilityId ? { ...d, [field]: value } : d));
+ };
+
+ const updateDateRow = (facilityId: number, rowIndex: number, field: string, value: string) => {
+ setDrafts(drafts.map(d => {
+ if (d.id === facilityId) {
+ const newDates = [...d.edit_datoer];
+ newDates[rowIndex] = { ...newDates[rowIndex], [field]: value };
+ return { ...d, edit_datoer: newDates };
+ }
+ return d;
+ }));
+ };
+
+ const addDateRow = (facilityId: number) => {
+ setDrafts(drafts.map(d => {
+ if (d.id === facilityId) {
+ return { ...d, edit_datoer: [...d.edit_datoer, { dato: '', status: 'Ledig' }] };
+ }
+ return d;
+ }));
+ };
+
+ const removeDateRow = (facilityId: number, rowIndex: number) => {
+ setDrafts(drafts.map(d => {
+ if (d.id === facilityId) {
+ const newDates = [...d.edit_datoer];
+ newDates.splice(rowIndex, 1);
+ return { ...d, edit_datoer: newDates };
+ }
+ return d;
+ }));
+ };
+
+ const handleApprove = async () => {
+ const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({
+ facility_id: d.id,
+ vtg_pris: Number(d.edit_pris) || null,
+ vtg_beskrivelse: d.edit_beskrivelse,
+ vtg_datoer: d.edit_datoer
+ }));
+
+ if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne.");
+
+ setSaving(true);
+ try {
+ const res = await fetch(`${API_URL}/admin/vtg/approve-bulk`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ approvals: toApprove })
+ });
+ if (res.ok) {
+ alert(`${toApprove.length} anlegg oppdatert!`);
+ setSelectedIds([]);
+ fetchDrafts();
+ } else {
+ alert("Noe gikk galt under lagring.");
+ }
+ } catch (e) {
+ alert("Nettverksfeil");
+ }
+ setSaving(false);
+ };
+
+ if (loading) return Laster VTG-utkast...
;
+
+ return (
+
+
+
+
+
← Tilbake til oversikten
+
VTG-Vaskeriet
+
Gå gjennom og godkjenn kursinformasjon for Veien til Golf.
+
+
+ {saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
+
+
+
+ {drafts.length === 0 ? (
+
+ 🧹
+
Ingen ventende VTG-utkast!
+
+ ) : (
+
+
+ 0} onChange={(e) => toggleSelectAll(e.target.checked)} />
+ Velg Alle
+
+
+ {drafts.map(draft => (
+
+
+
toggleOne(draft.id)} />
+
+
+
+ {draft.vtg_draft?.ai_begrunnelse && (
+
+ 🤖 AI Begrunnelse: {draft.vtg_draft.ai_begrunnelse}
+
+ )}
+
+
+ {/* Pris & Beskrivelse */}
+
+
Pris & Beskrivelse
+
+ Standardpris for Voksen (kr)
+ updateField(draft.id, 'edit_pris', e.target.value)} placeholder="Eks: 1990" />
+
+
+ Selgende tekst / Inkludert i kurset
+ updateField(draft.id, 'edit_beskrivelse', e.target.value)} placeholder="Beskriv kurset..." />
+
+
+
+ {/* Kursdatoer */}
+
+
Kursdatoer
+
+ {draft.edit_datoer.length === 0 ? (
+
Fant ingen spesifikke kursdatoer.
+ ) : (
+ draft.edit_datoer.map((row: any, idx: number) => (
+
+ updateDateRow(draft.id, idx, 'dato', e.target.value)} placeholder="F.eks: 12.-14. mai" />
+ updateDateRow(draft.id, idx, 'status', e.target.value)}>
+ Ledig
+ Fulltegnet
+ Venteliste
+ Få plasser
+
+ removeDateRow(draft.id, idx)} className="text-red-400 hover:text-red-600 px-2 opacity-0 group-hover:opacity-100 transition-opacity" title="Slett dato">✕
+
+ ))
+ )}
+
addDateRow(draft.id)} className="text-xs font-bold text-[#8bc34a] hover:underline mt-2 inline-block">
+ + Legg til ny dato
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/kode_eksport_1/frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt b/kode_eksport_1/frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt
index ac942a3..96f1b2c 100644
--- a/kode_eksport_1/frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt
+++ b/kode_eksport_1/frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt
@@ -1,12 +1,14 @@
"use client";
/**
- * TEE OFF DETAIL VIEW - COMPLETE v3.22
+ * TEE OFF DETAIL VIEW - COMPLETE v3.4 (FINAL LAYOUT FIX)
* ---------------------------------------------------------------------------
* 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.
+ * NEW: Priser (Medlemskap + Greenfee) i 2-kolonne Grid (xl:grid-cols-2).
+ * NEW: Veien til Golf (VTG) i full bredde under prisene, med robust array-parsing.
* REGEL: Beholder monokrome ikoner, 22/78 layout og robust JSON-parsing.
* ---------------------------------------------------------------------------
*/
@@ -71,6 +73,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
const [showBackToTop, setShowBackToTop] = useState(false);
const [currentSlide, setCurrentSlide] = useState(0);
+ // Robust parser for å hente ut JSONB data fra Postgres trygt
const parseJson = (val: any, fallback: any) => {
if (!val) return fallback;
if (typeof val === 'object') return val;
@@ -82,9 +85,12 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
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, []);
+ // Pris og kurs-arrays
+ const greenfeeRaw = parseJson(facility.greenfee, []);
+ const vtgDatoer = parseJson(facility.vtg_datoer, []);
+
const golfamoreData = parseJson(facility.golfamore_data, {});
const nsgData = parseJson(facility.nsg_data, {});
const socialLinksRaw = parseJson(facility.social_links, []);
@@ -96,13 +102,6 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
const hasGolfamore = facility.golfamore === true;
const hasNSG = facility.nsg_url || (nsgData && Object.keys(nsgData).length > 0);
- const groupedGreenfee: Record = 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";
@@ -157,7 +156,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
{facility.website_url && }
{facility.golfbox_booking_url && }
{facility.golfbox_tournament_url && }
-
+
{facility.weather_url && }
@@ -210,7 +209,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
{facility.email || 'Ikke oppgitt'}
@@ -221,7 +220,6 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
{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] ||
;
return (
@@ -346,7 +344,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
@@ -360,58 +358,168 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
)}
- {/* 8. PRISER & GJESTESPILL */}
-
-
-
Gjestespill
-
- {Object.keys(groupedGreenfee).length > 0 ? (
- Object.entries(groupedGreenfee).map(([bane, priser], idx) => (
-
- {!(bane === "Gjestespill" && Object.keys(groupedGreenfee).length === 1) && (
-
{bane}
- )}
-
-
Voksne
- {priser.map((g, i) => (
-
-
{g.priskategori}
-
kr {g.pris_voksne || '--'},-
+ {/* 8. PRISER (MEDLEMSKAP, GREENFEE & VTG) */}
+
+
+ Priser
+
+
+
+
+ {/* VENSTRE KOLONNE: MEDLEMSKAP */}
+ {(facility.standard_medlemskap || facility.rimeligste_alternativ) && (
+
+
+
+ ⛳ Medlemskap
+
+ {facility.medlemskap_url && (
+
+ Se alle →
+
+ )}
+
+
+
+ {facility.standard_medlemskap && (
+
+
Mest valgte
+
Standard
+
{facility.standard_medlemskap},-
+ {facility.standard_medlemskap_navn &&
{facility.standard_medlemskap_navn}
}
+ {facility.standard_medlemskap_kommentarer && (
+
+ {facility.standard_medlemskap_kommentarer.split('\n').map((line: string, i: number) => (
+ {line}
+ ))}
+
+ )}
- ))}
-
- {priser.some(g => g.pris_junior) && (
-
-
Junior
- {priser.map((g, i) => (
-
- {g.priskategori}
- kr {g.pris_junior || '--'},-
-
- ))}
-
- )}
-
- ))
- ) :
Ingen priser funnet.
}
-
- Krav: {facility.guest_requirements || 'Klubbhandicap'}
-
-
-
-
-
Medlemskap
-
-
{facility.navn_standard_medlemskap || "Standard"}
-
kr {facility.standard_medlemskap || '--'},-
- {facility.standard_medlemskap_kommentarer &&
{facility.standard_medlemskap_kommentarer}
}
-
- {facility.navn_rimeligste_alternativ && (
-
{facility.navn_rimeligste_alternativ} kr {facility.rimeligste_alternativ},-
+ )}
+
+ {facility.rimeligste_alternativ && (
+
+
Rimeligste golfkort
+
{facility.rimeligste_alternativ},-
+ {facility.rimeligste_navn &&
{facility.rimeligste_navn}
}
+
+ )}
+
+
)}
-
-
Se alle alternativer
-
+
+ {/* HØYRE KOLONNE: GREENFEE */}
+ {greenfeeRaw && greenfeeRaw.length > 0 && (
+
+
+
+
+
+
+
+ Bane/Kat.
+ Voksen
+ Junior
+
+
+
+ {greenfeeRaw.map((gf: any, idx: number) => (
+
+
+ {gf.banenavn}
+ {gf.priskategori}
+
+
+ {gf.pris_voksne ? `${gf.pris_voksne},-` : '-'}
+
+
+ {gf.pris_junior ? `${gf.pris_junior},-` : '-'}
+
+
+ ))}
+
+
+
+ {facility.guest_requirements && (
+
+ Krav: {facility.guest_requirements}
+
+ )}
+
+ )}
+
+
+ {/* VEIEN TIL GOLF (VTG) - FULL BREDDE UNDER */}
+ {(facility.vtg_pris || facility.vtg_beskrivelse || (vtgDatoer && vtgDatoer.length > 0)) && (
+
+ {/* Bakgrunnseffekt */}
+
🏌️♂️
+
+
+
+ Nybegynnerkurs (Veien til Golf)
+
+
+ {facility.vtg_beskrivelse && (
+
+ {facility.vtg_beskrivelse}
+
+ )}
+
+
+
+ {/* Pris */}
+ {facility.vtg_pris && (
+
+ Standard voksenpris
+ {facility.vtg_pris},-
+
+ )}
+
+ {/* Datoer */}
+ {vtgDatoer && vtgDatoer.length > 0 && (
+
+
Kommende kurs:
+
+ {vtgDatoer.map((kurs: any, i: number) => {
+ const status = (kurs.status || '').toLowerCase();
+ const isFull = status.includes('full');
+ const isWaitlist = status.includes('vente') || status.includes('få');
+
+ let badgeColor = "bg-white/20 text-white";
+ if (isFull) badgeColor = "bg-red-500/80 text-white line-through opacity-75";
+ if (isWaitlist) badgeColor = "bg-yellow-400 text-[#11280f]";
+
+ return (
+
+ {kurs.dato}
+ {kurs.status}
+
+ );
+ })}
+
+
+ )}
+
+ {/* Påmeldingsknapp */}
+ {facility.vtg_lenke && (
+
+ Påmelding ↗
+
+ )}
+
+
+
+ )}
{/* 9. SCOREKORT SEKSJON */}
diff --git a/kode_eksport_1/test_tjome_py.txt b/kode_eksport_1/test_tjome_py.txt
deleted file mode 100644
index 17dcef1..0000000
--- a/kode_eksport_1/test_tjome_py.txt
+++ /dev/null
@@ -1,44 +0,0 @@
-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())
diff --git a/kode_eksport_3/backend_create_admin_py.txt b/kode_eksport_3/backend_create_admin_py.txt
new file mode 100644
index 0000000..c2d3dcb
--- /dev/null
+++ b/kode_eksport_3/backend_create_admin_py.txt
@@ -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)
\ No newline at end of file
diff --git a/kode_eksport_3/backend_import_gallery_py.txt b/kode_eksport_3/backend_import_gallery_py.txt
new file mode 100644
index 0000000..116aa85
--- /dev/null
+++ b/kode_eksport_3/backend_import_gallery_py.txt
@@ -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())
diff --git a/kode_eksport_3/backend_import_nye_felter_py.txt b/kode_eksport_3/backend_import_nye_felter_py.txt
new file mode 100644
index 0000000..51a9ac3
--- /dev/null
+++ b/kode_eksport_3/backend_import_nye_felter_py.txt
@@ -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())
\ No newline at end of file
diff --git a/kode_eksport_3/backend_import_urls_py.txt b/kode_eksport_3/backend_import_urls_py.txt
new file mode 100644
index 0000000..f0f5ad0
--- /dev/null
+++ b/kode_eksport_3/backend_import_urls_py.txt
@@ -0,0 +1,103 @@
+"""
+TEE OFF - AUTOMATISK URL-IMPORTØR
+---------------------------------------------------------------------------
+Leser tekstfiler med lenker og forsøker å matche dem mot eksisterende
+golfanlegg i databasen basert på domenenavn.
+
+docker compose exec api python import_urls.py
+---------------------------------------------------------------------------
+"""
+
+import asyncio
+import asyncpg
+import os
+from urllib.parse import urlparse
+
+DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
+
+# Hvilke filer vi skal lese, og hvilke databasefelt de tilhører
+FILES_TO_IMPORT = {
+ "Medlemsskap.txt": "medlemskap_url",
+ "GreenFee.txt": "greenfee_url",
+ "VtG.txt": "vtg_lenke"
+}
+
+def extract_domain(url: str) -> str:
+ """Henter ut hoveddomenet (f.eks. 'tyrifjord-golfklubb.no') fra en URL."""
+ try:
+ domain = urlparse(url.strip()).netloc.lower()
+ if domain.startswith("www."):
+ domain = domain[4:]
+ return domain
+ except:
+ return ""
+
+async def run_import():
+ print("🚀 Starter URL-importør...")
+ conn = await asyncpg.connect(DB_URL)
+
+ try:
+ # Hent alle eksisterende anlegg og deres domener
+ facilities = await conn.fetch("SELECT id, name, website_url, scrape_status_url FROM facilities")
+
+ # Bygg en ordbok: { 'domenenavn.no': facility_id } for superraskt oppslag
+ domain_map = {}
+ for f in facilities:
+ # Prøv å hente domene fra website_url
+ if f['website_url']:
+ domain = extract_domain(f['website_url'])
+ if domain: domain_map[domain] = f['id']
+ # Prøv også scrape_status_url for sikkerhets skyld
+ if f['scrape_status_url']:
+ domain = extract_domain(f['scrape_status_url'])
+ if domain: domain_map[domain] = f['id']
+
+ print(f"📋 Fant {len(domain_map)} unike domener i databasen.")
+
+ # Gå gjennom fil for fil
+ for filename, db_field in FILES_TO_IMPORT.items():
+ print(f"\n▶️ BEHANDLER: {filename} -> Setter felt: {db_field}")
+
+ if not os.path.exists(filename):
+ print(f" ⚠️ Filen '{filename}' ble ikke funnet. Hopper over.")
+ continue
+
+ with open(filename, 'r', encoding='utf-8') as file:
+ lines = [line.strip() for line in file.readlines() if line.strip()]
+
+ matched_count = 0
+ unmatched = []
+
+ for line in lines:
+ # Hvis det er flere URL-er på samme linje separert med komma,
+ # matcher vi basert på den FØRSTE URL-en.
+ first_url = line.split(',')[0].strip()
+ domain = extract_domain(first_url)
+
+ # Hvis vi fant en match i databasen!
+ if domain in domain_map:
+ fac_id = domain_map[domain]
+
+ # Oppdater databasen med HELE linjen (for å bevare ev. komma-lenker)
+ await conn.execute(f"""
+ UPDATE facilities
+ SET {db_field} = $1
+ WHERE id = $2
+ """, line, fac_id)
+ matched_count += 1
+ else:
+ unmatched.append(line)
+
+ print(f" ✅ Matchet og oppdatert {matched_count} anlegg.")
+
+ if unmatched:
+ print(f" ❌ Følgende {len(unmatched)} URL-er fant ingen match i databasen og må legges inn manuelt:")
+ for url in unmatched:
+ print(f" - {url}")
+
+ finally:
+ await conn.close()
+ print("\n🏁 Import fullført!")
+
+if __name__ == "__main__":
+ asyncio.run(run_import())
\ No newline at end of file
diff --git a/kode_eksport_3/backend_import_wp_py.txt b/kode_eksport_3/backend_import_wp_py.txt
new file mode 100644
index 0000000..3eac73e
--- /dev/null
+++ b/kode_eksport_3/backend_import_wp_py.txt
@@ -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('&', '&').replace('&', '&').replace(' ', ' ').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())
\ No newline at end of file
diff --git a/kode_eksport_3/backend_main_py.txt b/kode_eksport_3/backend_main_py.txt
new file mode 100644
index 0000000..5688610
--- /dev/null
+++ b/kode_eksport_3/backend_main_py.txt
@@ -0,0 +1,663 @@
+"""
+TEE OFF BACKEND API v3.8.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]
+
+class QuickEditRequest(BaseModel):
+ field: str
+ value: str
+
+class GreenfeeApproval(BaseModel):
+ facility_id: int
+ greenfee: List[dict]
+
+
+class VtgApproval(BaseModel):
+ facility_id: int
+ vtg_pris: int | None
+ vtg_beskrivelse: str | None
+ vtg_datoer: List[dict] | None
+
+class BulkVtgRequest(BaseModel):
+ approvals: List[VtgApproval]
+# --- 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', 'vtg_datoer'
+ ]
+ 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}")
+
+def run_membership_worker(facility_ids: List[int]):
+ """Kjører medlemskap-skraperen i bakgrunnen."""
+ print(f"🔄 STARTER MEDLEMSKAP-SKRAPING FOR IDER: {facility_ids}")
+ try:
+ ids_arg = ",".join(map(str, facility_ids))
+ command = f"python -u scrape_membership.py --ids {ids_arg}"
+ subprocess.run(command, shell=True, check=True)
+ print(f"✅ MEDLEMSKAP-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
+ except Exception as e:
+ print(f"🔥 FEIL UNDER MEDLEMSKAP-SKRAPING: {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.8.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',
+ 'greenfee_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector', 'vtg_lenke'
+ ]
+
+ 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 = []
+
+ # Definer hvilke felt som er datoer i databasen
+ date_fields = ['membership_updated_at', 'greenfee_updated_at', 'vtg_updated_at', 'status_updated_at']
+
+ 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))
+ elif k in date_fields:
+ set_clauses.append(f"{k} = ${i}")
+ # Håndter tomme datoer og konverter til Python datetime
+ if v == "" or v is None:
+ values.append(None)
+ else:
+ # Tving strengen over til et ekte datetime-objekt.
+ # .replace() håndterer Next.js' "Z"-format.
+ dt_str = str(v).replace("Z", "+00:00")
+ try:
+ dt_obj = datetime.fromisoformat(dt_str)
+ values.append(dt_obj)
+ except ValueError:
+ values.append(None)
+ 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_str = course.get('slope_valid_until')
+ if valid_until_str == "" or valid_until_str is None:
+ valid_until = None
+ else:
+ # Gjør om strengen til et ekte date-objekt for asyncpg
+ try:
+ date_part = valid_until_str.split('T')[0]
+ valid_until = datetime.strptime(date_part, "%Y-%m-%d").date()
+ except ValueError:
+ 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.post("/api/admin/run-membership-scraper")
+async def run_membership_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
+ """Tar imot IDer for medlemskapsskraping og legger jobben i kø."""
+ if not request.facility_ids:
+ raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
+
+ print(f"📡 API mottok forespørsel om medlemskapsskraping for IDer: {request.facility_ids}")
+ background_tasks.add_task(run_membership_worker, request.facility_ids)
+
+ return {"status": "queued", "message": f"Medlemskapsskraping 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!"}
+
+@app.patch("/api/admin/facilities/{facility_id}/quick-edit")
+async def quick_edit_facility(facility_id: int, request: QuickEditRequest):
+ """Lyn-redigering av enkle URL-felter fra admin-dashbordet."""
+ # Sikkerhet: Tillat KUN disse tre feltene for hurtigredigering
+ allowed_fields = ['scrape_status_url', 'medlemskap_url', 'scrape_status_selector']
+ if request.field not in allowed_fields:
+ raise HTTPException(status_code=400, detail="Ugyldig felt for hurtigredigering.")
+
+ async with app.state.pool.acquire() as conn:
+ # F-string her er trygt fordi request.field er sjekket mot allowed_fields-listen
+ await conn.execute(f"UPDATE facilities SET {request.field} = $1 WHERE id = $2",
+ request.value, facility_id)
+ return {"status": "success"}
+
+# --- GREENFEE "VASKERI" ENDEPUNKTER ---
+
+@app.get("/api/admin/greenfee/drafts")
+async def get_greenfee_drafts():
+ """Henter alle anlegg som har et ventende greenfee-forslag fra AI-skraperen."""
+ async with app.state.pool.acquire() as conn:
+ rows = await conn.fetch("""
+ SELECT id, name, slug, greenfee_url, greenfee, greenfee_draft
+ FROM facilities
+ WHERE greenfee_draft IS NOT NULL
+ AND greenfee_draft::text != '{}'
+ ORDER BY name ASC
+ """)
+ return [format_row(row) for row in rows]
+
+class BulkGreenfeeRequest(BaseModel):
+ approvals: List[GreenfeeApproval]
+
+@app.post("/api/admin/greenfee/approve-bulk")
+async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
+ """Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet."""
+ async with app.state.pool.acquire() as conn:
+ async with conn.transaction():
+ for approval in request.approvals:
+ await conn.execute("""
+ UPDATE facilities
+ SET greenfee = $1::jsonb,
+ greenfee_updated_at = NOW(),
+ greenfee_draft = NULL
+ WHERE id = $2
+ """, json.dumps(approval.greenfee), approval.facility_id)
+ return {"status": "success"}
+
+def run_greenfee_worker(facility_ids: List[int]):
+ """Kjører greenfee-skraperen i bakgrunnen."""
+ print(f"🔄 STARTER GREENFEE-SKRAPING FOR IDER: {facility_ids}")
+ try:
+ import subprocess
+ ids_arg = ",".join(map(str, facility_ids))
+ command = f"python -u scrape_greenfee.py --ids {ids_arg}"
+ subprocess.run(command, shell=True, check=True)
+ print(f"✅ GREENFEE-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
+ except Exception as e:
+ print(f"🔥 FEIL UNDER GREENFEE-SKRAPING: {e}")
+
+@app.post("/api/admin/run-greenfee-scraper")
+async def run_greenfee_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
+ """Tar imot IDer for greenfeeskraping og legger jobben i kø."""
+ if not request.facility_ids:
+ raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
+ background_tasks.add_task(run_greenfee_worker, request.facility_ids)
+ return {"status": "queued", "message": "Skraping startet"}
+
+# --- VEIEN TIL GOLF (VTG) "VASKERI" ENDEPUNKTER ---
+
+@app.get("/api/admin/vtg/drafts")
+async def get_vtg_drafts():
+ """Henter alle anlegg som har et ventende VTG-forslag."""
+ async with app.state.pool.acquire() as conn:
+ rows = await conn.fetch("""
+ SELECT id, name, slug, vtg_lenke, vtg_pris, vtg_beskrivelse, vtg_datoer, vtg_draft
+ FROM facilities
+ WHERE vtg_draft IS NOT NULL
+ AND vtg_draft::text != '{}'
+ ORDER BY name ASC
+ """)
+ return [format_row(row) for row in rows]
+
+@app.post("/api/admin/vtg/approve-bulk")
+async def approve_vtg_bulk(request: BulkVtgRequest):
+ """Godkjenner AI-forslag for VTG, setter oppdatert-dato og sletter utkastet."""
+ async with app.state.pool.acquire() as conn:
+ async with conn.transaction():
+ for approval in request.approvals:
+ datoer_json = json.dumps(approval.vtg_datoer) if approval.vtg_datoer is not None else '[]'
+ await conn.execute("""
+ UPDATE facilities
+ SET vtg_pris = $1,
+ vtg_beskrivelse = $2,
+ vtg_datoer = $3::jsonb,
+ vtg_updated_at = NOW(),
+ vtg_draft = NULL
+ WHERE id = $4
+ """, approval.vtg_pris, approval.vtg_beskrivelse, datoer_json, approval.facility_id)
+ return {"status": "success"}
+
+def run_vtg_worker(facility_ids: List[int]):
+ """Kjører VTG-skraperen i bakgrunnen."""
+ print(f"🔄 STARTER VTG-SKRAPING FOR IDER: {facility_ids}")
+ try:
+ import subprocess
+ ids_arg = ",".join(map(str, facility_ids))
+ command = f"python -u scrape_vtg.py --ids {ids_arg}"
+ subprocess.run(command, shell=True, check=True)
+ print(f"✅ VTG-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
+ except Exception as e:
+ print(f"🔥 FEIL UNDER VTG-SKRAPING: {e}")
+
+@app.post("/api/admin/run-vtg-scraper")
+async def run_vtg_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
+ """Tar imot IDer for VTG-skraping og legger jobben i kø."""
+ if not request.facility_ids:
+ raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
+ background_tasks.add_task(run_vtg_worker, request.facility_ids)
+ return {"status": "queued", "message": "Skraping startet"}
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run(app, host="0.0.0.0", port=8000)
\ No newline at end of file
diff --git a/kode_eksport_3/backend_scrape_golfamore1_3_py.txt b/kode_eksport_3/backend_scrape_golfamore1_3_py.txt
new file mode 100644
index 0000000..fd418e0
--- /dev/null
+++ b/kode_eksport_3/backend_scrape_golfamore1_3_py.txt
@@ -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())
diff --git a/kode_eksport_3/backend_scrape_greenfee_py.txt b/kode_eksport_3/backend_scrape_greenfee_py.txt
new file mode 100644
index 0000000..edf796f
--- /dev/null
+++ b/kode_eksport_3/backend_scrape_greenfee_py.txt
@@ -0,0 +1,173 @@
+"""
+TEE OFF - GREENFEE-SKRAPER MED GEMINI AI
+---------------------------------------------------------------------------
+Henter alle greenfee-varianter fra en (eller flere) URL-er og strukturerer
+dem i en JSON-liste. Finner også avtaleklubber/vennskapsklubber.
+---------------------------------------------------------------------------
+"""
+
+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
+
+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!")
+
+genai.configure(api_key=GEMINI_API_KEY)
+model = genai.GenerativeModel('gemini-2.5-flash')
+
+async def fetch_page_text(url: str, browser) -> str:
+ url = url.strip()
+ if not url.startswith("http"):
+ return ""
+
+ print(f" 🌐 Laster inn: {url}")
+ try:
+ page = await browser.new_page()
+ await page.goto(url, wait_until="domcontentloaded", timeout=15000)
+ html_content = await page.content()
+ await page.close()
+
+ soup = BeautifulSoup(html_content, 'html.parser')
+ for script in soup(["script", "style", "nav", "footer", "header"]):
+ script.extract()
+
+ return soup.get_text(separator=' ', strip=True)
+ except Exception as e:
+ print(f" ❌ Feil ved lasting av {url}: {e}")
+ return ""
+
+def analyze_greenfee_with_gemini(text: str, club_name: str) -> dict:
+ print(f" 🧠 Sender {len(text)} tegn til Gemini for greenfee-analyse...")
+
+ prompt = f"""
+Du er en ekspert på norske golfklubber og prissetting.
+Din oppgave er å lese teksten hentet fra nettsidene til "{club_name}" og hente ut TO ting:
+1. ALLE varianter av greenfee-priser.
+2. Navn på eventuelle vennskapsklubber/avtaleklubber (hvis nevnt).
+
+REGLER FOR GREENFEE:
+- Trekk ut absolutt alle priskategorier du finner (f.eks. "Hverdag høysesong", "Helg før kl 14", "Gjest av medlem", "9 hull kveld", osv.).
+- Finn både voksenpris og juniorpris for hver kategori.
+- HVIS juniorpris er oppgitt som en regel (f.eks. "Juniorer betaler halv pris" eller "50% rabatt for junior"), MÅ du selv regne ut prisen og skrive inn heltallet.
+- "banenavn": Bruk navnet på banen hvis det er spesifisert (f.eks. "18-hullsbanen", "Korthullsbanen"). Hvis ikke spesifisert, bruk "{club_name}".
+- Priser SKAL være tall (integer). Sett pris til null (null) hvis den ikke finnes.
+
+REGLER FOR AVTALEKLUBBER:
+- Let etter overskrifter som "Vennskapsklubber", "Avtaleklubber", "Gjestespill", "Samarbeidsklubber".
+- Trekk ut kun navnene på klubbene i en liste (f.eks. ["Haga GK", "Oslo GK"]). La listen være tom hvis du ikke finner noen.
+
+TEKST FRA NETTSIDEN:
+{text}
+
+OPPGAVE:
+Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur:
+{{
+ "foreslatt_greenfee": [
+ {{
+ "banenavn": "Navn på banen",
+ "priskategori": "F.eks: Hverdag Gjest av Medlem",
+ "pris_voksne": 600,
+ "pris_junior": 300
+ }}
+ ],
+ "foreslatt_avtaleklubber": [
+ "Klubb 1 GK",
+ "Klubb 2 GK"
+ ],
+ "ai_begrunnelse": "Kort forklaring, f.eks: 'Fant et komplekst prissystem for høy/lavsesong. Regnet ut juniorpriser til 50% som angitt i teksten. Fant 3 samarbeidsklubber nederst.'"
+}}
+"""
+
+ try:
+ response = model.generate_content(prompt)
+ raw_response = response.text.strip()
+
+ 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_greenfee_scraper(facility_ids=None):
+ print("🚀 Starter Greenfee-skraperen...")
+ conn = await asyncpg.connect(DB_URL)
+
+ try:
+ query = "SELECT id, name, greenfee_url FROM facilities WHERE greenfee_url IS NOT NULL AND greenfee_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.")
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+
+ for facility in facilities:
+ fac_id = facility['id']
+ name = facility['name']
+ urls_raw = facility['greenfee_url']
+
+ print(f"\n▶️ Behandler Greenfee for: {name} (ID: {fac_id})")
+
+ urls = [u.strip() for u in urls_raw.split(',')]
+ combined_text = ""
+
+ for idx, url in enumerate(urls, 1):
+ page_text = await fetch_page_text(url, browser)
+ if page_text:
+ combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
+
+ if len(combined_text) < 50:
+ print(" ⚠️ Fant for lite tekst, hopper over.")
+ continue
+
+ draft_data = analyze_greenfee_with_gemini(combined_text[:25000], name)
+
+ if not draft_data:
+ continue
+
+ funnet_priser = len(draft_data.get('foreslatt_greenfee', []))
+ funnet_klubber = len(draft_data.get('foreslatt_avtaleklubber', []))
+ print(f" ✅ AI fant {funnet_priser} greenfee-varianter og {funnet_klubber} avtaleklubber.")
+
+ await conn.execute("""
+ UPDATE facilities
+ SET greenfee_draft = $1::jsonb
+ WHERE id = $2
+ """, json.dumps(draft_data), fac_id)
+
+ print(" 💾 Greenfee-utkast lagret i databasen!")
+
+ await browser.close()
+
+ finally:
+ await conn.close()
+ print("\n🏁 Skraping fullført.")
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Skrap greenfeepriser 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_greenfee_scraper(ids_to_scrape))
\ No newline at end of file
diff --git a/kode_eksport_3/backend_scrape_membership_py.txt b/kode_eksport_3/backend_scrape_membership_py.txt
new file mode 100644
index 0000000..2fb99fe
--- /dev/null
+++ b/kode_eksport_3/backend_scrape_membership_py.txt
@@ -0,0 +1,168 @@
+"""
+TEE OFF - MEDLEMSKAPSSKRAPER MED GEMINI AI (MULTI-URL VERSJON)
+---------------------------------------------------------------------------
+Går til oppgitte medlemskaps-URLer (støtter flere URLer adskilt med komma),
+henter ut tekst, og bruker Gemini til å summere og finne 'Standard' og
+'Rimeligste' medlemskap.
+---------------------------------------------------------------------------
+"""
+
+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
+
+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!")
+
+genai.configure(api_key=GEMINI_API_KEY)
+model = genai.GenerativeModel('gemini-2.5-flash')
+
+async def fetch_page_text(url: str, browser) -> str:
+ """Bruker Playwright for å hente all synlig tekst fra EN nettside."""
+ url = url.strip()
+ if not url.startswith("http"):
+ return ""
+
+ print(f" 🌐 Laster inn: {url}")
+ try:
+ page = await browser.new_page()
+ await page.goto(url, wait_until="domcontentloaded", timeout=15000)
+ html_content = await page.content()
+ await page.close()
+
+ soup = BeautifulSoup(html_content, 'html.parser')
+ for script in soup(["script", "style", "nav", "footer", "header"]):
+ script.extract()
+
+ text = soup.get_text(separator=' ', strip=True)
+ return text
+ except Exception as e:
+ print(f" ❌ Feil ved lasting av {url}: {e}")
+ return ""
+
+def analyze_with_gemini(text: str, club_name: str) -> dict:
+ """Sender den kombinerte teksten til Gemini for å trekke ut og evt. summere priser."""
+ print(f" 🧠 Sender {len(text)} tegn til Gemini for analyse...")
+
+ prompt = f"""
+Du er en ekspert på norske golfklubber. Din oppgave er å lese teksten hentet fra nettsidene til "{club_name}" og finne to spesifikke priser.
+
+VIKTIG REGEL OM NORSK GOLF:
+Mange steder er "Klubbkontingent/Medlemskap" og "Spillerett/Årskort" to forskjellige ting.
+For å få spille ubegrenset (Fritt spill) MÅ man betale BEGGE DELER. Hvis du ser at prisene for kontingent og spillerett er oppgitt hver for seg, SKAL DU SUMMERE disse to summene og bruke totalen som "Standard pris".
+
+ALDERSPREMISS FOR BEGGE PRISER:
+Vi forutsetter at personen som skal ha medlemskap er en VOKSEN GOLFER PÅ MINST 35 ÅR. Du må ALDRI velge priser som gjelder for barn, junior, ung voksen (f.eks. 20-29 år), student eller senior/pensjonist.
+
+DEFINISJONER DU MÅ FØLGE STRENGT:
+1. "Standard medlemskap": Hva er TOTALPRISEN (inkludert evt. spillerett/årskort) for en voksen person (35+ år) for å spille SÅ MYE VEDKOMMENDE ØNSKER (Fritt spill) i år?
+2. "Rimeligste alternativ": Det absolutt billigste alternativet FOR EN VOKSEN PERSON (35+ år) som gir medlemskap i klubben (golfkortet), forutsatt at man betaler greenfee for hver runde. (Ofte kalt Greenfeemedlem, Postkassemedlem, Fjernmedlem, eller kun "Klubbkontingent for voksne" uten spillerett).
+
+TEKST FRA NETTSIDEN(E):
+{text}
+
+OPPGAVE:
+Returner KUN et gyldig JSON-objekt med følgende struktur:
+{{
+ "foreslatt_standard_navn": "Navn (eks: Hovedmedlem Voksen inkl. spillerett)",
+ "foreslatt_standard_pris": 1234,
+ "foreslatt_standard_kommentar": "Kort kommentar (eks: Måtte summere kontingent på 900 og årskort på 5000)",
+ "foreslatt_rimeligste_navn": "Navn (eks: Greenfeemedlemskap Voksen)",
+ "foreslatt_rimeligste_pris": 500,
+ "ai_begrunnelse": "Kort forklaring på utregningen din."
+}}
+Merk: Prisene SKAL være tall (integer), ikke tekst. Sett til null hvis du ikke finner det.
+"""
+
+ try:
+ response = model.generate_content(prompt)
+ raw_response = response.text.strip()
+
+ 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):
+ print("🚀 Starter Medlemskaps-skraperen (Støtter multi-URL)...")
+ conn = await asyncpg.connect(DB_URL)
+
+ try:
+ 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.")
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+
+ for facility in facilities:
+ fac_id = facility['id']
+ name = facility['name']
+ urls_raw = facility['medlemskap_url']
+
+ print(f"\n▶️ Behandler: {name} (ID: {fac_id})")
+
+ # Sjekker om det er flere URL-er adskilt med komma
+ urls = [u.strip() for u in urls_raw.split(',')]
+ combined_text = ""
+
+ for idx, url in enumerate(urls, 1):
+ page_text = await fetch_page_text(url, browser)
+ if page_text:
+ combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
+
+ if len(combined_text) < 50:
+ print(" ⚠️ Fant for lite tekst, hopper over.")
+ continue
+
+ # Kutter teksten for å ikke overbelaste Gemini (ca 25000 tegn maks)
+ draft_data = analyze_with_gemini(combined_text[:25000], name)
+
+ if not draft_data:
+ continue
+
+ 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!")
+
+ await browser.close()
+
+ 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))
\ No newline at end of file
diff --git a/kode_eksport_3/backend_scrape_nsg_3_py.txt b/kode_eksport_3/backend_scrape_nsg_3_py.txt
new file mode 100644
index 0000000..190b12d
--- /dev/null
+++ b/kode_eksport_3/backend_scrape_nsg_3_py.txt
@@ -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'
(https://seniorgolf.no/lojalitetskort/.*?/) ', 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())
diff --git a/kode_eksport_3/backend_scrape_status_py.txt b/kode_eksport_3/backend_scrape_status_py.txt
new file mode 100644
index 0000000..fb12838
--- /dev/null
+++ b/kode_eksport_3/backend_scrape_status_py.txt
@@ -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))
\ No newline at end of file
diff --git a/kode_eksport_3/backend_scrape_vtg_py.txt b/kode_eksport_3/backend_scrape_vtg_py.txt
new file mode 100644
index 0000000..797545d
--- /dev/null
+++ b/kode_eksport_3/backend_scrape_vtg_py.txt
@@ -0,0 +1,161 @@
+"""
+TEE OFF - VEIEN TIL GOLF (VTG) SKRAPER MED GEMINI AI
+---------------------------------------------------------------------------
+Henter pris, beskrivelse (inkl. lånekøller/medlemskap) og kursdatoer fra VTG-sider.
+Støtter kommaseparerte URL-er.
+---------------------------------------------------------------------------
+"""
+
+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
+
+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!")
+
+genai.configure(api_key=GEMINI_API_KEY)
+model = genai.GenerativeModel('gemini-2.5-flash')
+
+async def fetch_page_text(url: str, browser) -> str:
+ url = url.strip()
+ if not url.startswith("http"):
+ return ""
+
+ print(f" 🌐 Laster inn: {url}")
+ try:
+ page = await browser.new_page()
+ await page.goto(url, wait_until="domcontentloaded", timeout=15000)
+ html_content = await page.content()
+ await page.close()
+
+ soup = BeautifulSoup(html_content, 'html.parser')
+ for script in soup(["script", "style", "nav", "footer", "header"]):
+ script.extract()
+
+ return soup.get_text(separator=' ', strip=True)
+ except Exception as e:
+ print(f" ❌ Feil ved lasting av {url}: {e}")
+ return ""
+
+def analyze_vtg_with_gemini(text: str, club_name: str) -> dict:
+ print(f" 🧠 Sender {len(text)} tegn til Gemini for VTG-analyse...")
+
+ prompt = f"""
+Du er en ekspert på norske golfklubber. Din oppgave er å lese en lang tekst fra nettsidene til "{club_name}" og koke dette ned til essensen om deres "Veien til Golf" (VTG) nybegynnerkurs.
+
+OPPGAVER:
+1. Finn standardprisen for VTG-kurset for en vanlig voksen person. (Returner KUN tallet).
+2. Skriv en KOMPRIMERT, selgende beskrivelse (maks 3-4 setninger). Du MÅ inkludere informasjon om:
+ - Er lån av køller/utstyr inkludert i kurset?
+ - Inkluderer prisen et medlemskap/spillerett i klubben (og ev. for hvor lenge)?
+ - Hva er omfanget? (F.eks. "12 timer praksis pluss e-læring").
+ Ignorer uvesentlig støy og lange historiske utgreiinger.
+3. Finn alle kommende kursdatoer. Finn startdato/sluttdato for hvert kurs, og noter status ("Ledig", "Fulltegnet", "Venteliste").
+
+TEKST FRA NETTSIDEN:
+{text}
+
+OPPGAVE:
+Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur:
+{{
+ "foreslatt_vtg_pris": 1990,
+ "foreslatt_vtg_beskrivelse": "Kurset går over 12 timer inkludert obligatorisk e-læring. Lån av golfkøller er inkludert under hele kurset, og prisen gir deg også fritt spill og medlemskap ut året.",
+ "foreslatt_vtg_datoer": [
+ {{"dato": "12.-14. mai", "status": "Fulltegnet"}},
+ {{"dato": "5.-7. juni", "status": "Ledig"}}
+ ],
+ "ai_begrunnelse": "Fant voksenpris på 1990,-. Teksten nevnte eksplisitt at medlemskap ut året er med i prisen, og at man får låne utstyr."
+}}
+Merk: Sett foreslatt_vtg_pris til null (null) hvis du ikke finner den. Hvis du ikke finner datoer, la listen være tom [].
+"""
+
+ try:
+ response = model.generate_content(prompt)
+ raw_response = response.text.strip()
+
+ 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_vtg_scraper(facility_ids=None):
+ print("🚀 Starter Veien til Golf (VTG) skraperen...")
+ conn = await asyncpg.connect(DB_URL)
+
+ try:
+ query = "SELECT id, name, vtg_lenke FROM facilities WHERE vtg_lenke IS NOT NULL AND vtg_lenke != ''"
+ 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.")
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+
+ for facility in facilities:
+ fac_id = facility['id']
+ name = facility['name']
+ urls_raw = facility['vtg_lenke']
+
+ print(f"\n▶️ Behandler VTG for: {name} (ID: {fac_id})")
+
+ urls = [u.strip() for u in urls_raw.split(',')]
+ combined_text = ""
+
+ for idx, url in enumerate(urls, 1):
+ page_text = await fetch_page_text(url, browser)
+ if page_text:
+ combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
+
+ if len(combined_text) < 50:
+ print(" ⚠️ Fant for lite tekst, hopper over.")
+ continue
+
+ draft_data = analyze_vtg_with_gemini(combined_text[:25000], name)
+
+ if not draft_data:
+ continue
+
+ print(f" ✅ AI fant pris: {draft_data.get('foreslatt_vtg_pris')}, og {len(draft_data.get('foreslatt_vtg_datoer', []))} datoer.")
+
+ await conn.execute("""
+ UPDATE facilities
+ SET vtg_draft = $1::jsonb
+ WHERE id = $2
+ """, json.dumps(draft_data), fac_id)
+
+ print(" 💾 VTG-utkast lagret i databasen!")
+
+ await browser.close()
+
+ finally:
+ await conn.close()
+ print("\n🏁 Skraping fullført.")
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Skrap VTG 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_vtg_scraper(ids_to_scrape))
\ No newline at end of file
diff --git a/kode_eksport_3/backend_sync_greenfee_py.txt b/kode_eksport_3/backend_sync_greenfee_py.txt
new file mode 100644
index 0000000..745a80e
--- /dev/null
+++ b/kode_eksport_3/backend_sync_greenfee_py.txt
@@ -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('&', '&').replace('&', '&').replace(' ', ' ').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())
\ No newline at end of file
diff --git a/kode_eksport_3/backend_test_gemini_py.txt b/kode_eksport_3/backend_test_gemini_py.txt
new file mode 100644
index 0000000..1141ed3
--- /dev/null
+++ b/kode_eksport_3/backend_test_gemini_py.txt
@@ -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())
\ No newline at end of file
diff --git a/kode_eksport_3/backend_test_login_py.txt b/kode_eksport_3/backend_test_login_py.txt
new file mode 100644
index 0000000..f026cbf
--- /dev/null
+++ b/kode_eksport_3/backend_test_login_py.txt
@@ -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())
\ No newline at end of file
diff --git a/kode_eksport_3/backend_update_admin_py.txt b/kode_eksport_3/backend_update_admin_py.txt
new file mode 100644
index 0000000..4883f01
--- /dev/null
+++ b/kode_eksport_3/backend_update_admin_py.txt
@@ -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)
\ No newline at end of file
diff --git a/kode_eksport_3/eksport_script_py.txt b/kode_eksport_3/eksport_script_py.txt
new file mode 100644
index 0000000..9db330a
--- /dev/null
+++ b/kode_eksport_3/eksport_script_py.txt
@@ -0,0 +1,72 @@
+import os
+import shutil
+from pathlib import Path
+
+# --- KONFIGURASJON ---
+KILDE_MAPPE = "/opt/teeoff/"
+EKSPORT_MAPPE = "/opt/teeoff/kode_eksport_3/"
+TRE_FIL = "/opt/teeoff/fil-tre-3.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()
diff --git a/kode_eksport_3/frontend_next-env_d_ts.txt b/kode_eksport_3/frontend_next-env_d_ts.txt
new file mode 100644
index 0000000..c4b7818
--- /dev/null
+++ b/kode_eksport_3/frontend_next-env_d_ts.txt
@@ -0,0 +1,6 @@
+///
+///
+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.
diff --git a/kode_eksport_3/frontend_next_config_ts.txt b/kode_eksport_3/frontend_next_config_ts.txt
new file mode 100644
index 0000000..e9ffa30
--- /dev/null
+++ b/kode_eksport_3/frontend_next_config_ts.txt
@@ -0,0 +1,7 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ /* config options here */
+};
+
+export default nextConfig;
diff --git a/kode_eksport_3/frontend_src_app_FacilitySearch_tsx.txt b/kode_eksport_3/frontend_src_app_FacilitySearch_tsx.txt
new file mode 100644
index 0000000..ad1ce74
--- /dev/null
+++ b/kode_eksport_3/frontend_src_app_FacilitySearch_tsx.txt
@@ -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 (
+
+
+ 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
+
+
+
+
setSearchQuery(e.target.value)} />
+
+
+ {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 (
+
+
+
+
+ {/* Status Badges for ALLE baner på anlegget */}
+
+ {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 (
+
+
+ {sArr.length > 1 && (
+
+ {course.name}
+
+ )}
+ {STATUS_MAP[rawStatus] || rawStatus}
+
+
+ {/* Dato-pille ved siden av den øverste status-pillen */}
+ {idx === 0 && (
+
+ {lastUpdated}
+
+ )}
+
+ );
+ })}
+
+
+ {/* Avstandspille */}
+ {f.dist !== Infinity && (
+
+ {Math.round(f.dist)} km unna
+
+ )}
+
+
+
+
{f.name}
+
{f.city} • {f.county}
+
+
+
+ {/* Hull-pille */}
+
+ {f.amenities?.antall_hull || '--'} HULL
+
+ {/* Banetype-pille */}
+
+ {f.banetype || 'SKOGSBANE'}
+
+
+
+ {/* Sirkel-ikoner (NSG / Golfamore) */}
+
+ {f.hasNSG && (
+
N
+ )}
+ {f.hasGolfamore && (
+
G
+ )}
+
+
+
+
+ );
+ })}
+
+
+ );
+}
\ No newline at end of file
diff --git a/kode_eksport_3/frontend_src_app_HeroSlider_tsx.txt b/kode_eksport_3/frontend_src_app_HeroSlider_tsx.txt
new file mode 100644
index 0000000..515ce80
--- /dev/null
+++ b/kode_eksport_3/frontend_src_app_HeroSlider_tsx.txt
@@ -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 (
+
+ {sliderItems.map((f, i) => (
+
+
+
+
+
+
+
+
+
+ {/* FONT NEDJUSTERT FRA text-6xl md:text-9xl TIL text-4xl md:text-7xl */}
+
+ {f.name}
+
+
+ {f.county} • {f.city}
+
+
+
+
+
+
+ ))}
+
+
+ {sliderItems.map((_, i) => (
+ setCurrentSlide(i)}
+ className={`h-1 transition-all duration-500 rounded-full ${
+ i === currentIndex ? 'w-16 bg-[#8bc34a]' : 'w-4 bg-white/20'
+ }`}
+ />
+ ))}
+
+
+ );
+}
\ No newline at end of file
diff --git a/kode_eksport_3/frontend_src_app_admin_greenfee_page_tsx.txt b/kode_eksport_3/frontend_src_app_admin_greenfee_page_tsx.txt
new file mode 100644
index 0000000..ed8a782
--- /dev/null
+++ b/kode_eksport_3/frontend_src_app_admin_greenfee_page_tsx.txt
@@ -0,0 +1,203 @@
+"use client";
+import { useState, useEffect } from 'react';
+import { API_URL } from "@/config/constants";
+import Link from 'next/link';
+
+export default function GreenfeeWasher() {
+ const [drafts, setDrafts] = useState
([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedIds, setSelectedIds] = useState([]);
+ const [saving, setSaving] = useState(false);
+
+ const fetchDrafts = () => {
+ setLoading(true);
+ fetch(`${API_URL}/admin/greenfee/drafts`)
+ .then(res => res.json())
+ .then(data => {
+ const editableDrafts = data.map((f: any) => {
+ // JSONB fra Postgres kan noen ganger komme som en streng,
+ // vi må sikre at vi parser det hvis det trengs
+ let parsedDraft = f.greenfee_draft;
+ if (typeof parsedDraft === 'string') {
+ try { parsedDraft = JSON.parse(parsedDraft); }
+ catch (e) { console.error("Kunne ikke parse JSON", e); }
+ }
+
+ // Hent ut selve listen (fallback til tom liste hvis noe er feil)
+ const greenfeeList = parsedDraft?.foreslatt_greenfee || [];
+
+ return {
+ ...f,
+ greenfee_draft: parsedDraft, // Lagre den parsede versjonen for visning
+ edit_greenfee: greenfeeList // Dette er arrayet som binder seg til input-feltene
+ };
+ });
+ 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 removeRow = (facilityId: number, rowIndex: number) => {
+ setDrafts(drafts.map(d => {
+ if (d.id === facilityId) {
+ const newRows = [...d.edit_greenfee];
+ newRows.splice(rowIndex, 1);
+ return { ...d, edit_greenfee: newRows };
+ }
+ return d;
+ }));
+ };
+
+ const updateField = (facilityId: number, rowIndex: number, field: string, value: string | number) => {
+ setDrafts(drafts.map(d => {
+ if (d.id === facilityId) {
+ const newRows = [...d.edit_greenfee];
+ newRows[rowIndex] = { ...newRows[rowIndex], [field]: value };
+ return { ...d, edit_greenfee: newRows };
+ }
+ return d;
+ }));
+ };
+
+ const handleApprove = async () => {
+ const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({
+ facility_id: d.id,
+ greenfee: d.edit_greenfee.map((row: any) => ({
+ banenavn: row.banenavn || "",
+ priskategori: row.priskategori || "",
+ pris_voksne: Number(row.pris_voksne) || null,
+ pris_junior: Number(row.pris_junior) || null
+ }))
+ }));
+
+ if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne.");
+
+ setSaving(true);
+ try {
+ const res = await fetch(`${API_URL}/admin/greenfee/approve-bulk`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ approvals: toApprove })
+ });
+ if (res.ok) {
+ alert(`${toApprove.length} anlegg oppdatert!`);
+ setSelectedIds([]);
+ fetchDrafts();
+ } else {
+ alert("Noe gikk galt under lagring.");
+ }
+ } catch (e) {
+ alert("Nettverksfeil");
+ }
+ setSaving(false);
+ };
+
+ if (loading) return Laster utkast...
;
+
+ return (
+
+
+
+
+
← Tilbake til oversikten
+
Greenfee-Vaskeriet
+
Sjekk at prisene gir mening før publisering.
+
+
+ {saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
+
+
+
+ {drafts.length === 0 ? (
+
+ 🧹
+
Alt er rent og pent!
+
+ ) : (
+
+
+ 0} onChange={(e) => toggleSelectAll(e.target.checked)} />
+ Velg Alle
+
+
+ {drafts.map(draft => (
+
+
+
toggleOne(draft.id)} />
+
+
+
+ {draft.greenfee_draft?.ai_begrunnelse && (
+
+ 🤖 AI Begrunnelse: {draft.greenfee_draft.ai_begrunnelse}
+
+ )}
+
+ {draft.greenfee_draft?.foreslatt_avtaleklubber?.length > 0 && (
+
+ 🤝 AI fant disse avtaleklubbene i teksten: {draft.greenfee_draft.foreslatt_avtaleklubber.join(', ')}
+
+ )}
+
+
+
+
Slik ser det ut i databasen nå:
+
+ {draft.greenfee && draft.greenfee.length > 0 ? draft.greenfee.map((g: any, i: number) => (
+
+ {g.banenavn} - {g.priskategori}
+ V: {g.pris_voksne || '-'} | J: {g.pris_junior || '-'}
+
+ )) : "Ingen priser registrert."}
+
+
+
+
+
Nytt forslag å godkjenne:
+
+ {draft.edit_greenfee && draft.edit_greenfee.map((row: any, idx: number) => (
+
+ updateField(draft.id, idx, 'banenavn', e.target.value)} placeholder="Bane" />
+ updateField(draft.id, idx, 'priskategori', e.target.value)} placeholder="Kategori" />
+ updateField(draft.id, idx, 'pris_voksne', e.target.value)} placeholder="Voksen" />
+ updateField(draft.id, idx, 'pris_junior', e.target.value)} placeholder="Junior" />
+ removeRow(draft.id, idx)} className="text-red-400 hover:text-red-600 px-2 opacity-0 group-hover:opacity-100 transition-opacity" title="Slett rad">✕
+
+ ))}
+
{
+ const newDrafts = [...drafts];
+ const draftIndex = newDrafts.findIndex(d => d.id === draft.id);
+ newDrafts[draftIndex].edit_greenfee.push({ banenavn: '', priskategori: '', pris_voksne: '', pris_junior: '' });
+ setDrafts(newDrafts);
+ }} className="text-xs font-bold text-[#8bc34a] hover:underline mt-2 inline-block">
+ + Legg til manuell rad
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/kode_eksport_3/frontend_src_app_admin_login_page_tsx.txt b/kode_eksport_3/frontend_src_app_admin_login_page_tsx.txt
new file mode 100644
index 0000000..084efbd
--- /dev/null
+++ b/kode_eksport_3/frontend_src_app_admin_login_page_tsx.txt
@@ -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 (
+
+
+
+
+
+
+ {step === 1 ? "Admin Portalen" : "Tofaktor Sjekk"}
+
+
+ {step === 1 ? (
+ <>
+ setFormData(prevState => ({...prevState, username: e.target.value}))} required />
+ setFormData(prevState => ({...prevState, password: e.target.value}))} required />
+ >
+ ) : (
+
+
Tast inn 6 siffer fra appen din
+
setFormData({...formData, code: e.target.value})} autoFocus required />
+
+ )}
+ {error && ⚠️ {error}
}
+
+ {isLoading ? "Venter..." : (step === 1 ? "Fortsett" : "Logg inn")}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/kode_eksport_3/frontend_src_app_admin_medlemskap_page_tsx.txt b/kode_eksport_3/frontend_src_app_admin_medlemskap_page_tsx.txt
new file mode 100644
index 0000000..7c16052
--- /dev/null
+++ b/kode_eksport_3/frontend_src_app_admin_medlemskap_page_tsx.txt
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedIds, setSelectedIds] = useState([]);
+ 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 Laster utkast...
;
+
+ return (
+
+
+
+
+
← Tilbake til oversikten
+
Medlemskaps-Vaskeriet
+
Gå gjennom AI-ens forslag, juster hvis nødvendig, og godkjenn for å publisere. Oppdatert-dato settes automatisk i dag.
+
+
+ {saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
+
+
+
+ {drafts.length === 0 ? (
+
+
🧹
+
Alt er rent og pent!
+
Ingen ventende forslag fra AI-skraperen akkurat nå.
+
+ ) : (
+
+
+ toggleSelectAll(e.target.checked)}
+ />
+ Velg Alle
+
+
+ {drafts.map(draft => (
+
+
+
+ toggleOne(draft.id)}
+ />
+
+
+
+ {/* OPPDATERT: Navn + ID Badge */}
+
+
+ {draft.membership_draft?.ai_begrunnelse && (
+
+ 🤖 AI Begrunnelse: {draft.membership_draft.ai_begrunnelse}
+
+ )}
+
+
+ {/* Standard */}
+
+
+ {/* Rimeligste */}
+
+
Rimeligste (Betaler Greenfee)
+
+ updateDraftField(draft.id, 'edit_rimeligste_navn', e.target.value)} placeholder="Navn (eks. Greenfeemedlem)" />
+ updateDraftField(draft.id, 'edit_rimeligste_pris', e.target.value)} placeholder="Pris" />
+
+
Gammel pris var: {draft.rimeligste_alternativ ? `kr ${draft.rimeligste_alternativ} (${draft.navn_rimeligste_alternativ})` : 'Ikke registrert'}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/kode_eksport_3/frontend_src_app_admin_page_tsx.txt b/kode_eksport_3/frontend_src_app_admin_page_tsx.txt
new file mode 100644
index 0000000..1bb56fa
--- /dev/null
+++ b/kode_eksport_3/frontend_src_app_admin_page_tsx.txt
@@ -0,0 +1,468 @@
+"use client";
+/**
+ * TEE OFF ADMIN DASHBOARD v4.0 - KONTROLLPANEL
+ */
+
+import { useState, useEffect, useMemo } from 'react';
+import { API_URL } from "@/config/constants";
+import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
+import Link from 'next/link';
+
+const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: number, field: string, initialValue: string, onSave: (id: number, field: string, val: string) => void }) => {
+ const [isEditing, setIsEditing] = useState(false);
+ const [value, setValue] = useState(initialValue || '');
+
+ const handleSave = () => {
+ setIsEditing(false);
+ if (value !== initialValue) {
+ onSave(facilityId, field, value);
+ }
+ };
+
+ if (isEditing) {
+ return (
+
+
setValue(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSave(); } }} placeholder="Lim inn URL(er)..." />
+
+ Lagre
+ { setIsEditing(false); setValue(initialValue || ''); }} className="bg-gray-200 text-gray-600 px-3 py-1.5 rounded-md text-[10px] font-black uppercase hover:bg-gray-300">Avbryt
+
+
+ );
+ }
+
+ return (
+ setIsEditing(true)} title="Klikk for å redigere URL">
+
+ {initialValue ? initialValue : Mangler URL }
+
+
✏️
+
+ );
+};
+
+export default function AdminDashboard() {
+ const [facilities, setFacilities] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedFacilities, setSelectedFacilities] = useState([]);
+ const [isScraping, setIsScraping] = useState(false);
+ const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
+ const [editingFacility, setEditingFacility] = useState(null);
+ const [activeTab, setActiveTab] = useState<'banestatus' | 'medlemskap' | 'greenfee' | 'vtg'>('banestatus');
+ 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]);
+
+ useEffect(() => { setSelectedFacilities([]); }, [activeTab]);
+
+ const filteredFacilities = useMemo(() => {
+ if (statusFilter === 'alle') return facilities;
+ return facilities.map(facility => {
+ if (!facility.course_statuses) return facility;
+ 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;
+ });
+ return { ...facility, course_statuses: filteredCourses };
+ }).filter(facility => facility.course_statuses && facility.course_statuses.length > 0);
+ }, [facilities, statusFilter]);
+
+ const handleSelectAll = (e: React.ChangeEvent) => {
+ 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 handleQuickEdit = async (id: number, field: string, value: string) => {
+ setFacilities(facilities.map(f => f.id === id ? { ...f, [field]: value } : f));
+ try {
+ const res = await fetch(`${API_URL}/admin/facilities/${id}/quick-edit`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ field, value })
+ });
+ if (!res.ok) throw new Error("Feil ved lagring");
+ } catch (e) {
+ alert("Kunne ikke lagre endringen i databasen.");
+ fetchFacilities();
+ }
+ };
+
+ const handleRunScrapers = async () => {
+ if (isScraping) { setIsScraping(false); return; }
+ setIsScraping(true);
+ const endpoint = activeTab === 'banestatus' ? '/admin/run-scraper' :
+ activeTab === 'medlemskap' ? '/admin/run-membership-scraper' :
+ activeTab === 'greenfee' ? '/admin/run-greenfee-scraper' :
+ '/admin/run-vtg-scraper';
+ try {
+ const response = await fetch(`${API_URL}${endpoint}`, {
+ 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) {
+ alert(`Feil ved start av ${activeTab}-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.");
+ } finally { setIsSaving(false); }
+ };
+
+ if (loading) return LASTER KONTROLLPANEL...
;
+
+ return (
+
+
+ {/* REDIGER-MODAL FOR BANESTATUS */}
+ {editingFacility && (
+
+
+
+
Skrape-innstillinger
+
{editingFacility.name}
+
+
+
+ Scrape URL (Banestatus)
+ 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" />
+
+
+ Skrapemetode
+ 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">
+ Standard (CSS)
+ ✨ Gemini AI (LLM)
+ Golfbox iframe
+ Auto-klikk + CSS
+ 🚨 Manuell (Ikke skrap)
+
+
+ {editForm.scrape_method === 'llm_parse' && (
+
+ ✨ AI-Hviskeren (Instruks til Gemini)
+ 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} />
+
+ )}
+ {editForm.scrape_method === 'manual' && (
+
+
🚨 Sett Status Manuelt
+
+ {editForm.courses.map((course: any, idx: number) => (
+
+ {course.name}
+ { 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">
+ 🟢 Åpen
+ 🟡 Vintergreener
+ 🟡 Åpner Snart
+ 🔴 Stengt
+ 🔴 Stenger Snart
+ 🔨 Under Utvikling
+ ⚫ Nedlagt
+ ⚪ Ukjent
+
+
+ ))}
+
+
+ )}
+ {(editForm.scrape_method === 'css_selector' || editForm.scrape_method === 'click_then_css' || editForm.scrape_method === 'iframe_golfbox') && (
+
+ CSS Selector
+ 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" />
+
+ )}
+
+
+ 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
+
+ {isSaving ? 'Lagrer...' : 'Lagre endringer'}
+
+
+
+
+ )}
+
+ {/* SIDEBAR */}
+
+
+ {!isSidebarCollapsed &&
TeeOff }
+ setIsSidebarCollapsed(!isSidebarCollapsed)} className="text-2xl hover:text-[#8bc34a] transition-colors" title="Skjul/Vis meny">☰
+
+
+
+
+ {isSidebarCollapsed ? 'KP' : 'Kontrollpanel'}
+
+
+
Datavask
+
+ {isSidebarCollapsed ? 'M' : 'Medlemskap'}
+
+
+ {isSidebarCollapsed ? 'G' : 'Greenfee'}
+
+
+ {isSidebarCollapsed ? 'V' : 'VTG'}
+
+
+
+
+
+ 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'}
+
+
+
+
+ {/* HOVEDINNHOLD */}
+
+
+
+
+ {/* VELDIG SYNLIGE FANER */}
+
+ setActiveTab('banestatus')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'banestatus' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Banestatus
+ setActiveTab('medlemskap')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'medlemskap' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Medlemskap
+ setActiveTab('greenfee')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'greenfee' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Greenfee
+ setActiveTab('vtg')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'vtg' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>VTG-Kurs
+
+
+ {activeTab === 'banestatus' && (
+
+ Filtrer på status:
+ 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">
+ Vis alle anlegg
+ 🟢 Kun åpne baner
+ 🟡 Ikke stengt (Åpne/Vintergreen/Snart)
+ 🔴 Kun stengte baner
+ ⚪ Ukjent / Skrapefeil
+
+
+ )}
+
+
+
+
+
+ 0} onChange={handleSelectAll} />
+ ID
+ Anlegg
+
+ {activeTab === 'banestatus' && (
+ <>
+ Konfigurasjon (URL & Selektor)
+ Metode
+ Siste Sjekk
+ Banestatus
+ >
+ )}
+ {activeTab === 'medlemskap' && (
+ <>
+ Medlemskap-side (Klikk for å redigere)
+ Nåværende Priser
+ Nytt Utkast?
+ Sist Vasket
+ >
+ )}
+ {activeTab === 'greenfee' && (
+ <>
+ Greenfee-side (Klikk for å redigere)
+ Aktive priser
+ Nytt Utkast?
+ Sist Vasket
+ >
+ )}
+ {activeTab === 'vtg' && (
+ <>
+ VTG-side (Klikk for å redigere)
+ Registrert Informasjon
+ Nytt Utkast?
+ Sist Vasket
+ >
+ )}
+ Handling
+
+
+
+
+ {filteredFacilities.map((f: any) => {
+ const hasMemDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0;
+ const hasGfDraft = f.greenfee_draft && Object.keys(f.greenfee_draft).length > 0;
+ const hasVtgDraft = f.vtg_draft && Object.keys(f.vtg_draft).length > 0;
+ const isHighlighted = (activeTab === 'medlemskap' && hasMemDraft) || (activeTab === 'greenfee' && hasGfDraft) || (activeTab === 'vtg' && hasVtgDraft);
+
+ return (
+
+ handleSelectOne(f.id, e.target.checked)} />
+ #{f.id}
+
+ {f.name}
+ {f.city}
+
+
+ {activeTab === 'banestatus' && (
+ <>
+
+
+ {f.scrape_status_selector}
+
+
+ {f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
+
+
+ {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 (
+
+ {cs.name}
+ {cs.status || 'UKJENT'}
+
+ )
+ })}
+
+
+ >
+ )}
+
+ {activeTab === 'medlemskap' && (
+ <>
+
+
+
+ Standard: {f.standard_medlemskap ? `${f.standard_medlemskap},-` : '---'}
+ Rimeligste: {f.rimeligste_alternativ ? `${f.rimeligste_alternativ},-` : '---'}
+
+
+ {hasMemDraft ? Ja, vask! : - }
+ {f.membership_updated_at ? new Date(f.membership_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
+ >
+ )}
+
+ {activeTab === 'greenfee' && (
+ <>
+
+
+
+ {f.greenfee && f.greenfee.length > 0 ? f.greenfee.map((g: any, i: number) => (
+
+ {g.banenavn}
+ V: {g.pris_voksne} J: {g.pris_junior}
+
+ )) : 'Ingen priser'}
+
+
+ {hasGfDraft ? Ja, vask! : - }
+ {f.greenfee_updated_at ? new Date(f.greenfee_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
+ >
+ )}
+
+ {activeTab === 'vtg' && (
+ <>
+
+
+
+ Pris: {f.vtg_pris ? `${f.vtg_pris},-` : '---'}
+ {f.vtg_beskrivelse || 'Ingen beskrivelse registrert.'}
+
+ {f.vtg_datoer && f.vtg_datoer.length > 0 ? `📅 ${f.vtg_datoer.length} kursdato(er)` : '📅 Ingen datoer registrert'}
+
+
+
+ {hasVtgDraft ? Ja, vask! : - }
+ {f.vtg_updated_at ? new Date(f.vtg_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
+ >
+ )}
+
+
+
+ {activeTab === 'banestatus' && 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">Innstillinger }
+ {activeTab === 'medlemskap' && hasMemDraft && Gå til Vaskeri}
+ {activeTab === 'greenfee' && hasGfDraft && Gå til Vaskeri}
+ {activeTab === 'vtg' && hasVtgDraft && Gå til Vaskeri}
+
+ Rediger alt
+
+
+
+ );
+ })}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/kode_eksport_3/frontend_src_app_admin_rediger_[slug]_EditFacilityClient_tsx.txt b/kode_eksport_3/frontend_src_app_admin_rediger_[slug]_EditFacilityClient_tsx.txt
new file mode 100644
index 0000000..3904d16
--- /dev/null
+++ b/kode_eksport_3/frontend_src_app_admin_rediger_[slug]_EditFacilityClient_tsx.txt
@@ -0,0 +1,637 @@
+"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 (
+
+
{label}
+
+ {options.map(opt => (
+
+ toggle(opt.slug)} className="w-5 h-5 accent-[#8bc34a]" />
+ {opt.name}
+
+ ))}
+
+
+ );
+};
+
+// 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 (
+
+
{label}
+
+
+ Legg til ny rad
+
+ );
+};
+
+// 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 (
+
+
{label}
+
+ {items.map((item, idx) => (
+
+
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 z-10">✕
+
+ {templateKeys.map(key => (
+
+ {key.replace(/_/g, ' ')}
+ updateField(idx, key, e.target.value)}
+ />
+
+ ))}
+
+
+ ))}
+
+
+ Legg til nytt element
+
+ );
+};
+
+// 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(() => {
+ 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(() => {
+ const keys = new Set();
+ 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(() => {
+ 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 (
+
+
+ Aktive Utslagskolonner:
+ {ALL_KEYS.map(k => (
+
+ toggleKey(k)}
+ className="w-5 h-5 accent-[#8bc34a]"
+ />
+ {k.toUpperCase()}
+
+ ))}
+
+
+
+
+
+ + Legg til hull
+ - Slett siste hull
+
+
+ );
+};
+
+
+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(
+ 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' }
+ ];
+
+ // Hjelpefunksjon for å hente ut verdi (spesielt formatert for dato)
+ const getValue = (field: string, type: string) => {
+ let val = formData[field] || "";
+ if (type === 'date' && val) {
+ val = val.split('T')[0];
+ }
+ return val;
+ };
+
+ return (
+
+
+
+ ← Tilbake til oversikten
+
Rediger: {initialData.name}
+
+
+ {saving ? "Lagrer..." : "Lagre endringer"}
+
+
+
+
+ {/* SIDEBAR MENY */}
+
+ {tabs.map(tab => (
+ 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}
+
+ ))}
+
+
+ {/* SKJEMA OMRÅDE */}
+
+ {activeTab === 'generelt' && (
+
+ )}
+
+ {activeTab === 'lokasjon' && (
+
+ )}
+
+ {activeTab === 'linker' && (
+
+ )}
+
+ {activeTab === 'okonomi' && (
+
+ {/* MEDLEMSKAP */}
+
+
Medlemskap
+
+ Sist Oppdatert (Dato)
+ handleChange('membership_updated_at', e.target.value)} />
+
+
+
+
+ {/* GREENFEE */}
+
+
Greenfee / Gjestespill
+
+
handleChange('greenfee', v)}
+ />
+
+
+ {/* VEIEN TIL GOLF (VTG) */}
+
+
Veien til Golf (VTG)
+
+
Pris VTG kurs (kun tall) handleChange('vtg_pris', Number(e.target.value))} />
+
Lenke til VTG påmelding handleChange('vtg_lenke', e.target.value)} />
+
Beskrivelse / Hva er inkludert handleChange('vtg_beskrivelse', e.target.value)} />
+
+
handleChange('vtg_datoer', v)}
+ />
+
+
+
+ handleChange('amenities', v)} />
+ handleChange('nsg_data', v)} />
+ handleChange('golfamore_data', v)} />
+
+ {/* HER ER GOLFPAKKENE SOM JEG MISTET I FORRIGE RUNDE */}
+ handleChange('golfpakker', v)}
+ />
+
+
+ )}
+
+ {activeTab === 'baner' && (
+
+
+
Baner og Scorekort
+
Bruk det interaktive skjemaet under for å redigere lengder, par og utslag.
+
+
+ {formData.courses?.map((course: any, cIdx: number) => (
+
+
+
{course.name}
+
+ {course.is_main_course ? 'Hovedbane' : 'Sekundærbane'}
+
+
+
+
+
+ Banenavn
+ {
+ const newCourses = [...formData.courses];
+ newCourses[cIdx] = {...course, name: e.target.value};
+ handleChange('courses', newCourses);
+ }} />
+
+
+ Status
+ {
+ const newCourses = [...formData.courses];
+ newCourses[cIdx] = {...course, status: e.target.value};
+ handleChange('courses', newCourses);
+ }}>
+ 🟢 Åpen
+ 🟡 Vintergreener
+ 🟡 Åpner Snart
+ 🔴 Stengt
+ ⚫ Nedlagt
+ ⚪ Ukjent
+
+
+
+ Total Par (Bane)
+ {
+ const newCourses = [...formData.courses];
+ newCourses[cIdx] = {...course, par: Number(e.target.value)};
+ handleChange('courses', newCourses);
+ }} />
+
+
+ Utløpsdato Slope
+ {
+ const newCourses = [...formData.courses];
+ newCourses[cIdx] = {...course, slope_valid_until: e.target.value};
+ handleChange('courses', newCourses);
+ }} />
+
+
+
+ {/* DET NYE SCOREKORTET INKLUDERES HER */}
+
{
+ const newCourses = [...formData.courses];
+ newCourses[cIdx] = updatedCourse;
+ handleChange('courses', newCourses);
+ }}
+ />
+
+ ))}
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/kode_eksport_3/frontend_src_app_admin_rediger_[slug]_page_tsx.txt b/kode_eksport_3/frontend_src_app_admin_rediger_[slug]_page_tsx.txt
new file mode 100644
index 0000000..dafe776
--- /dev/null
+++ b/kode_eksport_3/frontend_src_app_admin_rediger_[slug]_page_tsx.txt
@@ -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 Fant ikke anlegget...
;
+ }
+
+ return ;
+}
\ No newline at end of file
diff --git a/kode_eksport_3/frontend_src_app_admin_vtg_page_tsx.txt b/kode_eksport_3/frontend_src_app_admin_vtg_page_tsx.txt
new file mode 100644
index 0000000..11303a1
--- /dev/null
+++ b/kode_eksport_3/frontend_src_app_admin_vtg_page_tsx.txt
@@ -0,0 +1,208 @@
+"use client";
+import { useState, useEffect } from 'react';
+import { API_URL } from "@/config/constants";
+import Link from 'next/link';
+
+export default function VtgWasher() {
+ const [drafts, setDrafts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedIds, setSelectedIds] = useState([]);
+ const [saving, setSaving] = useState(false);
+
+ const fetchDrafts = () => {
+ setLoading(true);
+ fetch(`${API_URL}/admin/vtg/drafts`)
+ .then(res => res.json())
+ .then(data => {
+ const editableDrafts = data.map((f: any) => {
+ let parsedDraft = f.vtg_draft;
+ if (typeof parsedDraft === 'string') {
+ try { parsedDraft = JSON.parse(parsedDraft); }
+ catch (e) { console.error("Kunne ikke parse JSON", e); }
+ }
+
+ return {
+ ...f,
+ vtg_draft: parsedDraft,
+ edit_pris: parsedDraft?.foreslatt_vtg_pris || f.vtg_pris || '',
+ edit_beskrivelse: parsedDraft?.foreslatt_vtg_beskrivelse || f.vtg_beskrivelse || '',
+ edit_datoer: parsedDraft?.foreslatt_vtg_datoer || []
+ };
+ });
+ 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 updateField = (facilityId: number, field: string, value: any) => {
+ setDrafts(drafts.map(d => d.id === facilityId ? { ...d, [field]: value } : d));
+ };
+
+ const updateDateRow = (facilityId: number, rowIndex: number, field: string, value: string) => {
+ setDrafts(drafts.map(d => {
+ if (d.id === facilityId) {
+ const newDates = [...d.edit_datoer];
+ newDates[rowIndex] = { ...newDates[rowIndex], [field]: value };
+ return { ...d, edit_datoer: newDates };
+ }
+ return d;
+ }));
+ };
+
+ const addDateRow = (facilityId: number) => {
+ setDrafts(drafts.map(d => {
+ if (d.id === facilityId) {
+ return { ...d, edit_datoer: [...d.edit_datoer, { dato: '', status: 'Ledig' }] };
+ }
+ return d;
+ }));
+ };
+
+ const removeDateRow = (facilityId: number, rowIndex: number) => {
+ setDrafts(drafts.map(d => {
+ if (d.id === facilityId) {
+ const newDates = [...d.edit_datoer];
+ newDates.splice(rowIndex, 1);
+ return { ...d, edit_datoer: newDates };
+ }
+ return d;
+ }));
+ };
+
+ const handleApprove = async () => {
+ const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({
+ facility_id: d.id,
+ vtg_pris: Number(d.edit_pris) || null,
+ vtg_beskrivelse: d.edit_beskrivelse,
+ vtg_datoer: d.edit_datoer
+ }));
+
+ if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne.");
+
+ setSaving(true);
+ try {
+ const res = await fetch(`${API_URL}/admin/vtg/approve-bulk`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ approvals: toApprove })
+ });
+ if (res.ok) {
+ alert(`${toApprove.length} anlegg oppdatert!`);
+ setSelectedIds([]);
+ fetchDrafts();
+ } else {
+ alert("Noe gikk galt under lagring.");
+ }
+ } catch (e) {
+ alert("Nettverksfeil");
+ }
+ setSaving(false);
+ };
+
+ if (loading) return Laster VTG-utkast...
;
+
+ return (
+
+
+
+
+
← Tilbake til oversikten
+
VTG-Vaskeriet
+
Gå gjennom og godkjenn kursinformasjon for Veien til Golf.
+
+
+ {saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
+
+
+
+ {drafts.length === 0 ? (
+
+ 🧹
+
Ingen ventende VTG-utkast!
+
+ ) : (
+
+
+ 0} onChange={(e) => toggleSelectAll(e.target.checked)} />
+ Velg Alle
+
+
+ {drafts.map(draft => (
+
+
+
toggleOne(draft.id)} />
+
+
+
+ {draft.vtg_draft?.ai_begrunnelse && (
+
+ 🤖 AI Begrunnelse: {draft.vtg_draft.ai_begrunnelse}
+
+ )}
+
+
+ {/* Pris & Beskrivelse */}
+
+
Pris & Beskrivelse
+
+ Standardpris for Voksen (kr)
+ updateField(draft.id, 'edit_pris', e.target.value)} placeholder="Eks: 1990" />
+
+
+ Selgende tekst / Inkludert i kurset
+ updateField(draft.id, 'edit_beskrivelse', e.target.value)} placeholder="Beskriv kurset..." />
+
+
+
+ {/* Kursdatoer */}
+
+
Kursdatoer
+
+ {draft.edit_datoer.length === 0 ? (
+
Fant ingen spesifikke kursdatoer.
+ ) : (
+ draft.edit_datoer.map((row: any, idx: number) => (
+
+ updateDateRow(draft.id, idx, 'dato', e.target.value)} placeholder="F.eks: 12.-14. mai" />
+ updateDateRow(draft.id, idx, 'status', e.target.value)}>
+ Ledig
+ Fulltegnet
+ Venteliste
+ Få plasser
+
+ removeDateRow(draft.id, idx)} className="text-red-400 hover:text-red-600 px-2 opacity-0 group-hover:opacity-100 transition-opacity" title="Slett dato">✕
+
+ ))
+ )}
+
addDateRow(draft.id)} className="text-xs font-bold text-[#8bc34a] hover:underline mt-2 inline-block">
+ + Legg til ny dato
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/kode_eksport_3/frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt b/kode_eksport_3/frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt
new file mode 100644
index 0000000..a3a6219
--- /dev/null
+++ b/kode_eksport_3/frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt
@@ -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 (
+
+
+ {/* HEADER / KALKULATOR */}
+
+
+
{course.name}
+
+ Par {course.par} • {course.length_meters || '--'} meter
+
+
+ Rating utløper: {slopeExpiry}
+
+
+
+
+
Kjønn
+ { 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">
+ HERRER DAMER
+
+
+
Utslag
+ 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) => ({t.navn_utslag || t.navn_utslag_damer} ))}
+
+
+
Ditt HCP
+ setHcp(e.target.value)} className="w-12 bg-transparent text-[#11280f] font-black text-center border-b-2 border-[#7ca982]/30" />
+
+
+
SpH
+
{playingHandicap || 0}
+
+
+
+
+ {/* SCOREKORT TABELL */}
+
+
+
+
+ Hull
+ Par
+ HCP
+ Mottatt
+ Din Par
+ {activeColumns.map((col, i) => (
+ {col.label}
+ ))}
+
+
+
+ {/* UT-RUNDE */}
+ {holesOut.map((h: any) => {
+ const extra = getExtraStrokes(h.hcp_index);
+ return (
+
+ {h.hole_number}
+ {h.par}
+ {h.hcp_index}
+ {extra > 0 ? `+${extra}` : '-'}
+ {h.par + extra}
+ {activeColumns.map((col, i) => (
+
+ {h.lengths?.[col.key] || '--'}
+
+ ))}
+
+ );
+ })}
+
+ {/* UT RAD */}
+
+ Ut
+ {sumPar(holesOut)}
+
+ {activeColumns.map((col, i) => (
+ {sumLen(holesOut, col.key)}
+ ))}
+
+
+ {/* INN-RUNDE */}
+ {hasInHoles && holesIn.map((h: any) => {
+ const extra = getExtraStrokes(h.hcp_index);
+ return (
+
+ {h.hole_number}
+ {h.par}
+ {h.hcp_index}
+ {extra > 0 ? `+${extra}` : '-'}
+ {h.par + extra}
+ {activeColumns.map((col, i) => (
+
+ {h.lengths?.[col.key] || '--'}
+
+ ))}
+
+ );
+ })}
+
+ {/* INN RAD */}
+ {hasInHoles && (
+
+ Inn
+ {sumPar(holesIn)}
+
+ {activeColumns.map((col, i) => (
+ {sumLen(holesIn, col.key)}
+ ))}
+
+ )}
+
+ {/* TOTAL RAD */}
+
+ Totalt
+ {sumPar(allHoles)}
+
+ {activeColumns.map((col, i) => (
+
+ {sumLen(allHoles, col.key)}
+
+ ))}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/kode_eksport_3/frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt b/kode_eksport_3/frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt
new file mode 100644
index 0000000..96f1b2c
--- /dev/null
+++ b/kode_eksport_3/frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt
@@ -0,0 +1,543 @@
+"use client";
+/**
+ * TEE OFF DETAIL VIEW - COMPLETE v3.4 (FINAL LAYOUT FIX)
+ * ---------------------------------------------------------------------------
+ * 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.
+ * NEW: Priser (Medlemskap + Greenfee) i 2-kolonne Grid (xl:grid-cols-2).
+ * NEW: Veien til Golf (VTG) i full bredde under prisene, med robust array-parsing.
+ * 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('
+ );
+};
+
+const Icon = ({ children, className = "w-5 h-5" }: { children: React.ReactNode, className?: string }) => (
+
+ {children}
+
+);
+
+const ICONS = {
+ web: <> >,
+ phone: ,
+ mail: <> >,
+ pin: <> >,
+ booking: <> >,
+ trophy: <> >,
+ guide: <> >,
+ camera: <> >,
+ webcam: <> >,
+ chart: <> >,
+ weather: <> >
+};
+
+const SOCIAL_ICONS: Record = {
+ facebook: ,
+ instagram: <> >,
+ twitter: ,
+ x: ,
+ linkedin: <> >,
+ youtube: <> >,
+ tiktok: ,
+ snapchat:
+};
+
+export default function FacilityDetailView({ facility }: { facility: any }) {
+ const [showBackToTop, setShowBackToTop] = useState(false);
+ const [currentSlide, setCurrentSlide] = useState(0);
+
+ // Robust parser for å hente ut JSONB data fra Postgres trygt
+ 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 shotzoom = parseJson(facility.shotzoom, []);
+
+ // Pris og kurs-arrays
+ const greenfeeRaw = parseJson(facility.greenfee, []);
+ const vtgDatoer = parseJson(facility.vtg_datoer, []);
+
+ 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 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 (
+
+
+ {/* 1. HERO SLIDER */}
+
+ {gallery.map((img: string, i: number) => (
+
+ ))}
+
+
+ {/* BANESTATUS BADGES */}
+
+
+ {activeCourses.map((c: any) => (
+
+ {c.name.toUpperCase()}: {STATUS_MAP[c.status] || c.status}
+
+ ))}
+
+ {facility.status_updated_at && (
+
+ Sist oppdatert: {formatDate(facility.status_updated_at)}
+
+ )}
+
+
+ {/* FLYTENDE HURTIGKNAPPER */}
+
+ {facility.website_url &&
}
+ {facility.golfbox_booking_url &&
}
+ {facility.golfbox_tournament_url &&
}
+
+ {facility.weather_url &&
}
+
+
+ {/* HERO TEXT */}
+
+ {facility.logo_url && (
+
+ )}
+
{facility.name}
+
{facility.county} • {facility.city}
+
+
+
+ {/* 2. STICKY NAV */}
+
+
+ scrollTo('intro')}>Info
+ scrollTo('weather')}>Vær
+ scrollTo('details')}>Detaljer
+ scrollTo('map')}>Kart
+ {facility.video_url && scrollTo('video')}>Video }
+ scrollTo('prices')}>Priser
+ scrollTo('scorecards')}>Scorekort
+
+
+
+
+
+ {/* 3. INTRO & SIDEBAR */}
+
+ {/* HOVEDINNHOLD (78%) */}
+
+ {facility.footnote && (
+
+ {facility.footnote}
+
+ )}
+
+
+
+ {/* SIDEBAR (22%) */}
+
+
Kontakt & Adresse
+
+
+ {/* SOSIALE MEDIER IKONER */}
+ {socialLinks.length > 0 && (
+
+ {socialLinks.map((social: any, idx: number) => {
+ const platform = (social.platform || '').toLowerCase().trim();
+ const iconData = SOCIAL_ICONS[platform] ||
;
+
+ return (
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ Se alle baner i {facility.county} →
+
+
+
+
+
+ {/* 4. 3-KOLONNE INFO */}
+
+
+
+
Banen
+
+
Hull: {amenities.antall_hull || '--'}
+
Lengde: {facility.length_meters ? `${facility.length_meters}m` : '--'}
+
Sesong: {facility.season || '--'}
+
Byggeår: {facility.established_year || '--'}
+
Banetype: {facility.banetype || 'Park/Skog'}
+
Arkitekt: {facility.architect || '--'}
+
+
+
+
Andre Tilbud
+
+
Drivingrange: {amenities.drivingrange || 'Nei'}
+
Nærspill: {amenities.treningsgreen || 'Ja'}
+
Proshop: {renderValue(amenities.proshop)}
+
Kølleutleie: {amenities.kolleutleie || 'Ja'}
+
Bilutleie: {amenities.bilutleie || 'Nei'}
+
Simulator: {renderValue(amenities.simulator)}
+
Kafé: {renderValue(amenities.kafe)}
+
+ {/* Golfamore og NSG */}
+
+ Golfamore:
+
+ {hasGolfamore ? {golfamoreData.gyldighet || "Ja"} : "Nei"}
+
+
+
+
Seniorgolf (NSG):
+
+ {hasNSG && facility.nsg_url
+ ? Ja (Vis Avtale)
+ : (hasNSG ? Ja : "Nei")
+ }
+
+
+
+ {/* SAMARBEIDENDE KLUBBER */}
+ {cooperatingClubs.length > 0 && (
+
+
Samarbeider med:
+
+ {cooperatingClubs.map((slug: string) => (
+
+ {slug.replace('-golfklubb', '').replace(/-/g, ' ')}
+
+ ))}
+
+
+ )}
+
+
+
+
+ {/* 5. VÆR SEKSJON */}
+
+ Vær for {facility.name}
+
+ {facility.weather_url ? (
) :
Værvarsel ikke tilgjengelig
}
+
+
+
+ {/* 6. KART SEKSJON */}
+
+
+ {/* 7. VIDEO SEKSJON */}
+ {facility.video_url && (
+
+ )}
+
+ {/* 8. PRISER (MEDLEMSKAP, GREENFEE & VTG) */}
+
+
+ Priser
+
+
+
+
+ {/* VENSTRE KOLONNE: MEDLEMSKAP */}
+ {(facility.standard_medlemskap || facility.rimeligste_alternativ) && (
+
+
+
+ ⛳ Medlemskap
+
+ {facility.medlemskap_url && (
+
+ Se alle →
+
+ )}
+
+
+
+ {facility.standard_medlemskap && (
+
+
Mest valgte
+
Standard
+
{facility.standard_medlemskap},-
+ {facility.standard_medlemskap_navn &&
{facility.standard_medlemskap_navn}
}
+ {facility.standard_medlemskap_kommentarer && (
+
+ {facility.standard_medlemskap_kommentarer.split('\n').map((line: string, i: number) => (
+ {line}
+ ))}
+
+ )}
+
+ )}
+
+ {facility.rimeligste_alternativ && (
+
+
Rimeligste golfkort
+
{facility.rimeligste_alternativ},-
+ {facility.rimeligste_navn &&
{facility.rimeligste_navn}
}
+
+ )}
+
+
+ )}
+
+ {/* HØYRE KOLONNE: GREENFEE */}
+ {greenfeeRaw && greenfeeRaw.length > 0 && (
+
+
+
+
+
+
+
+ Bane/Kat.
+ Voksen
+ Junior
+
+
+
+ {greenfeeRaw.map((gf: any, idx: number) => (
+
+
+ {gf.banenavn}
+ {gf.priskategori}
+
+
+ {gf.pris_voksne ? `${gf.pris_voksne},-` : '-'}
+
+
+ {gf.pris_junior ? `${gf.pris_junior},-` : '-'}
+
+
+ ))}
+
+
+
+ {facility.guest_requirements && (
+
+ Krav: {facility.guest_requirements}
+
+ )}
+
+ )}
+
+
+ {/* VEIEN TIL GOLF (VTG) - FULL BREDDE UNDER */}
+ {(facility.vtg_pris || facility.vtg_beskrivelse || (vtgDatoer && vtgDatoer.length > 0)) && (
+
+ {/* Bakgrunnseffekt */}
+
🏌️♂️
+
+
+
+ Nybegynnerkurs (Veien til Golf)
+
+
+ {facility.vtg_beskrivelse && (
+
+ {facility.vtg_beskrivelse}
+
+ )}
+
+
+
+ {/* Pris */}
+ {facility.vtg_pris && (
+
+ Standard voksenpris
+ {facility.vtg_pris},-
+
+ )}
+
+ {/* Datoer */}
+ {vtgDatoer && vtgDatoer.length > 0 && (
+
+
Kommende kurs:
+
+ {vtgDatoer.map((kurs: any, i: number) => {
+ const status = (kurs.status || '').toLowerCase();
+ const isFull = status.includes('full');
+ const isWaitlist = status.includes('vente') || status.includes('få');
+
+ let badgeColor = "bg-white/20 text-white";
+ if (isFull) badgeColor = "bg-red-500/80 text-white line-through opacity-75";
+ if (isWaitlist) badgeColor = "bg-yellow-400 text-[#11280f]";
+
+ return (
+
+ {kurs.dato}
+ {kurs.status}
+
+ );
+ })}
+
+
+ )}
+
+ {/* Påmeldingsknapp */}
+ {facility.vtg_lenke && (
+
+ Påmelding ↗
+
+ )}
+
+
+
+ )}
+
+
+ {/* 9. SCOREKORT SEKSJON */}
+
+ Scorekort
+
+ {activeCourses.map((c: any) => (
+
+ ))}
+
+
+
+
+ {showBackToTop && (
+ 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">↑
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/kode_eksport_3/frontend_src_app_golfbaner_[slug]_page_tsx.txt b/kode_eksport_3/frontend_src_app_golfbaner_[slug]_page_tsx.txt
new file mode 100644
index 0000000..fbae7a1
--- /dev/null
+++ b/kode_eksport_3/frontend_src_app_golfbaner_[slug]_page_tsx.txt
@@ -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 Fant ikke golfbanen...
;
+ }
+
+ // Vi sender dataene til den navngitte komponenten
+ return ;
+}
diff --git a/kode_eksport_3/frontend_src_app_layout_tsx.txt b/kode_eksport_3/frontend_src_app_layout_tsx.txt
new file mode 100644
index 0000000..97d3128
--- /dev/null
+++ b/kode_eksport_3/frontend_src_app_layout_tsx.txt
@@ -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 (
+
+
+
+ {children}
+
+
+ );
+}
\ No newline at end of file
diff --git a/kode_eksport_3/frontend_src_app_page_tsx.txt b/kode_eksport_3/frontend_src_app_page_tsx.txt
new file mode 100644
index 0000000..4115f15
--- /dev/null
+++ b/kode_eksport_3/frontend_src_app_page_tsx.txt
@@ -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 (
+
+ {/* Wrapper slideren i en div som skjuler den på mobil (hidden) og viser den på PC (md:block) */}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/kode_eksport_3/frontend_src_components_Header_tsx.txt b/kode_eksport_3/frontend_src_components_Header_tsx.txt
new file mode 100644
index 0000000..48bf3de
--- /dev/null
+++ b/kode_eksport_3/frontend_src_components_Header_tsx.txt
@@ -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 (
+
+
+
+ {/* LOGO */}
+
+
+
+
+ {/* DESKTOP NAV */}
+
+ Hjem
+ Finn Bane
+ Medlemskap
+ Om oss
+ Admin
+
+
+ {/* HAMBURGER (Mobil) */}
+
setIsOpen(!isOpen)} className="md:hidden p-2 text-[#11280f]">
+
+
+
+
+
+
+ {/* MOBIL MENY OVERLAY */}
+ {isOpen && (
+
+ setIsOpen(false)} href="/" className="text-lg font-black uppercase text-[#11280f]">Hjem
+ setIsOpen(false)} href="/golfbaner" className="text-lg font-black uppercase text-[#11280f]">Finn Bane
+ setIsOpen(false)} href="/medlemskap" className="text-lg font-black uppercase text-[#11280f]">Medlemskap
+ setIsOpen(false)} href="/logg-inn" className="text-[#ff5722] font-black uppercase">Admin Logg inn
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/kode_eksport_3/frontend_src_components_ScrapeMethodSelect_tsx.txt b/kode_eksport_3/frontend_src_components_ScrapeMethodSelect_tsx.txt
new file mode 100644
index 0000000..8b93e85
--- /dev/null
+++ b/kode_eksport_3/frontend_src_components_ScrapeMethodSelect_tsx.txt
@@ -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 (
+ handleMethodChange(e.target.value)}
+ disabled={isLoading}
+ className={`border rounded p-1 text-sm transition-colors duration-300 ${statusColor} ${isLoading ? 'opacity-50' : ''}`}
+ >
+ Standard (CSS)
+ ✨ Gemini AI (LLM)
+ Golfbox iframe
+ Auto-klikk + CSS
+ Ingen (Avslått)
+
+ );
+}
\ No newline at end of file
diff --git a/kode_eksport_3/frontend_src_config_constants_ts.txt b/kode_eksport_3/frontend_src_config_constants_ts.txt
new file mode 100644
index 0000000..4b54ddf
--- /dev/null
+++ b/kode_eksport_3/frontend_src_config_constants_ts.txt
@@ -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 = {
+ "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 = {
+ "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"]
+};
\ No newline at end of file
diff --git a/kode_eksport_3/frontend_src_middleware_ts.txt b/kode_eksport_3/frontend_src_middleware_ts.txt
new file mode 100644
index 0000000..f01dfac
--- /dev/null
+++ b/kode_eksport_3/frontend_src_middleware_ts.txt
@@ -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*'],
+};
\ No newline at end of file
diff --git a/losby_dump.txt b/losby_dump.txt
deleted file mode 100644
index 713a79f..0000000
--- a/losby_dump.txt
+++ /dev/null
@@ -1,72 +0,0 @@
--[ RECORD 1 ]---------------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
-id | 129
-name | Losby Golfklubb
-slug | losby-golfklubb
-description | Anlegget er plassert i den naturskjønne Losbydalen i Lørenskog kommune, ca.20 minutter fra Oslo sentrum. Anlegget omfatter 27 hull, omfattende treningsfasiliteter, innendørs golfsenter og en av landets fineste proshopper, samt hotell, konferansesenter og restaurant i Losby Gods.\r +
- | \r +
- | Losbys utfordrende mesterskapsbane, Østmork (par 72), er anlagt på begge sider av elven i Losbydalen. Banen kjennetegnes av sine store greener, varierte hull, den buktende elven, og av å være en bane som kan utfordre spillere på alle plan. Her testes alle aspekter av spillet i tur og orden fra korte par tre hull hvor man må være pinlig nøyaktig, til lange par fire hvor vannet lurer, og par fem hvor innspillet er like viktig som en god drive.\r +
- | \r +
- | Foto: Kevin Myklebust
-established_year | 1999
-season | April-oktober
-address | Losbyveien 270
-zipcode |
-city | 1475 Finstadjordet
-county | Akershus
-lat | 59.8868597
-lng | 10.983609099999967
-email | info@losby.no
-phone | +47 67 92 33 40
-website_url | https://losby.no/
-golfbox_booking_url | http://www.golfbox.no/site/system/redirect.asp?locale=nb_NO&rUrl=%2Fsite%2Fressources%2Fbooking%2Fgrid.asp%3FRessource_GUID%3D%{3C44C599-4A4C-40D9-8AF7-9F3CDB9EDD7F}
-golfbox_tournament_url | https://www.golfbox.no/portal/golf_info/gbtourframe.asp?language=1044#/customer/243/schedule
-facebook_url |
-instagram_url |
-weather_url | https://www.yr.no/nb/graf/dag/0-9/Norge/Akershus/L%C3%B8renskog/Losby%20golfbane
-webcam_url | https://on.windy.com/4qt2e
-golfamore | f
-created_at | 2026-02-26 15:11:41.794387
-image_url | /media/main_losby-golfklubb.jpg
-amenities | {"pro": "David A. Loyd ", "kafe": "Losby Gods ", "proshop": "Ja ", "bilutleie": "Ja", "simulator": "Simulatorsenter ", "antall_hull": "18+9", "kolleutleie": "Ja", "drivingrange": "Ja", "treningsgreen": "Ja"}
-greenfee | [{"banenavn": "Østmork", "pris_junior": 525, "pris_voksne": 1050, "priskategori": "Primetime: Hverdager 15:00 – 18:00"}, {"banenavn": "Østmork", "pris_junior": 525, "pris_voksne": 1050, "priskategori": "Primetime: Helger 09:00 – 13:00"}, {"banenavn": "Østmork", "pris_junior": 420, "pris_voksne": 840, "priskategori": "Øvrige tider"}, {"banenavn": "Vestmork", "pris_junior": 168, "pris_voksne": 336, "priskategori": "Alle dager"}]
-architect | Peter Nordwall
-membership |
-vtg |
-video_url |
-baneguide_url | https://losby.no/banene/baneguide
-logo_url | /media/logo_losby-golfklubb.png
-flyfoto_url | http://kart.finn.no/?lng=10.99187&lat=59.88954&zoom=15&mapType=norortho&markers=10.98147,59.88812,r,Brukerdefinert
-guest_requirements | ?
-status_updated_at | 2026-03-06
-gallery | []
-faqs | []
-shotzoom | [{"shotzoom_url": "https://shotzoom.com/courses/13844534121/losby-golfklubb--ostmork-finstadjordet#", "shotzoom_beskrivelse": " (Østmork)"}, {"shotzoom_url": "https://shotzoom.com/courses/38894085676/losby-golfklubb--vestmork-finstadjordet#", "shotzoom_beskrivelse": " (Vestmork)"}]
-front_image_url |
-nsg_url |
-nsg_description |
-nsg_data | {}
-golfamore_data |
-ngf_number | 72
-golfbox_club_id | 243
-golfbox_booking_id |
-facebook_id |
-instagram_place_id |
-tournament_url |
-footnote | 2024-10-15 - 2024-10-27: Banene forblir åpne i uke 43, fra 11:00 - 16:00, med shotgun start for medlemmene i helgene. Banearbeid forekommer. Staker, benker og søppelbøtter tas inn. Alle bunkere er ute av spill.
-social_links | [{"nettverks-url": "https://nb-no.facebook.com/pages/Losby-Golfklubb/197645840275855", "sosialt_nettverk": "Facebook"}, {"nettverks-url": "http://instagram.com/losbygolf", "sosialt_nettverk": "Instagram"}]
-webcam_html |
-length_meters | 6385
-navn_standard_medlemskap | Full spillerett -uten oppsigelsestid
-standard_medlemskap | 12600
-standard_medlemskap_kommentarer | Pr. 31.07.2024
-navn_rimeligste_alternativ | Medlem uten spillerett
-rimeligste_alternativ | 1800
-rimeligste_alternativ_kommentarer |
-medlemskap_url | https://losby.no/medlemskap
-banetype | Mesterskapsbane
-scrape_status_url | https://losby.no/
-scrape_status_selector |
-scrape_method | llm_parse
-ai_instruction |
-courses | [{"id": 137, "par": 72, "name": "Østmork", "holes": [{"id": 2251, "par": 5, "lengths": {"kort": 367, "lang": 454, "lengst": 478, "kortest": 285, "mellomkort": null, "mellomlang": 421}, "course_id": 137, "hcp_index": 8, "hole_number": 1}, {"id": 2252, "par": 3, "lengths": {"kort": 72, "lang": 107, "lengst": 117, "kortest": 72, "mellomkort": null, "mellomlang": 95}, "course_id": 137, "hcp_index": 18, "hole_number": 2}, {"id": 2253, "par": 5, "lengths": {"kort": 398, "lang": 455, "lengst": 486, "kortest": 315, "mellomkort": null, "mellomlang": 429}, "course_id": 137, "hcp_index": 10, "hole_number": 3}, {"id": 2254, "par": 4, "lengths": {"kort": 313, "lang": 350, "lengst": 377, "kortest": 273, "mellomkort": null, "mellomlang": 350}, "course_id": 137, "hcp_index": 6, "hole_number": 4}, {"id": 2255, "par": 4, "lengths": {"kort": 315, "lang": 387, "lengst": 410, "kortest": 245, "mellomkort": null, "mellomlang": 315}, "course_id": 137, "hcp_index": 2, "hole_number": 5}, {"id": 2256, "par": 3, "lengths": {"kort": 120, "lang": 151, "lengst": 173, "kortest": 80, "mellomkort": null, "mellomlang": 135}, "course_id": 137, "hcp_index": 12, "hole_number": 6}, {"id": 2257, "par": 4, "lengths": {"kort": 250, "lang": 311, "lengst": 382, "kortest": 220, "mellomkort": null, "mellomlang": 311}, "course_id": 137, "hcp_index": 14, "hole_number": 7}, {"id": 2258, "par": 3, "lengths": {"kort": 98, "lang": 136, "lengst": 159, "kortest": 98, "mellomkort": null, "mellomlang": 121}, "course_id": 137, "hcp_index": 16, "hole_number": 8}, {"id": 2259, "par": 4, "lengths": {"kort": 283, "lang": 376, "lengst": 376, "kortest": 223, "mellomkort": null, "mellomlang": 283}, "course_id": 137, "hcp_index": 4, "hole_number": 9}, {"id": 2260, "par": 3, "lengths": {"kort": 158, "lang": 191, "lengst": 211, "kortest": 158, "mellomkort": null, "mellomlang": 176}, "course_id": 137, "hcp_index": 5, "hole_number": 10}, {"id": 2261, "par": 5, "lengths": {"kort": 408, "lang": 487, "lengst": 501, "kortest": 228, "mellomkort": null, "mellomlang": 472}, "course_id": 137, "hcp_index": 15, "hole_number": 11}, {"id": 2262, "par": 4, "lengths": {"kort": 246, "lang": 301, "lengst": 304, "kortest": 242, "mellomkort": null, "mellomlang": 301}, "course_id": 137, "hcp_index": 13, "hole_number": 12}, {"id": 2263, "par": 3, "lengths": {"kort": 122, "lang": 160, "lengst": 183, "kortest": 122, "mellomkort": null, "mellomlang": 141}, "course_id": 137, "hcp_index": 7, "hole_number": 13}, {"id": 2264, "par": 5, "lengths": {"kort": 430, "lang": 516, "lengst": 539, "kortest": 350, "mellomkort": null, "mellomlang": 430}, "course_id": 137, "hcp_index": 9, "hole_number": 14}, {"id": 2265, "par": 4, "lengths": {"kort": 285, "lang": 338, "lengst": 359, "kortest": 245, "mellomkort": null, "mellomlang": 325}, "course_id": 137, "hcp_index": 3, "hole_number": 15}, {"id": 2266, "par": 4, "lengths": {"kort": 321, "lang": 381, "lengst": 434, "kortest": 241, "mellomkort": null, "mellomlang": 321}, "course_id": 137, "hcp_index": 1, "hole_number": 16}, {"id": 2267, "par": 5, "lengths": {"kort": 376, "lang": 460, "lengst": 505, "kortest": 310, "mellomkort": null, "mellomlang": 440}, "course_id": 137, "hcp_index": 17, "hole_number": 17}, {"id": 2268, "par": 4, "lengths": {"kort": 272, "lang": 325, "lengst": 391, "kortest": 220, "mellomkort": null, "mellomlang": 325}, "course_id": 137, "hcp_index": 11, "hole_number": 18}], "status": "stengt", "architect": "Peter Nordwall", "tee_boxes": {"damer": [{"baneverdi_damer": "78,6", "slopeverdi_damer": "142", "navn_utslag_damer": "59"}, {"baneverdi_damer": "75,6", "slopeverdi_damer": "136", "navn_utslag_damer": "54"}, {"baneverdi_damer": "72,2", "slopeverdi_damer": "128", "navn_utslag_damer": "48"}, {"baneverdi_damer": "66,7", "slopeverdi_damer": "117", "navn_utslag_damer": "39"}], "herrer": [{"baneverdi": "74,6", "slopeverdi": "140", "navn_utslag": "64"}, {"baneverdi": "72,1", "slopeverdi": "135", "navn_utslag": "59"}, {"baneverdi": "69,6", "slopeverdi": "130", "navn_utslag": "54"}, {"baneverdi": "66,9", "slopeverdi": "124", "navn_utslag": "48"}, {"baneverdi": "63,2", "slopeverdi": "117", "navn_utslag": "39"}]}, "created_at": "2026-02-26T20:08:27.74249", "course_type": null, "facility_id": 129, "length_meters": 6385, "is_main_course": true, "scrape_keyword": null}, {"id": 138, "par": 70, "name": "Vestmork", "holes": [{"id": 2269, "par": 4, "lengths": {"kort": null, "lang": 242, "lengst": 280, "kortest": 184, "mellomkort": null, "mellomlang": null}, "course_id": 138, "hcp_index": 6, "hole_number": 1}, {"id": 2270, "par": 3, "lengths": {"kort": null, "lang": 94, "lengst": 127, "kortest": 94, "mellomkort": null, "mellomlang": null}, "course_id": 138, "hcp_index": 16, "hole_number": 2}, {"id": 2271, "par": 5, "lengths": {"kort": null, "lang": 317, "lengst": 372, "kortest": 210, "mellomkort": null, "mellomlang": null}, "course_id": 138, "hcp_index": 2, "hole_number": 3}, {"id": 2272, "par": 4, "lengths": {"kort": null, "lang": 213, "lengst": 252, "kortest": 180, "mellomkort": null, "mellomlang": null}, "course_id": 138, "hcp_index": 10, "hole_number": 4}, {"id": 2273, "par": 3, "lengths": {"kort": null, "lang": 111, "lengst": 133, "kortest": 90, "mellomkort": null, "mellomlang": null}, "course_id": 138, "hcp_index": 12, "hole_number": 5}, {"id": 2274, "par": 4, "lengths": {"kort": null, "lang": 284, "lengst": 333, "kortest": 218, "mellomkort": null, "mellomlang": null}, "course_id": 138, "hcp_index": 4, "hole_number": 6}, {"id": 2275, "par": 4, "lengths": {"kort": null, "lang": 146, "lengst": 208, "kortest": 130, "mellomkort": null, "mellomlang": null}, "course_id": 138, "hcp_index": 14, "hole_number": 7}, {"id": 2276, "par": 5, "lengths": {"kort": null, "lang": 293, "lengst": 355, "kortest": 191, "mellomkort": null, "mellomlang": null}, "course_id": 138, "hcp_index": 8, "hole_number": 8}, {"id": 2277, "par": 3, "lengths": {"kort": null, "lang": 78, "lengst": 111, "kortest": 78, "mellomkort": null, "mellomlang": null}, "course_id": 138, "hcp_index": 18, "hole_number": 9}, {"id": 2278, "par": 4, "lengths": {"kort": null, "lang": 242, "lengst": 280, "kortest": 184, "mellomkort": null, "mellomlang": null}, "course_id": 138, "hcp_index": 5, "hole_number": 10}, {"id": 2279, "par": 3, "lengths": {"kort": null, "lang": 94, "lengst": 127, "kortest": 94, "mellomkort": null, "mellomlang": null}, "course_id": 138, "hcp_index": 15, "hole_number": 11}, {"id": 2280, "par": 5, "lengths": {"kort": null, "lang": 317, "lengst": 372, "kortest": 210, "mellomkort": null, "mellomlang": null}, "course_id": 138, "hcp_index": 1, "hole_number": 12}, {"id": 2281, "par": 4, "lengths": {"kort": null, "lang": 213, "lengst": 252, "kortest": 180, "mellomkort": null, "mellomlang": null}, "course_id": 138, "hcp_index": 9, "hole_number": 13}, {"id": 2282, "par": 3, "lengths": {"kort": null, "lang": 111, "lengst": 133, "kortest": 90, "mellomkort": null, "mellomlang": null}, "course_id": 138, "hcp_index": 11, "hole_number": 14}, {"id": 2283, "par": 4, "lengths": {"kort": null, "lang": 284, "lengst": 333, "kortest": 218, "mellomkort": null, "mellomlang": null}, "course_id": 138, "hcp_index": 3, "hole_number": 15}, {"id": 2284, "par": 4, "lengths": {"kort": null, "lang": 146, "lengst": 208, "kortest": 130, "mellomkort": null, "mellomlang": null}, "course_id": 138, "hcp_index": 13, "hole_number": 16}, {"id": 2285, "par": 5, "lengths": {"kort": null, "lang": 293, "lengst": 355, "kortest": 191, "mellomkort": null, "mellomlang": null}, "course_id": 138, "hcp_index": 7, "hole_number": 17}, {"id": 2286, "par": 3, "lengths": {"kort": null, "lang": 78, "lengst": 111, "kortest": 78, "mellomkort": null, "mellomlang": null}, "course_id": 138, "hcp_index": 17, "hole_number": 18}], "status": "stengt", "architect": "Peter Nordwall", "tee_boxes": {"damer": [{"baneverdi_damer_bane_to": "68,6", "slopeverdi_damer_bane_to": "120", "navn_utslag_damer_bane_to": "44"}, {"baneverdi_damer_bane_to": "63,9", "slopeverdi_damer_bane_to": "109", "navn_utslag_damer_bane_to": "36"}, {"baneverdi_damer_bane_to": "60,0", "slopeverdi_damer_bane_to": "101", "navn_utslag_damer_bane_to": "28"}], "herrer": [{"baneverdi_bane_to": "63,8", "slopeverdi_bane_to": "111", "navn_utslag_bane_to": "44"}, {"baneverdi_bane_to": "61,3", "slopeverdi_bane_to": "107", "navn_utslag_bane_to": "36"}, {"baneverdi_bane_to": "58,8", "slopeverdi_bane_to": "101", "navn_utslag_bane_to": "28"}]}, "created_at": "2026-02-26T20:08:27.964244", "course_type": null, "facility_id": 129, "length_meters": 4342, "is_main_course": false, "scrape_keyword": null}]
-
diff --git a/nsg.txt b/nsg.txt
deleted file mode 100644
index 7048269..0000000
--- a/nsg.txt
+++ /dev/null
@@ -1,3160 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Fordelskortet - Seniorgolf
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Skip to content
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/rene_urler.txt b/rene_urler.txt
deleted file mode 100644
index ff9e9ba..0000000
--- a/rene_urler.txt
+++ /dev/null
@@ -1,56 +0,0 @@
-https://seniorgolf.no/lojalitetskort/10-hos-flight-park/
-https://seniorgolf.no/lojalitetskort/10-hos-flight-park/
-https://seniorgolf.no/lojalitetskort/alesund-gk/
-https://seniorgolf.no/lojalitetskort/alesund-gk/
-https://seniorgolf.no/lojalitetskort/arendal-omegn-gk/
-https://seniorgolf.no/lojalitetskort/arendal-omegn-gk/
-https://seniorgolf.no/lojalitetskort/asker-gk/
-https://seniorgolf.no/lojalitetskort/asker-gk/
-https://seniorgolf.no/lojalitetskort/askim-gk/
-https://seniorgolf.no/lojalitetskort/askim-gk/
-https://seniorgolf.no/lojalitetskort/atlungstad-gk/
-https://seniorgolf.no/lojalitetskort/atlungstad-gk/
-https://seniorgolf.no/lojalitetskort/baerum-gk/
-https://seniorgolf.no/lojalitetskort/baerum-gk/
-https://seniorgolf.no/lojalitetskort/bamble-gk/
-https://seniorgolf.no/lojalitetskort/bamble-gk/
-https://seniorgolf.no/lojalitetskort/bjaavann-gk/
-https://seniorgolf.no/lojalitetskort/bjaavann-gk/
-https://seniorgolf.no/lojalitetskort/bjornefjorden-gk/
-https://seniorgolf.no/lojalitetskort/bjornefjorden-gk/
-https://seniorgolf.no/lojalitetskort/borre-gk/
-https://seniorgolf.no/lojalitetskort/borre-gk/
-https://seniorgolf.no/lojalitetskort/borregaard-gk/
-https://seniorgolf.no/lojalitetskort/borregaard-gk/
-https://seniorgolf.no/lojalitetskort/drammen-gk/
-https://seniorgolf.no/lojalitetskort/drammen-gk/
-https://seniorgolf.no/lojalitetskort/eiker-gk/
-https://seniorgolf.no/lojalitetskort/eiker-gk/
-https://seniorgolf.no/lojalitetskort/elverum-gk/
-https://seniorgolf.no/lojalitetskort/elverum-gk/
-https://seniorgolf.no/lojalitetskort/fana-gk/
-https://seniorgolf.no/lojalitetskort/fana-gk/
-https://seniorgolf.no/lojalitetskort/gamle-fredrikstad-gk/
-https://seniorgolf.no/lojalitetskort/gamle-fredrikstad-gk/
-https://seniorgolf.no/lojalitetskort/gjerdrum-gk/
-https://seniorgolf.no/lojalitetskort/gjerdrum-gk/
-https://seniorgolf.no/lojalitetskort/gjersjoen-gk/
-https://seniorgolf.no/lojalitetskort/gjersjoen-gk/
-https://seniorgolf.no/lojalitetskort/gjovik-toten-gk/
-https://seniorgolf.no/lojalitetskort/gjovik-toten-gk/
-https://seniorgolf.no/lojalitetskort/grenland-gk/
-https://seniorgolf.no/lojalitetskort/grenland-gk/
-https://seniorgolf.no/lojalitetskort/groruddalen-gk/
-https://seniorgolf.no/lojalitetskort/groruddalen-gk/
-https://seniorgolf.no/lojalitetskort/hafjell-gk/
-https://seniorgolf.no/lojalitetskort/hafjell-gk/
-https://seniorgolf.no/lojalitetskort/hakadal-gk/
-https://seniorgolf.no/lojalitetskort/hakadal-gk/
-https://seniorgolf.no/lojalitetskort/halden-gk/
-https://seniorgolf.no/lojalitetskort/halden-gk/
-https://seniorgolf.no/lojalitetskort/hallingdal-gk/
-https://seniorgolf.no/lojalitetskort/hallingdal-gk/
-https://seniorgolf.no/lojalitetskort/hauger-gk/
-https://seniorgolf.no/lojalitetskort/hauger-gk/
-https://seniorgolf.no/lojalitetskort/haugesund-gk/
-https://seniorgolf.no/lojalitetskort/haugesund-gk/
diff --git a/struktur2_dump.txt b/struktur2_dump.txt
new file mode 100644
index 0000000..38bce9f
--- /dev/null
+++ b/struktur2_dump.txt
@@ -0,0 +1,639 @@
+--
+-- PostgreSQL database dump
+--
+
+-- Dumped from database version 15.8 (Debian 15.8-1.pgdg110+1)
+-- Dumped by pg_dump version 15.8 (Debian 15.8-1.pgdg110+1)
+
+SET statement_timeout = 0;
+SET lock_timeout = 0;
+SET idle_in_transaction_session_timeout = 0;
+SET client_encoding = 'UTF8';
+SET standard_conforming_strings = on;
+SELECT pg_catalog.set_config('search_path', '', false);
+SET check_function_bodies = false;
+SET xmloption = content;
+SET client_min_messages = warning;
+SET row_security = off;
+
+--
+-- Name: tiger; Type: SCHEMA; Schema: -; Owner: teeoff_admin
+--
+
+CREATE SCHEMA tiger;
+
+
+ALTER SCHEMA tiger OWNER TO teeoff_admin;
+
+--
+-- Name: tiger_data; Type: SCHEMA; Schema: -; Owner: teeoff_admin
+--
+
+CREATE SCHEMA tiger_data;
+
+
+ALTER SCHEMA tiger_data OWNER TO teeoff_admin;
+
+--
+-- Name: topology; Type: SCHEMA; Schema: -; Owner: teeoff_admin
+--
+
+CREATE SCHEMA topology;
+
+
+ALTER SCHEMA topology OWNER TO teeoff_admin;
+
+--
+-- Name: SCHEMA topology; Type: COMMENT; Schema: -; Owner: teeoff_admin
+--
+
+COMMENT ON SCHEMA topology IS 'PostGIS Topology schema';
+
+
+--
+-- Name: fuzzystrmatch; Type: EXTENSION; Schema: -; Owner: -
+--
+
+CREATE EXTENSION IF NOT EXISTS fuzzystrmatch WITH SCHEMA public;
+
+
+--
+-- Name: EXTENSION fuzzystrmatch; Type: COMMENT; Schema: -; Owner:
+--
+
+COMMENT ON EXTENSION fuzzystrmatch IS 'determine similarities and distance between strings';
+
+
+--
+-- Name: postgis; Type: EXTENSION; Schema: -; Owner: -
+--
+
+CREATE EXTENSION IF NOT EXISTS postgis WITH SCHEMA public;
+
+
+--
+-- Name: EXTENSION postgis; Type: COMMENT; Schema: -; Owner:
+--
+
+COMMENT ON EXTENSION postgis IS 'PostGIS geometry and geography spatial types and functions';
+
+
+--
+-- Name: postgis_tiger_geocoder; Type: EXTENSION; Schema: -; Owner: -
+--
+
+CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder WITH SCHEMA tiger;
+
+
+--
+-- Name: EXTENSION postgis_tiger_geocoder; Type: COMMENT; Schema: -; Owner:
+--
+
+COMMENT ON EXTENSION postgis_tiger_geocoder IS 'PostGIS tiger geocoder and reverse geocoder';
+
+
+--
+-- Name: postgis_topology; Type: EXTENSION; Schema: -; Owner: -
+--
+
+CREATE EXTENSION IF NOT EXISTS postgis_topology WITH SCHEMA topology;
+
+
+--
+-- Name: EXTENSION postgis_topology; Type: COMMENT; Schema: -; Owner:
+--
+
+COMMENT ON EXTENSION postgis_topology IS 'PostGIS topology spatial types and functions';
+
+
+SET default_tablespace = '';
+
+SET default_table_access_method = heap;
+
+--
+-- Name: admins; Type: TABLE; Schema: public; Owner: teeoff_admin
+--
+
+CREATE TABLE public.admins (
+ id integer NOT NULL,
+ username character varying(50) NOT NULL,
+ password_hash text NOT NULL,
+ otp_secret character varying(32),
+ created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
+ email character varying(255)
+);
+
+
+ALTER TABLE public.admins OWNER TO teeoff_admin;
+
+--
+-- Name: admins_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
+--
+
+CREATE SEQUENCE public.admins_id_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER TABLE public.admins_id_seq OWNER TO teeoff_admin;
+
+--
+-- Name: admins_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
+--
+
+ALTER SEQUENCE public.admins_id_seq OWNED BY public.admins.id;
+
+
+--
+-- Name: courses; Type: TABLE; Schema: public; Owner: teeoff_admin
+--
+
+CREATE TABLE public.courses (
+ id integer NOT NULL,
+ facility_id integer,
+ name character varying(255) NOT NULL,
+ holes integer,
+ par integer,
+ length_meters integer,
+ course_type character varying(255),
+ architect character varying(255),
+ status character varying(255),
+ is_main_course boolean DEFAULT true,
+ created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
+ tee_boxes jsonb,
+ scrape_keyword text,
+ slope_valid_until date
+);
+
+
+ALTER TABLE public.courses OWNER TO teeoff_admin;
+
+--
+-- Name: courses_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
+--
+
+CREATE SEQUENCE public.courses_id_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER TABLE public.courses_id_seq OWNER TO teeoff_admin;
+
+--
+-- Name: courses_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
+--
+
+ALTER SEQUENCE public.courses_id_seq OWNED BY public.courses.id;
+
+
+--
+-- Name: facilities; Type: TABLE; Schema: public; Owner: teeoff_admin
+--
+
+CREATE TABLE public.facilities (
+ id integer NOT NULL,
+ name character varying(255) NOT NULL,
+ slug character varying(255) NOT NULL,
+ description text,
+ established_year integer,
+ season character varying(255),
+ address character varying(255),
+ zipcode character varying(50),
+ city character varying(255),
+ county character varying(255),
+ lat double precision,
+ lng double precision,
+ email character varying(255),
+ phone character varying(255),
+ website_url character varying(255),
+ golfbox_booking_url character varying(255),
+ golfbox_tournament_url character varying(255),
+ facebook_url character varying(255),
+ instagram_url character varying(255),
+ weather_url character varying(255),
+ webcam_url character varying(255),
+ golfamore boolean DEFAULT false,
+ created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
+ image_url character varying(500),
+ amenities jsonb,
+ greenfee jsonb,
+ architect text,
+ membership jsonb,
+ vtg jsonb,
+ video_url text,
+ baneguide_url text,
+ logo_url text,
+ flyfoto_url text,
+ guest_requirements text,
+ status_updated_at date,
+ gallery jsonb,
+ faqs jsonb DEFAULT '[]'::jsonb,
+ shotzoom jsonb DEFAULT '[]'::jsonb,
+ front_image_url text,
+ nsg_url text,
+ nsg_description text,
+ nsg_data jsonb DEFAULT '{}'::jsonb,
+ golfamore_data jsonb DEFAULT '{}'::jsonb,
+ ngf_number integer,
+ golfbox_club_id integer,
+ golfbox_booking_id text,
+ facebook_id text,
+ instagram_place_id text,
+ tournament_url text,
+ footnote text,
+ social_links jsonb DEFAULT '[]'::jsonb,
+ webcam_html text,
+ length_meters integer,
+ navn_standard_medlemskap text,
+ standard_medlemskap integer,
+ standard_medlemskap_kommentarer text,
+ navn_rimeligste_alternativ text,
+ rimeligste_alternativ integer,
+ rimeligste_alternativ_kommentarer text,
+ medlemskap_url text,
+ banetype text,
+ scrape_status_url text,
+ scrape_status_selector text,
+ scrape_method character varying(50) DEFAULT 'css_selector'::character varying,
+ ai_instruction text,
+ golfpakker jsonb DEFAULT '[]'::jsonb,
+ rabattert_greenfee jsonb DEFAULT '[]'::jsonb,
+ vtg_presentasjon text,
+ vtg_lenke text,
+ vtg_pris integer,
+ vtg_kursdatoer jsonb DEFAULT '[]'::jsonb,
+ membership_draft jsonb,
+ membership_updated_at timestamp with time zone,
+ greenfee_url character varying(255) DEFAULT NULL::character varying,
+ greenfee_draft jsonb,
+ greenfee_updated_at timestamp with time zone,
+ vtg_beskrivelse text,
+ vtg_datoer jsonb,
+ vtg_draft jsonb,
+ vtg_updated_at timestamp with time zone
+);
+
+
+ALTER TABLE public.facilities OWNER TO teeoff_admin;
+
+--
+-- Name: facilities_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
+--
+
+CREATE SEQUENCE public.facilities_id_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER TABLE public.facilities_id_seq OWNER TO teeoff_admin;
+
+--
+-- Name: facilities_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
+--
+
+ALTER SEQUENCE public.facilities_id_seq OWNED BY public.facilities.id;
+
+
+--
+-- Name: facility_images; Type: TABLE; Schema: public; Owner: teeoff_admin
+--
+
+CREATE TABLE public.facility_images (
+ id integer NOT NULL,
+ facility_id integer,
+ image_url character varying(255) NOT NULL,
+ display_order integer DEFAULT 0,
+ sort_order integer DEFAULT 0
+);
+
+
+ALTER TABLE public.facility_images OWNER TO teeoff_admin;
+
+--
+-- Name: facility_images_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
+--
+
+CREATE SEQUENCE public.facility_images_id_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER TABLE public.facility_images_id_seq OWNER TO teeoff_admin;
+
+--
+-- Name: facility_images_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
+--
+
+ALTER SEQUENCE public.facility_images_id_seq OWNED BY public.facility_images.id;
+
+
+--
+-- Name: hole_lengths; Type: TABLE; Schema: public; Owner: teeoff_admin
+--
+
+CREATE TABLE public.hole_lengths (
+ id integer NOT NULL,
+ hole_id integer,
+ tee_id integer,
+ length_meters integer
+);
+
+
+ALTER TABLE public.hole_lengths OWNER TO teeoff_admin;
+
+--
+-- Name: hole_lengths_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
+--
+
+CREATE SEQUENCE public.hole_lengths_id_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER TABLE public.hole_lengths_id_seq OWNER TO teeoff_admin;
+
+--
+-- Name: hole_lengths_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
+--
+
+ALTER SEQUENCE public.hole_lengths_id_seq OWNED BY public.hole_lengths.id;
+
+
+--
+-- Name: holes; Type: TABLE; Schema: public; Owner: teeoff_admin
+--
+
+CREATE TABLE public.holes (
+ id integer NOT NULL,
+ course_id integer,
+ hole_number integer NOT NULL,
+ par integer,
+ hcp_index integer,
+ lengths jsonb
+);
+
+
+ALTER TABLE public.holes OWNER TO teeoff_admin;
+
+--
+-- Name: holes_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
+--
+
+CREATE SEQUENCE public.holes_id_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER TABLE public.holes_id_seq OWNER TO teeoff_admin;
+
+--
+-- Name: holes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
+--
+
+ALTER SEQUENCE public.holes_id_seq OWNED BY public.holes.id;
+
+
+--
+-- Name: tees; Type: TABLE; Schema: public; Owner: teeoff_admin
+--
+
+CREATE TABLE public.tees (
+ id integer NOT NULL,
+ course_id integer,
+ name character varying(50) NOT NULL,
+ cr_men numeric(4,1),
+ slope_men integer,
+ cr_women numeric(4,1),
+ slope_women integer
+);
+
+
+ALTER TABLE public.tees OWNER TO teeoff_admin;
+
+--
+-- Name: tees_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
+--
+
+CREATE SEQUENCE public.tees_id_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER TABLE public.tees_id_seq OWNER TO teeoff_admin;
+
+--
+-- Name: tees_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
+--
+
+ALTER SEQUENCE public.tees_id_seq OWNED BY public.tees.id;
+
+
+--
+-- Name: admins id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.admins ALTER COLUMN id SET DEFAULT nextval('public.admins_id_seq'::regclass);
+
+
+--
+-- Name: courses id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.courses ALTER COLUMN id SET DEFAULT nextval('public.courses_id_seq'::regclass);
+
+
+--
+-- Name: facilities id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.facilities ALTER COLUMN id SET DEFAULT nextval('public.facilities_id_seq'::regclass);
+
+
+--
+-- Name: facility_images id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.facility_images ALTER COLUMN id SET DEFAULT nextval('public.facility_images_id_seq'::regclass);
+
+
+--
+-- Name: hole_lengths id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.hole_lengths ALTER COLUMN id SET DEFAULT nextval('public.hole_lengths_id_seq'::regclass);
+
+
+--
+-- Name: holes id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.holes ALTER COLUMN id SET DEFAULT nextval('public.holes_id_seq'::regclass);
+
+
+--
+-- Name: tees id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.tees ALTER COLUMN id SET DEFAULT nextval('public.tees_id_seq'::regclass);
+
+
+--
+-- Name: admins admins_email_key; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.admins
+ ADD CONSTRAINT admins_email_key UNIQUE (email);
+
+
+--
+-- Name: admins admins_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.admins
+ ADD CONSTRAINT admins_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: admins admins_username_key; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.admins
+ ADD CONSTRAINT admins_username_key UNIQUE (username);
+
+
+--
+-- Name: courses courses_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.courses
+ ADD CONSTRAINT courses_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: facilities facilities_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.facilities
+ ADD CONSTRAINT facilities_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: facilities facilities_slug_key; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.facilities
+ ADD CONSTRAINT facilities_slug_key UNIQUE (slug);
+
+
+--
+-- Name: facility_images facility_images_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.facility_images
+ ADD CONSTRAINT facility_images_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: hole_lengths hole_lengths_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.hole_lengths
+ ADD CONSTRAINT hole_lengths_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: holes holes_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.holes
+ ADD CONSTRAINT holes_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: tees tees_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.tees
+ ADD CONSTRAINT tees_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: facilities unique_slug; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.facilities
+ ADD CONSTRAINT unique_slug UNIQUE (slug);
+
+
+--
+-- Name: courses courses_facility_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.courses
+ ADD CONSTRAINT courses_facility_id_fkey FOREIGN KEY (facility_id) REFERENCES public.facilities(id) ON DELETE CASCADE;
+
+
+--
+-- Name: hole_lengths hole_lengths_hole_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.hole_lengths
+ ADD CONSTRAINT hole_lengths_hole_id_fkey FOREIGN KEY (hole_id) REFERENCES public.holes(id) ON DELETE CASCADE;
+
+
+--
+-- Name: hole_lengths hole_lengths_tee_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.hole_lengths
+ ADD CONSTRAINT hole_lengths_tee_id_fkey FOREIGN KEY (tee_id) REFERENCES public.tees(id) ON DELETE CASCADE;
+
+
+--
+-- Name: holes holes_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.holes
+ ADD CONSTRAINT holes_course_id_fkey FOREIGN KEY (course_id) REFERENCES public.courses(id) ON DELETE CASCADE;
+
+
+--
+-- Name: tees tees_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
+--
+
+ALTER TABLE ONLY public.tees
+ ADD CONSTRAINT tees_course_id_fkey FOREIGN KEY (course_id) REFERENCES public.courses(id) ON DELETE CASCADE;
+
+
+--
+-- PostgreSQL database dump complete
+--
+
diff --git a/struktur3_dump.txt b/struktur3_dump.txt
index c604849..38bce9f 100644
--- a/struktur3_dump.txt
+++ b/struktur3_dump.txt
@@ -165,7 +165,8 @@ CREATE TABLE public.courses (
is_main_course boolean DEFAULT true,
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
tee_boxes jsonb,
- scrape_keyword text
+ scrape_keyword text,
+ slope_valid_until date
);
@@ -262,7 +263,22 @@ CREATE TABLE public.facilities (
scrape_status_url text,
scrape_status_selector text,
scrape_method character varying(50) DEFAULT 'css_selector'::character varying,
- ai_instruction text
+ ai_instruction text,
+ golfpakker jsonb DEFAULT '[]'::jsonb,
+ rabattert_greenfee jsonb DEFAULT '[]'::jsonb,
+ vtg_presentasjon text,
+ vtg_lenke text,
+ vtg_pris integer,
+ vtg_kursdatoer jsonb DEFAULT '[]'::jsonb,
+ membership_draft jsonb,
+ membership_updated_at timestamp with time zone,
+ greenfee_url character varying(255) DEFAULT NULL::character varying,
+ greenfee_draft jsonb,
+ greenfee_updated_at timestamp with time zone,
+ vtg_beskrivelse text,
+ vtg_datoer jsonb,
+ vtg_draft jsonb,
+ vtg_updated_at timestamp with time zone
);
diff --git a/test_tjome.py b/test_tjome.py
deleted file mode 100644
index 17dcef1..0000000
--- a/test_tjome.py
+++ /dev/null
@@ -1,44 +0,0 @@
-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())