Før rekonstruksjon av backend, med skraping av medlemskap
This commit is contained in:
parent
d9dca0ce6a
commit
aca30c9adc
42 changed files with 5776 additions and 116 deletions
Binary file not shown.
148
backend/main.py
148
backend/main.py
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
TEE OFF BACKEND API v3.6.9 - KOBLET PÅ ADMIN KJØR-KNAPP
|
||||
TEE OFF BACKEND API v3.7.0 - KOBLET PÅ FULL ADMIN REDIGERING
|
||||
---------------------------------------------------------------------------
|
||||
REGEL 1: Bruk str (ikke string) for type-hinting.
|
||||
REGEL 2: Inkluder alle subqueries for banestatus og hull-data.
|
||||
|
|
@ -23,7 +23,7 @@ from dotenv import load_dotenv
|
|||
|
||||
# NYE IMPORTER FOR ADMIN PANELET OG BAKGRUNNSJOBBER
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Any
|
||||
import subprocess
|
||||
|
||||
load_dotenv()
|
||||
|
|
@ -51,6 +51,17 @@ class ScrapeSettingsUpdate(BaseModel):
|
|||
class ScrapeRunRequest(BaseModel):
|
||||
facility_ids: List[int]
|
||||
|
||||
class MembershipDraftApproval(BaseModel):
|
||||
facility_id: int
|
||||
navn_standard_medlemskap: Optional[str] = None
|
||||
standard_medlemskap: Optional[int] = None
|
||||
standard_medlemskap_kommentarer: Optional[str] = None
|
||||
navn_rimeligste_alternativ: Optional[str] = None
|
||||
rimeligste_alternativ: Optional[int] = None
|
||||
|
||||
class BulkApprovalRequest(BaseModel):
|
||||
approvals: List[MembershipDraftApproval]
|
||||
|
||||
# --- FUNKSJONER ---
|
||||
def format_row(row):
|
||||
"""
|
||||
|
|
@ -64,16 +75,16 @@ def format_row(row):
|
|||
|
||||
d = dict(row)
|
||||
|
||||
for key in ['status_updated_at', 'created_at']:
|
||||
for key in ['status_updated_at', 'created_at', 'slope_valid_until', 'membership_updated_at']:
|
||||
if isinstance(d.get(key), (date, datetime)):
|
||||
d[key] = d[key].isoformat()
|
||||
|
||||
|
||||
json_list_fields = [
|
||||
'course_statuses', 'courses', 'gallery', 'greenfee',
|
||||
'faqs', 'shotzoom', 'social_links', 'holes'
|
||||
'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs'
|
||||
]
|
||||
json_dict_fields = [
|
||||
'amenities', 'vtg', 'nsg_data', 'golfamore_data'
|
||||
'amenities', 'vtg', 'nsg_data', 'golfamore_data', 'membership_draft'
|
||||
]
|
||||
|
||||
for field in json_list_fields:
|
||||
|
|
@ -146,7 +157,7 @@ async def lifespan(app: FastAPI):
|
|||
# Lukk pool ved avslutning
|
||||
await app.state.pool.close()
|
||||
|
||||
app = FastAPI(title="TeeOff API v3.6.9", lifespan=lifespan)
|
||||
app = FastAPI(title="TeeOff API v3.7.0", lifespan=lifespan)
|
||||
|
||||
# CORS - Tillater både lokal utvikling og produksjonsdomene
|
||||
app.add_middleware(
|
||||
|
|
@ -315,6 +326,84 @@ async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdat
|
|||
raise e
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# --- NYTT ADMIN ENDPOINT FOR FULL OPPDATERING (JSON-EDITOR) ---
|
||||
@app.put("/api/admin/facilities/{facility_id}/full")
|
||||
async def update_facility_full(facility_id: int, request: Request):
|
||||
"""Dynamisk endpoint som oppdaterer anlegg, baner og hull (den fulle editoren)."""
|
||||
data = await request.json()
|
||||
|
||||
# Felter som er trygge å oppdatere manuelt på anlegget
|
||||
allowed_fields = [
|
||||
'name', 'description', 'established_year', 'season', 'banetype', 'architect', 'length_meters',
|
||||
'address', 'zipcode', 'city', 'county', 'lat', 'lng',
|
||||
'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
|
||||
'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url',
|
||||
'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee',
|
||||
'nsg_url', 'nsg_data', 'golfamore', 'golfamore_data',
|
||||
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
|
||||
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url',
|
||||
'vtg_presentasjon', 'vtg_lenke', 'vtg_pris', 'vtg_kursdatoer',
|
||||
'guest_requirements', 'scrape_method', 'scrape_status_url',
|
||||
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at'
|
||||
]
|
||||
|
||||
update_data = {k: v for k, v in data.items() if k in allowed_fields}
|
||||
|
||||
async with app.state.pool.acquire() as conn:
|
||||
async with conn.transaction(): # Sikrer at alt lagres samlet
|
||||
|
||||
# 1. OPPDATER ANLEGG (FACILITIES)
|
||||
if update_data:
|
||||
set_clauses = []
|
||||
values = []
|
||||
for i, (k, v) in enumerate(update_data.items(), 1):
|
||||
if isinstance(v, (dict, list)):
|
||||
set_clauses.append(f"{k} = ${i}::jsonb")
|
||||
values.append(json.dumps(v))
|
||||
else:
|
||||
set_clauses.append(f"{k} = ${i}")
|
||||
values.append(v)
|
||||
|
||||
values.append(facility_id)
|
||||
query = f"UPDATE facilities SET {', '.join(set_clauses)} WHERE id = ${len(values)}"
|
||||
await conn.execute(query, *values)
|
||||
|
||||
# 2. OPPDATER BANER (COURSES) OG HULL (HOLES)
|
||||
courses = data.get('courses', [])
|
||||
for course in courses:
|
||||
course_id = course.get('id')
|
||||
if course_id:
|
||||
# Rens datoformat for PostgreSQL (håndterer Next.js date input)
|
||||
valid_until = course.get('slope_valid_until')
|
||||
if valid_until == "" or valid_until is None:
|
||||
valid_until = None
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE courses
|
||||
SET name=$1, par=$2, length_meters=$3, architect=$4,
|
||||
status=$5, is_main_course=$6, tee_boxes=$7::jsonb,
|
||||
slope_valid_until=$8
|
||||
WHERE id=$9 AND facility_id=$10
|
||||
""",
|
||||
course.get('name'), course.get('par'), course.get('length_meters'),
|
||||
course.get('architect'), course.get('status'), course.get('is_main_course'),
|
||||
json.dumps(course.get('tee_boxes', {})), valid_until, course_id, facility_id)
|
||||
|
||||
# 3. OPPDATER HULL PÅ BANEN (HOLES)
|
||||
holes = course.get('holes', [])
|
||||
for hole in holes:
|
||||
hole_id = hole.get('id')
|
||||
if hole_id:
|
||||
await conn.execute("""
|
||||
UPDATE holes
|
||||
SET par=$1, hcp_index=$2, lengths=$3::jsonb
|
||||
WHERE id=$4 AND course_id=$5
|
||||
""",
|
||||
hole.get('par'), hole.get('hcp_index'),
|
||||
json.dumps(hole.get('lengths', {})), hole_id, course_id)
|
||||
|
||||
return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."}
|
||||
|
||||
# --- NYTT ADMIN ENDPOINT: KJØRER SKRAPEREN FOR VALGTE IDER ---
|
||||
@app.post("/api/admin/run-scraper")
|
||||
async def run_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
|
||||
|
|
@ -327,12 +416,10 @@ async def run_scraper_endpoint(request: ScrapeRunRequest, background_tasks: Back
|
|||
|
||||
print(f"📡 API mottok forespørsel om å kjøre skraping for IDer: {request.facility_ids}")
|
||||
|
||||
# Her starter vi selve magien: Vi legger jobben i FastAPIs BackgroundTasks
|
||||
background_tasks.add_task(run_scrape_worker, request.facility_ids)
|
||||
|
||||
return {"status": "queued", "message": f"Skraping for {len(request.facility_ids)} anlegg ble lagt i kø."}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
"""Enkel sjekk for å se at API og DB lever."""
|
||||
|
|
@ -343,6 +430,49 @@ async def health_check():
|
|||
except Exception as e:
|
||||
return {"status": "unhealthy", "error": str(e)}
|
||||
|
||||
# --- MEDLEMSKAP "VASKERI" ENDEPUNKTER ---
|
||||
|
||||
@app.get("/api/admin/membership/drafts")
|
||||
async def get_membership_drafts():
|
||||
"""Henter alle anlegg som har et ventende forslag fra AI-skraperen."""
|
||||
async with app.state.pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT id, name, slug, medlemskap_url,
|
||||
navn_standard_medlemskap, standard_medlemskap,
|
||||
navn_rimeligste_alternativ, rimeligste_alternativ,
|
||||
membership_draft
|
||||
FROM facilities
|
||||
WHERE membership_draft IS NOT NULL
|
||||
AND membership_draft::text != '{}'
|
||||
ORDER BY name ASC
|
||||
""")
|
||||
return [format_row(row) for row in rows]
|
||||
|
||||
@app.post("/api/admin/membership/approve-bulk")
|
||||
async def approve_membership_bulk(request: BulkApprovalRequest):
|
||||
"""Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet."""
|
||||
async with app.state.pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
for approval in request.approvals:
|
||||
await conn.execute("""
|
||||
UPDATE facilities
|
||||
SET navn_standard_medlemskap = $1,
|
||||
standard_medlemskap = $2,
|
||||
standard_medlemskap_kommentarer = $3,
|
||||
navn_rimeligste_alternativ = $4,
|
||||
rimeligste_alternativ = $5,
|
||||
membership_updated_at = NOW(),
|
||||
membership_draft = NULL
|
||||
WHERE id = $6
|
||||
""",
|
||||
approval.navn_standard_medlemskap,
|
||||
approval.standard_medlemskap,
|
||||
approval.standard_medlemskap_kommentarer,
|
||||
approval.navn_rimeligste_alternativ,
|
||||
approval.rimeligste_alternativ,
|
||||
approval.facility_id)
|
||||
return {"status": "success", "message": f"{len(request.approvals)} anlegg ble oppdatert med nye priser!"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
163
backend/scrape_membership.py
Normal file
163
backend/scrape_membership.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""
|
||||
TEE OFF - MEDLEMSKAPSSKRAPER MED GEMINI AI
|
||||
---------------------------------------------------------------------------
|
||||
Går til oppgitte medlemskaps-URLer, henter ut tekst, og bruker Gemini til å
|
||||
finne 'Standard' og 'Rimeligste' medlemskap basert på TeeOffs definisjoner.
|
||||
Lagrer resultatet som et utkast i databasen (membership_draft).
|
||||
---------------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
from bs4 import BeautifulSoup
|
||||
from playwright.async_api import async_playwright
|
||||
import google.generativeai as genai
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Last inn miljøvariabler
|
||||
load_dotenv()
|
||||
|
||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
||||
|
||||
if not GEMINI_API_KEY:
|
||||
raise ValueError("🚨 GEMINI_API_KEY mangler i .env filen!")
|
||||
|
||||
# Konfigurer Gemini
|
||||
genai.configure(api_key=GEMINI_API_KEY)
|
||||
model = genai.GenerativeModel('gemini-2.5-flash') # Eller gemini-1.5-pro avhengig av hva du har tilgang til
|
||||
|
||||
async def fetch_page_text(url: str) -> str:
|
||||
"""Bruker Playwright for å hente all synlig tekst fra nettsiden."""
|
||||
print(f" 🌐 Laster inn: {url}")
|
||||
try:
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
page = await browser.new_page()
|
||||
# Setter timeout til 15 sekunder
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=15000)
|
||||
|
||||
# Hent hele HTML-innholdet
|
||||
html_content = await page.content()
|
||||
await browser.close()
|
||||
|
||||
# Bruk BeautifulSoup til å renske ut bare den synlige teksten
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
# Fjern script og style tags
|
||||
for script in soup(["script", "style", "nav", "footer", "header"]):
|
||||
script.extract()
|
||||
|
||||
text = soup.get_text(separator=' ', strip=True)
|
||||
|
||||
# Begrens teksten slik at vi ikke sprenger token-grensen til AI (f.eks max 15000 tegn)
|
||||
return text[:15000]
|
||||
except Exception as e:
|
||||
print(f" ❌ Feil ved lasting av side: {e}")
|
||||
return ""
|
||||
|
||||
def analyze_with_gemini(text: str, club_name: str) -> dict:
|
||||
"""Sender teksten til Gemini for å trekke ut priser."""
|
||||
print(f" 🧠 Sender {len(text)} tegn til Gemini for analyse...")
|
||||
|
||||
prompt = f"""
|
||||
Du er en ekspert på norske golfklubber og medlemskap.
|
||||
Din oppgave er å lese teksten hentet fra nettsiden til "{club_name}" og trekke ut to spesifikke medlemskapspriser.
|
||||
|
||||
DEFINISJONER DU MÅ FØLGE STRENGT:
|
||||
1. "Standard medlemskap": Hva vil det koste for en gjennomsnittsgolfer (voksen over 25/30 år, ikke student/senior) å spille SÅ RYE VEDKOMMENDE ØNSKER (Fritt spill) på denne banen i år?
|
||||
2. "Rimeligste alternativ": Det absolutt billigste medlemskapet som gir medlemskap i klubben (golfkortet), forutsatt at man aksepterer å måtte betale greenfee for hver runde man spiller. (Ofte kalt Greenfeemedlem, Postkassemedlem, Fjernmedlem el.l.)
|
||||
|
||||
TEKST FRA NETTSIDEN:
|
||||
{text}
|
||||
|
||||
OPPGAVE:
|
||||
Returner KUN et gyldig JSON-objekt med følgende struktur (og ingenting annet, ingen markdown):
|
||||
{{
|
||||
"foreslatt_standard_navn": "Navnet på medlemskapet (eks: Hovedmedlem Voksen)",
|
||||
"foreslatt_standard_pris": 1234,
|
||||
"foreslatt_standard_kommentar": "Kort evt kommentar (eks: Inkluderer ikke 500kr i dugnadsavgift)",
|
||||
"foreslatt_rimeligste_navn": "Navnet (eks: Greenfeemedlemskap)",
|
||||
"foreslatt_rimeligste_pris": 500,
|
||||
"ai_begrunnelse": "Kort forklaring på hvorfor du valgte disse to, f.eks: 'Valgte Hovedmedlem for fritt spill og Greenfeemedlem fordi...'."
|
||||
}}
|
||||
|
||||
Merk: Hvis prisene mangler, sett pris til null og skriv "Fant ikke" i navnet. Prisen SKAL være et tall (integer), ikke en tekststreng (bruk 6500, ikke "6 500").
|
||||
"""
|
||||
|
||||
try:
|
||||
response = model.generate_content(prompt)
|
||||
raw_response = response.text.strip()
|
||||
|
||||
# Rensker vekk eventuell markdown-formatering som ```json
|
||||
if raw_response.startswith("```json"):
|
||||
raw_response = raw_response[7:]
|
||||
if raw_response.endswith("```"):
|
||||
raw_response = raw_response[:-3]
|
||||
|
||||
return json.loads(raw_response.strip())
|
||||
except Exception as e:
|
||||
print(f" ❌ AI-analyse feilet: {e}")
|
||||
return None
|
||||
|
||||
async def run_scraper(facility_ids=None):
|
||||
"""Hovedfunksjon som henter fra DB, skraper, og lagrer utkast."""
|
||||
print("🚀 Starter Medlemskaps-skraperen...")
|
||||
|
||||
conn = await asyncpg.connect(DB_URL)
|
||||
|
||||
try:
|
||||
# Hent anlegg som har en url for medlemskap
|
||||
query = "SELECT id, name, medlemskap_url FROM facilities WHERE medlemskap_url IS NOT NULL AND medlemskap_url != ''"
|
||||
if facility_ids:
|
||||
query += f" AND id IN ({','.join(map(str, facility_ids))})"
|
||||
|
||||
facilities = await conn.fetch(query)
|
||||
print(f"📋 Fant {len(facilities)} anlegg å skrape.")
|
||||
|
||||
for facility in facilities:
|
||||
fac_id = facility['id']
|
||||
name = facility['name']
|
||||
url = facility['medlemskap_url']
|
||||
|
||||
print(f"\n▶️ Behandler: {name} (ID: {fac_id})")
|
||||
|
||||
# 1. Hent tekst
|
||||
page_text = await fetch_page_text(url)
|
||||
if not page_text or len(page_text) < 50:
|
||||
print(" ⚠️ Fant for lite tekst på siden, hopper over.")
|
||||
continue
|
||||
|
||||
# 2. Analyser med Gemini
|
||||
draft_data = analyze_with_gemini(page_text, name)
|
||||
|
||||
if not draft_data:
|
||||
continue
|
||||
|
||||
# 3. Lagre i databasen som utkast
|
||||
print(f" ✅ AI foreslår: Standard: {draft_data.get('foreslatt_standard_pris')} | Rimeligste: {draft_data.get('foreslatt_rimeligste_pris')}")
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE facilities
|
||||
SET membership_draft = $1::jsonb
|
||||
WHERE id = $2
|
||||
""", json.dumps(draft_data), fac_id)
|
||||
|
||||
print(" 💾 Utkast lagret i databasen!")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
print("\n🏁 Skraping fullført.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Skrap medlemskapspriser via AI.")
|
||||
parser.add_argument("--ids", type=str, help="Kommaseparert liste med facility IDs (eks: 1,5,12)")
|
||||
args = parser.parse_args()
|
||||
|
||||
ids_to_scrape = None
|
||||
if args.ids:
|
||||
ids_to_scrape = [int(x.strip()) for x in args.ids.split(",")]
|
||||
|
||||
asyncio.run(run_scraper(ids_to_scrape))
|
||||
29
fil-tre.txt
29
fil-tre.txt
|
|
@ -1,11 +1,16 @@
|
|||
📁 teeoff/
|
||||
📄 nsg.txt
|
||||
📄 test_tjome.py
|
||||
📄 fil-tre.txt
|
||||
📄 losby_dump.txt
|
||||
📄 seed.sql
|
||||
📄 struktur3_dump.txt
|
||||
📄 eksport_script.py
|
||||
📄 update_golfbox.sql
|
||||
📄 docker-compose.yml
|
||||
📄 schema.sql
|
||||
📄 init.sql
|
||||
📄 rene_urler.txt
|
||||
📁 frontend/
|
||||
📄 eslint.config.mjs
|
||||
📄 next-env.d.ts
|
||||
|
|
@ -678,32 +683,56 @@
|
|||
📄 FacilityDetailView.tsx
|
||||
📁 admin/
|
||||
📄 page.tsx
|
||||
📁 rediger/
|
||||
📁 [slug]/
|
||||
📄 EditFacilityClient.tsx
|
||||
📄 page.tsx
|
||||
📁 login/
|
||||
📄 page.tsx
|
||||
📁 medlemskap/
|
||||
📄 page.tsx
|
||||
📁 kode_eksport_1/
|
||||
📄 backend_scrape_membership_py.txt
|
||||
📄 frontend_src_components_Header_tsx.txt
|
||||
📄 backend_scrape_nsg_3_py.txt
|
||||
📄 frontend_src_app_admin_medlemskap_page_tsx.txt
|
||||
📄 frontend_next-env_d_ts.txt
|
||||
📄 frontend_src_app_layout_tsx.txt
|
||||
📄 frontend_src_app_page_tsx.txt
|
||||
📄 eksport_script_py.txt
|
||||
📄 frontend_src_components_ScrapeMethodSelect_tsx.txt
|
||||
📄 frontend_src_app_golfbaner_[slug]_page_tsx.txt
|
||||
📄 backend_import_wp_py.txt
|
||||
📄 frontend_src_middleware_ts.txt
|
||||
📄 test_tjome_py.txt
|
||||
📄 backend_test_gemini_py.txt
|
||||
📄 frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt
|
||||
📄 frontend_next_config_ts.txt
|
||||
📄 backend_update_admin_py.txt
|
||||
📄 backend_import_nye_felter_py.txt
|
||||
📄 frontend_src_app_admin_login_page_tsx.txt
|
||||
📄 frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt
|
||||
📄 backend_main_py.txt
|
||||
📄 frontend_src_app_admin_rediger_[slug]_page_tsx.txt
|
||||
📄 frontend_src_app_admin_page_tsx.txt
|
||||
📄 frontend_src_app_admin_rediger_[slug]_EditFacilityClient_tsx.txt
|
||||
📄 frontend_src_app_HeroSlider_tsx.txt
|
||||
📄 backend_test_login_py.txt
|
||||
📄 backend_create_admin_py.txt
|
||||
📄 backend_sync_greenfee_py.txt
|
||||
📄 backend_scrape_status_py.txt
|
||||
📄 backend_scrape_golfamore1_3_py.txt
|
||||
📄 frontend_src_app_FacilitySearch_tsx.txt
|
||||
📄 frontend_src_config_constants_ts.txt
|
||||
📄 backend_import_gallery_py.txt
|
||||
📁 backend/
|
||||
📄 scrape_nsg_3.py
|
||||
📄 update_admin.py
|
||||
📄 test_gemini.py
|
||||
📄 import_gallery.py
|
||||
📄 import_nye_felter.py
|
||||
📄 .env
|
||||
📄 scrape_membership.py
|
||||
📄 test_login.py
|
||||
📄 sync_greenfee.py
|
||||
📄 scrape_status.py
|
||||
|
|
|
|||
179
frontend/src/app/admin/medlemskap/page.tsx
Normal file
179
frontend/src/app/admin/medlemskap/page.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"use client";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { API_URL } from "@/config/constants";
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function MembershipWasher() {
|
||||
const [drafts, setDrafts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchDrafts = () => {
|
||||
setLoading(true);
|
||||
fetch(`${API_URL}/admin/membership/drafts`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
// Konverter innkommende drafts til editerbare felter lokalt
|
||||
const editableDrafts = data.map((f: any) => ({
|
||||
...f,
|
||||
edit_standard_navn: f.membership_draft?.foreslatt_standard_navn || f.navn_standard_medlemskap || "",
|
||||
edit_standard_pris: f.membership_draft?.foreslatt_standard_pris || f.standard_medlemskap || "",
|
||||
edit_standard_kommentar: f.membership_draft?.foreslatt_standard_kommentar || "",
|
||||
edit_rimeligste_navn: f.membership_draft?.foreslatt_rimeligste_navn || f.navn_rimeligste_alternativ || "",
|
||||
edit_rimeligste_pris: f.membership_draft?.foreslatt_rimeligste_pris || f.rimeligste_alternativ || "",
|
||||
}));
|
||||
setDrafts(editableDrafts);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDrafts();
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = (checked: boolean) => {
|
||||
if (checked) setSelectedIds(drafts.map(d => d.id));
|
||||
else setSelectedIds([]);
|
||||
};
|
||||
|
||||
const toggleOne = (id: number) => {
|
||||
if (selectedIds.includes(id)) setSelectedIds(selectedIds.filter(i => i !== id));
|
||||
else setSelectedIds([...selectedIds, id]);
|
||||
};
|
||||
|
||||
const updateDraftField = (id: number, field: string, value: any) => {
|
||||
setDrafts(drafts.map(d => d.id === id ? { ...d, [field]: value } : d));
|
||||
};
|
||||
|
||||
const handleApprove = async () => {
|
||||
const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({
|
||||
facility_id: d.id,
|
||||
navn_standard_medlemskap: d.edit_standard_navn,
|
||||
standard_medlemskap: Number(d.edit_standard_pris) || null,
|
||||
standard_medlemskap_kommentarer: d.edit_standard_kommentar,
|
||||
navn_rimeligste_alternativ: d.edit_rimeligste_navn,
|
||||
rimeligste_alternativ: Number(d.edit_rimeligste_pris) || null,
|
||||
}));
|
||||
|
||||
if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne.");
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/admin/membership/approve-bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ approvals: toApprove })
|
||||
});
|
||||
if (res.ok) {
|
||||
alert(`${toApprove.length} anlegg ble oppdatert og lagret til live!`);
|
||||
setSelectedIds([]);
|
||||
fetchDrafts(); // Oppdaterer listen (fjerner de godkjente)
|
||||
} else {
|
||||
alert("Noe gikk galt under lagring.");
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Nettverksfeil");
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-20 text-center font-black animate-pulse">Laster utkast...</div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f1f7ed] p-8 text-[#11280f]">
|
||||
<div className="max-w-[1400px] mx-auto">
|
||||
<div className="flex justify-between items-end mb-10 border-b border-gray-200 pb-6">
|
||||
<div>
|
||||
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
||||
<h1 className="text-4xl font-black">Medlemskaps-Vaskeriet</h1>
|
||||
<p className="text-sm text-gray-600 mt-2">Gå gjennom AI-ens forslag, juster hvis nødvendig, og godkjenn for å publisere. Oppdatert-dato settes automatisk i dag.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
disabled={saving || selectedIds.length === 0}
|
||||
className="bg-[#8bc34a] text-white px-8 py-4 rounded-xl font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50 disabled:scale-100"
|
||||
>
|
||||
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{drafts.length === 0 ? (
|
||||
<div className="bg-white p-20 rounded-[2rem] text-center shadow-sm">
|
||||
<span className="text-6xl mb-4 block">🧹</span>
|
||||
<h2 className="text-2xl font-black text-gray-400">Alt er rent og pent!</h2>
|
||||
<p className="text-gray-500">Ingen ventende forslag fra AI-skraperen akkurat nå.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-4 rounded-2xl shadow-sm flex items-center gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-5 h-5 accent-[#8bc34a] ml-2"
|
||||
checked={selectedIds.length === drafts.length}
|
||||
onChange={(e) => toggleSelectAll(e.target.checked)}
|
||||
/>
|
||||
<span className="font-black uppercase tracking-widest text-xs text-gray-500">Velg Alle</span>
|
||||
</div>
|
||||
|
||||
{drafts.map(draft => (
|
||||
<div key={draft.id} className={`bg-white p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/5' : 'border-transparent'}`}>
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-6 h-6 accent-[#8bc34a] cursor-pointer"
|
||||
checked={selectedIds.includes(draft.id)}
|
||||
onChange={() => toggleOne(draft.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow space-y-4">
|
||||
|
||||
{/* OPPDATERT: Navn + ID Badge */}
|
||||
<div className="flex justify-between items-center border-b pb-4">
|
||||
<h3 className="text-2xl font-black flex items-center gap-3">
|
||||
{draft.name}
|
||||
<span className="text-xs font-mono font-bold bg-gray-100 text-gray-400 px-2 py-1 rounded-md">ID: {draft.id}</span>
|
||||
</h3>
|
||||
<a href={draft.medlemskap_url} target="_blank" className="text-xs font-bold text-blue-600 hover:underline bg-blue-50 px-4 py-2 rounded-lg">Sjekk Klubbens Nettside ↗</a>
|
||||
</div>
|
||||
|
||||
{draft.membership_draft?.ai_begrunnelse && (
|
||||
<div className="bg-blue-50/50 p-4 rounded-xl text-sm italic text-blue-900 border border-blue-100">
|
||||
<strong>🤖 AI Begrunnelse:</strong> {draft.membership_draft.ai_begrunnelse}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-2">
|
||||
{/* Standard */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-black uppercase tracking-widest text-gray-400">Standard Medlemskap (Ubegrenset)</h4>
|
||||
<div className="flex gap-2">
|
||||
<input className="w-2/3 p-3 rounded-xl border border-gray-200 font-bold focus:border-[#8bc34a] outline-none" value={draft.edit_standard_navn} onChange={e => updateDraftField(draft.id, 'edit_standard_navn', e.target.value)} placeholder="Navn (eks. Hovedmedlem)" />
|
||||
<input className="w-1/3 p-3 rounded-xl border border-gray-200 font-bold text-right focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_standard_pris} onChange={e => updateDraftField(draft.id, 'edit_standard_pris', e.target.value)} placeholder="Pris" />
|
||||
</div>
|
||||
<input className="w-full p-3 rounded-xl border border-gray-200 text-sm focus:border-[#8bc34a] outline-none" value={draft.edit_standard_kommentar} onChange={e => updateDraftField(draft.id, 'edit_standard_kommentar', e.target.value)} placeholder="Kommentar (F.eks: Inkluderer ikke treningsavgift)" />
|
||||
<p className="text-[10px] text-gray-400">Gammel pris var: {draft.standard_medlemskap ? `kr ${draft.standard_medlemskap} (${draft.navn_standard_medlemskap})` : 'Ikke registrert'}</p>
|
||||
</div>
|
||||
|
||||
{/* Rimeligste */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-black uppercase tracking-widest text-gray-400">Rimeligste (Betaler Greenfee)</h4>
|
||||
<div className="flex gap-2">
|
||||
<input className="w-2/3 p-3 rounded-xl border border-gray-200 font-bold focus:border-[#8bc34a] outline-none" value={draft.edit_rimeligste_navn} onChange={e => updateDraftField(draft.id, 'edit_rimeligste_navn', e.target.value)} placeholder="Navn (eks. Greenfeemedlem)" />
|
||||
<input className="w-1/3 p-3 rounded-xl border border-gray-200 font-bold text-right focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_rimeligste_pris} onChange={e => updateDraftField(draft.id, 'edit_rimeligste_pris', e.target.value)} placeholder="Pris" />
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 mt-2">Gammel pris var: {draft.rimeligste_alternativ ? `kr ${draft.rimeligste_alternativ} (${draft.navn_rimeligste_alternativ})` : 'Ikke registrert'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -57,7 +57,7 @@ export default function AdminDashboard() {
|
|||
return () => clearInterval(interval);
|
||||
}, [isScraping]);
|
||||
|
||||
// NYTT: Filtreringslogikken som kjører automatisk når facilities eller filteret endres
|
||||
// Filtreringslogikken som kjører automatisk når facilities eller filteret endres
|
||||
const filteredFacilities = useMemo(() => {
|
||||
if (statusFilter === 'alle') return facilities;
|
||||
|
||||
|
|
@ -87,11 +87,10 @@ export default function AdminDashboard() {
|
|||
return { ...facility, course_statuses: filteredCourses };
|
||||
|
||||
}).filter(facility => facility.course_statuses && facility.course_statuses.length > 0);
|
||||
// Skjul anlegget helt hvis det ikke har noen baner som matcher filteret
|
||||
|
||||
}, [facilities, statusFilter]);
|
||||
|
||||
// OPPDATERT: "Velg alle" gjelder nå kun de anleggene som er synlige i filteret
|
||||
// "Velg alle" gjelder kun de anleggene som er synlige i filteret
|
||||
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedFacilities(filteredFacilities.map(f => f.id));
|
||||
|
|
@ -171,12 +170,12 @@ export default function AdminDashboard() {
|
|||
return (
|
||||
<div className="flex min-h-screen bg-[#f1f7ed] font-sans relative overflow-hidden">
|
||||
|
||||
{/* REDIGER-MODAL */}
|
||||
{/* REDIGER-MODAL FOR SKRAPING */}
|
||||
{editingFacility && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<div className="bg-[#11280f] text-white p-6 shrink-0">
|
||||
<h3 className="text-xl font-black uppercase tracking-widest">Rediger Konfigurasjon</h3>
|
||||
<h3 className="text-xl font-black uppercase tracking-widest">Skrape-innstillinger</h3>
|
||||
<p className="text-sm text-[#7ca982]">{editingFacility.name}</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -287,12 +286,17 @@ export default function AdminDashboard() {
|
|||
</div>
|
||||
|
||||
<nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow">
|
||||
<div className={`text-white border-l-4 border-[#8bc34a] py-1 ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4'}`} title="Scraping Monitor">
|
||||
{/* Lenke til Hoveddashbord / Scraping Monitor */}
|
||||
<Link href="/admin" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-[#8bc34a] text-white'}`} title="Scraping Monitor">
|
||||
{isSidebarCollapsed ? 'SM' : 'Scraping Monitor'}
|
||||
</div>
|
||||
<div className={`hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Medlemskap">
|
||||
</Link>
|
||||
|
||||
{/* Lenke til Medlemskaps-Vaskeriet */}
|
||||
<Link href="/admin/medlemskap" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Medlemskap">
|
||||
{isSidebarCollapsed ? 'M' : 'Medlemskap'}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Bildegalleri (Placeholder inntil vi bygger denne) */}
|
||||
<div className={`hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Bildegalleri">
|
||||
{isSidebarCollapsed ? 'B' : 'Bildegalleri'}
|
||||
</div>
|
||||
|
|
@ -333,7 +337,6 @@ export default function AdminDashboard() {
|
|||
</button>
|
||||
</header>
|
||||
|
||||
{/* NYTT: Filter-meny UI */}
|
||||
<div className="flex flex-wrap items-center gap-4 bg-gray-50 p-4 rounded-2xl border border-gray-100 mb-8">
|
||||
<label htmlFor="statusFilter" className="text-xs font-bold text-gray-500 uppercase tracking-widest">Filtrer på status:</label>
|
||||
<select
|
||||
|
|
@ -362,6 +365,7 @@ export default function AdminDashboard() {
|
|||
onChange={handleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="pb-4 w-12 text-center">ID</th>
|
||||
<th className="pb-4 pr-6">Anlegg</th>
|
||||
<th className="pb-4">Konfigurasjon</th>
|
||||
<th className="pb-4">Metode</th>
|
||||
|
|
@ -381,6 +385,11 @@ export default function AdminDashboard() {
|
|||
onChange={(e) => handleSelectOne(f.id, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className="py-6 text-center text-xs font-mono text-gray-400">
|
||||
#{f.id}
|
||||
</td>
|
||||
|
||||
<td className="py-6 pr-6">
|
||||
<div className="font-black text-base md:text-lg whitespace-nowrap">{f.name}</div>
|
||||
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
|
||||
|
|
@ -418,6 +427,7 @@ export default function AdminDashboard() {
|
|||
})}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="py-6 text-right pr-4">
|
||||
<div className="flex flex-col gap-2 items-end">
|
||||
<button
|
||||
|
|
@ -434,6 +444,7 @@ export default function AdminDashboard() {
|
|||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,28 @@ import { useState } from 'react';
|
|||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
// KOMPONENT 1: Viser flate JSON-objekter (som fasiliteter) som rader med Nøkkel og Verdi
|
||||
// KOMPONENT 1: MultiSelect for samarbeidende klubber
|
||||
const MultiSelect = ({ label, options, selected, onChange }: { label: string, options: any[], selected: string[], onChange: (s: string[]) => void }) => {
|
||||
const toggle = (val: string) => {
|
||||
if (selected.includes(val)) onChange(selected.filter(x => x !== val));
|
||||
else onChange([...selected, val]);
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-2 mb-8 col-span-1 md:col-span-2">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">{label}</label>
|
||||
<div className="p-4 rounded-2xl border-2 border-gray-300 bg-white shadow-sm max-h-64 overflow-y-auto grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{options.map(opt => (
|
||||
<label key={opt.slug} className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded-lg cursor-pointer border border-transparent hover:border-gray-200 transition-all">
|
||||
<input type="checkbox" checked={selected.includes(opt.slug)} onChange={() => toggle(opt.slug)} className="w-5 h-5 accent-[#8bc34a]" />
|
||||
<span className="text-sm font-bold text-gray-700">{opt.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// KOMPONENT 2: Viser flate JSON-objekter (som fasiliteter) som rader med Nøkkel og Verdi
|
||||
const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any, onChange: (v: any) => void }) => {
|
||||
const entries = Object.entries(value || {});
|
||||
|
||||
|
|
@ -35,39 +56,38 @@ const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any,
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 mb-8 bg-gray-50 p-6 rounded-[2rem] border border-gray-100">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-[#11280f]">{label}</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-4 mb-8 bg-gray-100 p-6 md:p-8 rounded-[2rem] border border-gray-200 shadow-sm">
|
||||
<label className="text-sm font-black uppercase tracking-widest text-[#11280f]">{label}</label>
|
||||
<div className="space-y-3">
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k} className="flex gap-2 items-center">
|
||||
<div key={k} className="flex gap-3 items-center">
|
||||
<input
|
||||
className="w-1/3 p-3 rounded-xl border border-gray-200 text-xs font-bold focus:border-[#8bc34a] outline-none"
|
||||
className="w-1/3 p-4 rounded-xl border-2 border-gray-300 text-sm font-bold text-black bg-white focus:border-[#8bc34a] outline-none shadow-sm"
|
||||
placeholder="Nøkkel (f.eks proshop)"
|
||||
defaultValue={k.startsWith('ny_rad_') ? '' : k}
|
||||
onBlur={e => updateKey(k, e.target.value, v)}
|
||||
/>
|
||||
<input
|
||||
className="w-full p-3 rounded-xl border border-gray-200 text-xs focus:border-[#8bc34a] outline-none"
|
||||
className="w-full p-4 rounded-xl border-2 border-gray-300 text-base font-medium text-black bg-white focus:border-[#8bc34a] outline-none shadow-sm"
|
||||
placeholder="Verdi (f.eks Ja, eller et navn)"
|
||||
value={String(v)}
|
||||
onChange={e => updateVal(k, e.target.value)}
|
||||
/>
|
||||
<button onClick={() => removeKey(k)} className="p-3 bg-red-100 text-red-600 hover:bg-red-200 rounded-xl font-bold transition-colors">✕</button>
|
||||
<button onClick={() => removeKey(k)} className="p-4 bg-red-100 text-red-700 hover:bg-red-200 hover:text-red-900 rounded-xl font-black text-lg transition-colors border border-red-200">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={addRow} className="mt-2 text-left text-xs font-bold text-[#8bc34a] hover:text-[#11280f] transition-colors">+ Legg til ny rad</button>
|
||||
<button onClick={addRow} className="mt-2 text-left text-sm font-black text-[#8bc34a] hover:text-[#11280f] transition-colors bg-white px-6 py-3 rounded-xl border-2 border-[#8bc34a] self-start">+ Legg til ny rad</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// KOMPONENT 2: Viser Arrays med objekter (som Greenfee-lister) som små pene kort
|
||||
// KOMPONENT 3: Viser Arrays med objekter (som Greenfee-lister) som små pene kort
|
||||
const ListObjectEditor = ({ label, value, templateKeys, onChange }: { label: string, value: any[], templateKeys: string[], onChange: (v: any[]) => void }) => {
|
||||
const items = Array.isArray(value) ? value : [];
|
||||
|
||||
const updateField = (index: number, key: string, val: string | number) => {
|
||||
const newItems = [...items];
|
||||
// Prøv å konvertere til tall hvis det gir mening (for priser)
|
||||
const parsedVal = (!isNaN(Number(val)) && val !== "") ? Number(val) : val;
|
||||
newItems[index] = { ...newItems[index], [key]: parsedVal };
|
||||
onChange(newItems);
|
||||
|
|
@ -85,18 +105,18 @@ const ListObjectEditor = ({ label, value, templateKeys, onChange }: { label: str
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mb-8 bg-gray-50 p-6 rounded-[2rem] border border-gray-100">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-[#11280f]">{label}</label>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4 mb-8 bg-gray-100 p-6 md:p-8 rounded-[2rem] border border-gray-200 shadow-sm">
|
||||
<label className="text-sm font-black uppercase tracking-widest text-[#11280f]">{label}</label>
|
||||
<div className="space-y-6">
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className="flex flex-col bg-white p-4 rounded-2xl border border-gray-100 shadow-sm relative group hover:border-[#8bc34a] transition-colors">
|
||||
<button onClick={() => removeRow(idx)} className="absolute top-4 right-4 w-6 h-6 flex items-center justify-center bg-red-100 text-red-600 hover:bg-red-200 rounded-full text-xs font-bold transition-colors">✕</button>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pr-8">
|
||||
<div key={idx} className="flex flex-col bg-white p-6 rounded-2xl border-2 border-gray-300 shadow-sm relative group hover:border-[#8bc34a] transition-colors">
|
||||
<button onClick={() => removeRow(idx)} className="absolute top-4 right-4 w-8 h-8 flex items-center justify-center bg-red-100 text-red-700 hover:bg-red-200 hover:text-red-900 rounded-full text-sm font-black transition-colors border border-red-200">✕</button>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pr-10">
|
||||
{templateKeys.map(key => (
|
||||
<div key={key} className="flex flex-col gap-1">
|
||||
<label className="text-[9px] uppercase font-bold text-gray-400 tracking-wider">{key.replace(/_/g, ' ')}</label>
|
||||
<div key={key} className="flex flex-col gap-2">
|
||||
<label className="text-xs uppercase font-black text-gray-600 tracking-wider">{key.replace(/_/g, ' ')}</label>
|
||||
<input
|
||||
className="p-2 rounded-lg border border-gray-200 text-xs font-bold text-gray-700 focus:border-[#8bc34a] outline-none"
|
||||
className="p-3 rounded-lg border-2 border-gray-300 text-base font-bold text-black bg-gray-50 focus:bg-white focus:border-[#8bc34a] outline-none transition-colors"
|
||||
value={item[key] || ""}
|
||||
onChange={e => updateField(idx, key, e.target.value)}
|
||||
/>
|
||||
|
|
@ -106,18 +126,199 @@ const ListObjectEditor = ({ label, value, templateKeys, onChange }: { label: str
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={addRow} className="mt-2 text-left text-xs font-bold text-[#8bc34a] hover:text-[#11280f] transition-colors">+ Legg til nytt element</button>
|
||||
<button onClick={addRow} className="mt-2 text-left text-sm font-black text-[#8bc34a] hover:text-[#11280f] transition-colors bg-white px-6 py-3 rounded-xl border-2 border-[#8bc34a] self-start">+ Legg til nytt element</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// KOMPONENT 4: DEN NYE SCOREKORT-BYGGEREN
|
||||
const ScorecardBuilder = ({ course, onChange }: { course: any, onChange: (c: any) => void }) => {
|
||||
const ALL_KEYS = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest'];
|
||||
|
||||
const [holes, setHoles] = useState<any[]>(() => {
|
||||
const h = course.holes || [];
|
||||
if (h.length === 0) {
|
||||
return Array.from({length: 18}, (_, i) => ({ hole_number: i+1, par: '', hcp_index: '', lengths: {} }));
|
||||
}
|
||||
return h.sort((a: any, b: any) => a.hole_number - b.hole_number);
|
||||
});
|
||||
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(() => {
|
||||
const keys = new Set<string>();
|
||||
holes.forEach(h => {
|
||||
if (h.lengths) Object.keys(h.lengths).forEach(k => keys.add(k));
|
||||
});
|
||||
return ALL_KEYS.filter(k => keys.has(k));
|
||||
});
|
||||
|
||||
const [tees, setTees] = useState<any>(() => {
|
||||
const herrer = course.tee_boxes?.herrer || [];
|
||||
const damer = course.tee_boxes?.damer || [];
|
||||
const initialTees = { herrer: {} as any, damer: {} as any };
|
||||
activeKeys.forEach((key, idx) => {
|
||||
initialTees.herrer[key] = herrer[idx] || { navn_utslag: '', baneverdi: '', slopeverdi: '' };
|
||||
initialTees.damer[key] = damer[idx] || { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' };
|
||||
});
|
||||
return initialTees;
|
||||
});
|
||||
|
||||
const syncToParent = (newHoles: any[], newKeys: string[], newTees: any) => {
|
||||
const updatedTeeBoxes = {
|
||||
herrer: newKeys.map(k => newTees.herrer[k] || {}),
|
||||
damer: newKeys.map(k => newTees.damer[k] || {})
|
||||
};
|
||||
onChange({
|
||||
...course,
|
||||
holes: newHoles,
|
||||
tee_boxes: updatedTeeBoxes
|
||||
});
|
||||
};
|
||||
|
||||
const toggleKey = (key: string) => {
|
||||
const newKeys = activeKeys.includes(key)
|
||||
? activeKeys.filter(k => k !== key)
|
||||
: ALL_KEYS.filter(k => activeKeys.includes(k) || k === key);
|
||||
setActiveKeys(newKeys);
|
||||
|
||||
const newTees = { ...tees };
|
||||
if (!newTees.herrer[key]) newTees.herrer[key] = { navn_utslag: '', baneverdi: '', slopeverdi: '' };
|
||||
if (!newTees.damer[key]) newTees.damer[key] = { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' };
|
||||
setTees(newTees);
|
||||
syncToParent(holes, newKeys, newTees);
|
||||
};
|
||||
|
||||
const updateTee = (gender: 'herrer'|'damer', key: string, field: string, value: string) => {
|
||||
const newTees = { ...tees };
|
||||
newTees[gender][key] = { ...newTees[gender][key], [field]: value };
|
||||
setTees(newTees);
|
||||
syncToParent(holes, activeKeys, newTees);
|
||||
};
|
||||
|
||||
const updateHole = (index: number, field: string, value: string, lengthKey: string | null = null) => {
|
||||
const newHoles = [...holes];
|
||||
if (lengthKey) {
|
||||
newHoles[index].lengths = { ...newHoles[index].lengths, [lengthKey]: value === '' ? '' : Number(value) };
|
||||
} else {
|
||||
newHoles[index][field] = value === '' ? '' : Number(value);
|
||||
}
|
||||
setHoles(newHoles);
|
||||
syncToParent(newHoles, activeKeys, tees);
|
||||
};
|
||||
|
||||
const addHole = () => {
|
||||
const newHoles = [...holes, { hole_number: holes.length + 1, par: '', hcp_index: '', lengths: {} }];
|
||||
setHoles(newHoles);
|
||||
syncToParent(newHoles, activeKeys, tees);
|
||||
};
|
||||
|
||||
const removeLastHole = () => {
|
||||
const newHoles = holes.slice(0, -1);
|
||||
setHoles(newHoles);
|
||||
syncToParent(newHoles, activeKeys, tees);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mt-6">
|
||||
<div className="flex flex-wrap gap-4 items-center bg-gray-100 p-4 rounded-xl border-2 border-gray-200">
|
||||
<span className="text-xs font-black uppercase tracking-widest text-gray-600">Aktive Utslagskolonner:</span>
|
||||
{ALL_KEYS.map(k => (
|
||||
<label key={k} className="flex items-center gap-2 text-sm font-bold cursor-pointer text-black">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeKeys.includes(k)}
|
||||
onChange={() => toggleKey(k)}
|
||||
className="w-5 h-5 accent-[#8bc34a]"
|
||||
/>
|
||||
{k.toUpperCase()}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-2xl border-2 border-gray-300 shadow-sm bg-white pb-2">
|
||||
<table className="w-full text-center text-sm min-w-[800px] border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 text-gray-700 text-xs font-black uppercase tracking-widest border-b-2 border-gray-300">
|
||||
<th className="p-3 border-r border-gray-200">Hull</th>
|
||||
<th className="p-3 border-r border-gray-200">Par</th>
|
||||
<th className="p-3 border-r border-gray-300">HCP</th>
|
||||
{activeKeys.map(k => <th key={k} className="p-3 border-r border-gray-300 w-32">{k}</th>)}
|
||||
</tr>
|
||||
{/* Herrer */}
|
||||
<tr className="bg-blue-50 border-b border-gray-300">
|
||||
<th colSpan={3} className="p-3 text-right text-[10px] font-black text-blue-900 uppercase tracking-widest border-r border-gray-300">
|
||||
Herrer (Navn / CR / Slope)
|
||||
</th>
|
||||
{activeKeys.map(k => (
|
||||
<td key={k} className="p-2 border-r border-gray-300 align-top">
|
||||
<div className="flex flex-col gap-1">
|
||||
<input placeholder="Eks: Gul" className="w-full p-2 text-xs font-bold text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.navn_utslag || ''} onChange={e => updateTee('herrer', k, 'navn_utslag', e.target.value)} />
|
||||
<div className="flex gap-1">
|
||||
<input placeholder="CR" className="w-1/2 p-2 text-xs text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.baneverdi || ''} onChange={e => updateTee('herrer', k, 'baneverdi', e.target.value)} />
|
||||
<input placeholder="Slope" className="w-1/2 p-2 text-xs text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.slopeverdi || ''} onChange={e => updateTee('herrer', k, 'slopeverdi', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
{/* Damer */}
|
||||
<tr className="bg-red-50 border-b-4 border-gray-400">
|
||||
<th colSpan={3} className="p-3 text-right text-[10px] font-black text-red-900 uppercase tracking-widest border-r border-gray-300">
|
||||
Damer (Navn / CR / Slope)
|
||||
</th>
|
||||
{activeKeys.map(k => (
|
||||
<td key={k} className="p-2 border-r border-gray-300 align-top">
|
||||
<div className="flex flex-col gap-1">
|
||||
<input placeholder="Eks: Rød" className="w-full p-2 text-xs font-bold text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.navn_utslag_damer || ''} onChange={e => updateTee('damer', k, 'navn_utslag_damer', e.target.value)} />
|
||||
<div className="flex gap-1">
|
||||
<input placeholder="CR" className="w-1/2 p-2 text-xs text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.baneverdi_damer || ''} onChange={e => updateTee('damer', k, 'baneverdi_damer', e.target.value)} />
|
||||
<input placeholder="Slope" className="w-1/2 p-2 text-xs text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.slopeverdi_damer || ''} onChange={e => updateTee('damer', k, 'slopeverdi_damer', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{holes.map((h, idx) => (
|
||||
<tr key={idx} className="border-b border-gray-200 hover:bg-gray-50">
|
||||
<td className="p-2 font-black text-lg text-gray-800 border-r border-gray-200">{h.hole_number}</td>
|
||||
<td className="p-2 border-r border-gray-200"><input type="number" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.par || ''} onChange={e => updateHole(idx, 'par', e.target.value)} /></td>
|
||||
<td className="p-2 border-r border-gray-300"><input type="number" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.hcp_index || ''} onChange={e => updateHole(idx, 'hcp_index', e.target.value)} /></td>
|
||||
{activeKeys.map(k => (
|
||||
<td key={k} className="p-2 border-r border-gray-300 bg-gray-50/50">
|
||||
<input type="number" placeholder="Lengde" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-mono font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.lengths?.[k] || ''} onChange={e => updateHole(idx, 'lengths', e.target.value, k)} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 px-2">
|
||||
<button onClick={addHole} className="text-sm font-black text-[#8bc34a] hover:text-[#11280f] px-4 py-2 border-2 border-[#8bc34a] rounded-xl">+ Legg til hull</button>
|
||||
<button onClick={removeLastHole} className="text-sm font-black text-red-500 hover:text-red-700 px-4 py-2 border-2 border-red-500 rounded-xl">- Slett siste hull</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default function EditFacilityClient({ initialData }: { initialData: any }) {
|
||||
export default function EditFacilityClient({ initialData, allFacilities }: { initialData: any, allFacilities: any[] }) {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState(initialData);
|
||||
const [activeTab, setActiveTab] = useState('generelt');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Trekk ut unike arkitekter fra alle anlegg
|
||||
const uniqueArchitects = Array.from(new Set(allFacilities.map(f => f.architect).filter(Boolean))).sort();
|
||||
|
||||
// Sørg for at cooperating_clubs er et array
|
||||
const [coopClubs, setCoopClubs] = useState<string[]>(
|
||||
Array.isArray(initialData.cooperating_clubs) ? initialData.cooperating_clubs :
|
||||
(typeof initialData.cooperating_clubs === 'string' ? JSON.parse(initialData.cooperating_clubs) : [])
|
||||
);
|
||||
|
||||
const handleChange = (field: string, value: any) => {
|
||||
setFormData((prev: any) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
|
@ -132,7 +333,7 @@ export default function EditFacilityClient({ initialData }: { initialData: any }
|
|||
});
|
||||
|
||||
if (res.ok) {
|
||||
alert("Anlegget ble oppdatert suksessfullt!");
|
||||
alert("Lagret suksessfullt!");
|
||||
router.refresh();
|
||||
} else {
|
||||
alert("Noe gikk galt under lagring.");
|
||||
|
|
@ -148,64 +349,116 @@ export default function EditFacilityClient({ initialData }: { initialData: any }
|
|||
{ id: 'lokasjon', label: 'Lokasjon & Kontakt' },
|
||||
{ id: 'linker', label: 'Lenker & Media' },
|
||||
{ id: 'okonomi', label: 'Økonomi & Medlemskap' },
|
||||
{ id: 'avansert', label: 'Spesial & Strukturer (JSON)' }
|
||||
{ id: 'baner', label: 'Baner & Scorekort' }
|
||||
];
|
||||
|
||||
const Input = ({ field, label, type = "text" }: { field: string, label: string, type?: string }) => (
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500">{label}</label>
|
||||
{type === 'textarea' ? (
|
||||
<textarea className="p-4 rounded-2xl border-2 border-gray-100 bg-gray-50 focus:bg-white focus:border-[#8bc34a] outline-none transition-all" rows={4} value={formData[field] || ""} onChange={e => handleChange(field, e.target.value)} />
|
||||
) : (
|
||||
<input type={type} className="p-4 rounded-2xl border-2 border-gray-100 bg-gray-50 focus:bg-white focus:border-[#8bc34a] outline-none transition-all font-bold text-[#11280f]" value={formData[field] || ""} onChange={e => handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const Input = ({ field, label, type = "text" }: { field: string, label: string, type?: string }) => {
|
||||
// Håndter dato-formatet (YYYY-MM-DD) slik at HTML5 date picker forstår det
|
||||
let displayValue = formData[field] || "";
|
||||
if (type === 'date' && displayValue) {
|
||||
displayValue = displayValue.split('T')[0]; // Kutter vekk klokkeslettet
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 mb-8">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">{label}</label>
|
||||
{type === 'textarea' ? (
|
||||
<textarea
|
||||
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base shadow-sm focus:border-[#8bc34a] outline-none transition-all"
|
||||
rows={4}
|
||||
value={displayValue}
|
||||
onChange={e => handleChange(field, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none transition-all"
|
||||
value={displayValue}
|
||||
onChange={e => handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-[1200px] mx-auto p-8 relative z-40 bg-white min-h-screen">
|
||||
<div className="flex justify-between items-center mb-10 pb-6 border-b border-gray-100">
|
||||
<div className="max-w-[1400px] mx-auto p-4 md:p-8 relative z-40 bg-white min-h-screen">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-10 pb-6 border-b border-gray-200 gap-6">
|
||||
<div>
|
||||
<Link href="/admin" className="text-sm font-bold text-gray-400 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
||||
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
||||
<h1 className="text-4xl font-black text-[#11280f]">Rediger: <span className="text-[#8bc34a]">{initialData.name}</span></h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="bg-[#11280f] text-white px-8 py-4 rounded-full font-black uppercase tracking-widest hover:bg-[#8bc34a] transition-colors shadow-xl disabled:opacity-50"
|
||||
className="bg-[#11280f] text-white px-8 py-4 rounded-full font-black uppercase tracking-widest hover:bg-[#8bc34a] transition-colors shadow-xl disabled:opacity-50 w-full md:w-auto"
|
||||
>
|
||||
{saving ? "Lagrer..." : "Lagre endringer"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-10">
|
||||
<div className="w-full md:w-1/4 flex flex-col gap-2">
|
||||
{/* SIDEBAR MENY */}
|
||||
<div className="w-full md:w-1/4 flex flex-col gap-3">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`p-4 rounded-2xl text-left font-black uppercase text-xs tracking-widest transition-all ${activeTab === tab.id ? 'bg-[#8bc34a] text-white shadow-lg translate-x-2' : 'bg-gray-50 text-gray-500 hover:bg-gray-100'}`}
|
||||
className={`p-4 rounded-2xl text-left font-black uppercase text-sm tracking-widest transition-all ${activeTab === tab.id ? 'bg-[#8bc34a] text-white shadow-lg translate-x-2' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-3/4 bg-white">
|
||||
{/* SKJEMA OMRÅDE */}
|
||||
<div className="w-full md:w-3/4">
|
||||
{activeTab === 'generelt' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
||||
<div className="col-span-1 md:col-span-2"><Input field="name" label="Anleggsnavn" /></div>
|
||||
<div className="col-span-1 md:col-span-2"><Input field="description" label="Beskrivelse" type="textarea" /></div>
|
||||
|
||||
{/* NYTT: Viktig beskjed / Kursiv intro */}
|
||||
<div className="col-span-1 md:col-span-2"><Input field="footnote" label="Viktig beskjed (Kursiv intro-tekst over beskrivelsen)" type="textarea" /></div>
|
||||
|
||||
<div className="col-span-1 md:col-span-2"><Input field="description" label="Hovedbeskrivelse" type="textarea" /></div>
|
||||
|
||||
<Input field="banetype" label="Banetype (f.eks Park/Skog)" />
|
||||
<Input field="season" label="Sesong (f.eks April-Oktober)" />
|
||||
<Input field="established_year" label="Byggeår" type="number" />
|
||||
<Input field="architect" label="Arkitekt" />
|
||||
|
||||
{/* NYTT: Arkitekt med forslag */}
|
||||
<div className="flex flex-col gap-2 mb-8">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Arkitekt</label>
|
||||
<input
|
||||
list="architect-list"
|
||||
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none transition-all"
|
||||
value={formData.architect || ""}
|
||||
onChange={e => handleChange('architect', e.target.value)}
|
||||
placeholder="Velg eller skriv inn ny..."
|
||||
/>
|
||||
<datalist id="architect-list">
|
||||
<option value="Ukjent" />
|
||||
{uniqueArchitects.map((arch: any) => <option key={arch} value={arch} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<Input field="length_meters" label="Totallengde (meter)" type="number" />
|
||||
|
||||
{/* NYTT: Samarbeidende klubber */}
|
||||
<MultiSelect
|
||||
label="Samarbeidende Klubber (Gjestespill etc.)"
|
||||
options={allFacilities.filter(f => f.id !== initialData.id)}
|
||||
selected={coopClubs}
|
||||
onChange={(val) => {
|
||||
setCoopClubs(val);
|
||||
handleChange('cooperating_clubs', val);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'lokasjon' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
||||
<div className="col-span-1 md:col-span-2"><Input field="address" label="Gateadresse" /></div>
|
||||
<Input field="zipcode" label="Postnummer" />
|
||||
<Input field="city" label="Poststed / By" />
|
||||
|
|
@ -227,51 +480,133 @@ export default function EditFacilityClient({ initialData }: { initialData: any }
|
|||
<Input field="weather_url" label="Vær URL (YR)" />
|
||||
<Input field="webcam_url" label="Webkamera URL" />
|
||||
<Input field="video_url" label="Video URL (YouTube/Vimeo)" />
|
||||
|
||||
{/* NYTT: Sosiale Medier lagt inn som liste-editor her */}
|
||||
<ListObjectEditor
|
||||
label="Sosiale Medier (Legg inn f.eks facebook, instagram, linkedin)"
|
||||
value={formData.social_links}
|
||||
templateKeys={['platform', 'url']}
|
||||
onChange={(v) => handleChange('social_links', v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'okonomi' && (
|
||||
<div className="flex flex-col">
|
||||
<h3 className="font-black uppercase tracking-widest text-gray-300 mb-4 pb-2 border-b">Medlemskap</h3>
|
||||
<Input field="navn_standard_medlemskap" label="Navn på standard medlemskap" />
|
||||
<Input field="standard_medlemskap" label="Pris standard (kun tall)" type="number" />
|
||||
<Input field="standard_medlemskap_kommentarer" label="Kommentar standard" />
|
||||
<Input field="navn_rimeligste_alternativ" label="Navn på rimeligste" />
|
||||
<Input field="rimeligste_alternativ" label="Pris rimeligste (kun tall)" type="number" />
|
||||
<Input field="medlemskap_url" label="Lenke til medlemskapsside" />
|
||||
{/* NYTT FELT FOR MANUELL DATO */}
|
||||
<div className="mb-6">
|
||||
<Input field="membership_updated_at" label="Sist Oppdatert (Dato for Medlemskapspriser)" type="date" />
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-100 p-6 rounded-2xl mb-8 border border-gray-200">
|
||||
<h3 className="font-black uppercase tracking-widest text-gray-800 mb-6 pb-2 border-b-2 border-gray-200">Medlemskap</h3>
|
||||
<Input field="navn_standard_medlemskap" label="Navn på standard medlemskap" />
|
||||
<Input field="standard_medlemskap" label="Pris standard (kun tall)" type="number" />
|
||||
<Input field="standard_medlemskap_kommentarer" label="Kommentar standard" />
|
||||
<Input field="navn_rimeligste_alternativ" label="Navn på rimeligste" />
|
||||
<Input field="rimeligste_alternativ" label="Pris rimeligste (kun tall)" type="number" />
|
||||
<Input field="medlemskap_url" label="Lenke til medlemskapsside" />
|
||||
</div>
|
||||
|
||||
<h3 className="font-black uppercase tracking-widest text-gray-300 mb-4 mt-8 pb-2 border-b">Veien til Golf (VTG)</h3>
|
||||
<Input field="vtg_pris" label="Pris VTG kurs (kun tall)" type="number" />
|
||||
<Input field="vtg_lenke" label="Lenke til VTG påmelding" />
|
||||
<div className="bg-gray-100 p-6 rounded-2xl border border-gray-200">
|
||||
<h3 className="font-black uppercase tracking-widest text-gray-800 mb-6 pb-2 border-b-2 border-gray-200">Veien til Golf (VTG)</h3>
|
||||
<Input field="vtg_pris" label="Pris VTG kurs (kun tall)" type="number" />
|
||||
<Input field="vtg_lenke" label="Lenke til VTG påmelding" />
|
||||
</div>
|
||||
|
||||
{/* Tilbud (tidligere under Avansert) */}
|
||||
<div className="mt-8 border-t-2 border-gray-200 pt-8">
|
||||
<KeyValueEditor label="Fasiliteter (Proshop, Kafé etc.)" value={formData.amenities} onChange={(v) => handleChange('amenities', v)} />
|
||||
<KeyValueEditor label="Norsk Seniorgolf (NSG)" value={formData.nsg_data} onChange={(v) => handleChange('nsg_data', v)} />
|
||||
<KeyValueEditor label="Golfamore Info" value={formData.golfamore_data} onChange={(v) => handleChange('golfamore_data', v)} />
|
||||
|
||||
<ListObjectEditor
|
||||
label="Greenfee Priser"
|
||||
value={formData.greenfee}
|
||||
templateKeys={['banenavn', 'priskategori', 'pris_voksne', 'pris_junior']}
|
||||
onChange={(v) => handleChange('greenfee', v)}
|
||||
/>
|
||||
|
||||
<ListObjectEditor
|
||||
label="Golfpakker"
|
||||
value={formData.golfpakker}
|
||||
templateKeys={['navn', 'pris', 'beskrivelse']}
|
||||
onChange={(v) => handleChange('golfpakker', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'avansert' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-[#f1f7ed] p-5 rounded-2xl border border-[#7ca982]/20 mb-4">
|
||||
<h3 className="font-black text-[#11280f] text-sm uppercase tracking-widest mb-1">Strukturerte Data</h3>
|
||||
<p className="text-xs text-gray-600">Her kan du redigere komplekse lister og felter uten å bekymre deg for kodefeil. Skjemaet lagrer det trygt i databasen for deg.</p>
|
||||
{/* BANER & SCOREKORT MED NY GRAFISK BYGGER */}
|
||||
{activeTab === 'baner' && (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="bg-[#f1f7ed] p-6 rounded-2xl border-2 border-[#7ca982] mb-4">
|
||||
<h3 className="font-black text-[#11280f] text-lg uppercase tracking-widest mb-2">Baner og Scorekort</h3>
|
||||
<p className="text-sm text-gray-800 font-medium">Bruk det interaktive skjemaet under for å redigere lengder, par og utslag.</p>
|
||||
</div>
|
||||
|
||||
{/* Enkle Key-Value editorer for objekter */}
|
||||
<KeyValueEditor label="Fasiliteter (Proshop, Kafé etc.)" value={formData.amenities} onChange={(v) => handleChange('amenities', v)} />
|
||||
<KeyValueEditor label="Norsk Seniorgolf Info" value={formData.nsg_data} onChange={(v) => handleChange('nsg_data', v)} />
|
||||
<KeyValueEditor label="Golfamore Info" value={formData.golfamore_data} onChange={(v) => handleChange('golfamore_data', v)} />
|
||||
|
||||
{/* Komplekse Liste-editorer for Arrays */}
|
||||
<ListObjectEditor
|
||||
label="Greenfee Priser"
|
||||
value={formData.greenfee}
|
||||
templateKeys={['banenavn', 'priskategori', 'pris_voksne', 'pris_junior']}
|
||||
onChange={(v) => handleChange('greenfee', v)}
|
||||
/>
|
||||
|
||||
<ListObjectEditor
|
||||
label="Golfpakker"
|
||||
value={formData.golfpakker}
|
||||
templateKeys={['navn', 'pris', 'beskrivelse']}
|
||||
onChange={(v) => handleChange('golfpakker', v)}
|
||||
/>
|
||||
{formData.courses?.map((course: any, cIdx: number) => (
|
||||
<div key={course.id || cIdx} className="bg-gray-100 p-8 rounded-[2rem] border-2 border-gray-200 shadow-sm mb-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4 border-b-2 border-gray-200 pb-4">
|
||||
<h4 className="text-2xl font-black text-black">{course.name}</h4>
|
||||
<span className={`px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest ${course.is_main_course ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-300 text-gray-700'}`}>
|
||||
{course.is_main_course ? 'Hovedbane' : 'Sekundærbane'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Banenavn</label>
|
||||
<input className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.name || ""} onChange={e => {
|
||||
const newCourses = [...formData.courses];
|
||||
newCourses[cIdx] = {...course, name: e.target.value};
|
||||
handleChange('courses', newCourses);
|
||||
}} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Status</label>
|
||||
<select className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.status || "ukjent"} onChange={e => {
|
||||
const newCourses = [...formData.courses];
|
||||
newCourses[cIdx] = {...course, status: e.target.value};
|
||||
handleChange('courses', newCourses);
|
||||
}}>
|
||||
<option value="aapen">🟢 Åpen</option>
|
||||
<option value="aapen_med_vintergreener">🟡 Vintergreener</option>
|
||||
<option value="aapner_snart">🟡 Åpner Snart</option>
|
||||
<option value="stengt">🔴 Stengt</option>
|
||||
<option value="nedlagt">⚫ Nedlagt</option>
|
||||
<option value="ukjent">⚪ Ukjent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Total Par (Bane)</label>
|
||||
<input type="number" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.par || ""} onChange={e => {
|
||||
const newCourses = [...formData.courses];
|
||||
newCourses[cIdx] = {...course, par: Number(e.target.value)};
|
||||
handleChange('courses', newCourses);
|
||||
}} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Utløpsdato Slope</label>
|
||||
<input type="date" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.slope_valid_until ? course.slope_valid_until.split('T')[0] : ""} onChange={e => {
|
||||
const newCourses = [...formData.courses];
|
||||
newCourses[cIdx] = {...course, slope_valid_until: e.target.value};
|
||||
handleChange('courses', newCourses);
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DET NYE SCOREKORTET INKLUDERES HER */}
|
||||
<ScorecardBuilder
|
||||
course={course}
|
||||
onChange={(updatedCourse) => {
|
||||
const newCourses = [...formData.courses];
|
||||
newCourses[cIdx] = updatedCourse;
|
||||
handleChange('courses', newCourses);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,12 +3,18 @@ import EditFacilityClient from "./EditFacilityClient";
|
|||
|
||||
export default async function EditFacilityPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
|
||||
// Henter anlegget vi skal redigere
|
||||
const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: 'no-store' });
|
||||
const facility = await res.json();
|
||||
|
||||
// Henter ALLE anlegg slik at vi kan bygge lister for samarbeid og arkitekter
|
||||
const allRes = await fetch(`${API_URL}/facilities`, { cache: 'no-store' });
|
||||
const allFacilities = await allRes.json();
|
||||
|
||||
if (!facility || facility.error) {
|
||||
return <div className="p-20 text-center font-bold text-2xl">Fant ikke anlegget...</div>;
|
||||
}
|
||||
|
||||
return <EditFacilityClient initialData={facility} />;
|
||||
return <EditFacilityClient initialData={facility} allFacilities={allFacilities} />;
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
"use client";
|
||||
/**
|
||||
* TEE OFF DETAIL VIEW - COMPLETE v3.21
|
||||
* TEE OFF DETAIL VIEW - COMPLETE v3.22
|
||||
* ---------------------------------------------------------------------------
|
||||
* FIX: Gjenopprettet "Turneringer" i den flytende knapperaden over bildet.
|
||||
* FIX: Byttet plass 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: Webkamera under Andre ressurser, NSG, Golfamore og Bilutleie under Andre Tilbud.
|
||||
* NEW: Sosiale Medier, Footnote og Samarbeidende klubber integrert.
|
||||
* REGEL: Beholder monokrome ikoner, 22/78 layout og robust JSON-parsing.
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
|
@ -34,7 +34,7 @@ const renderValue = (val: string) => {
|
|||
|
||||
const Icon = ({ children, className = "w-5 h-5" }: { children: React.ReactNode, className?: string }) => (
|
||||
<svg
|
||||
className={`${className} flex-shrink-0 text-[#11280f]`}
|
||||
className={`${className} flex-shrink-0 text-current`}
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ width: '20px', height: '20px' }}
|
||||
>
|
||||
|
|
@ -56,6 +56,17 @@ const ICONS = {
|
|||
weather: <><path d="M12 2v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="M20 12h2"/><path d="m19.07 4.93-1.41 1.41"/><path d="M15.947 12.65a4 4 0 0 0-5.925-4.128"/><path d="M13 22H7a5 5 0 1 1 4.9-6H13a3 3 0 0 1 0 6Z"/></>
|
||||
};
|
||||
|
||||
const SOCIAL_ICONS: Record<string, React.ReactNode> = {
|
||||
facebook: <path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />,
|
||||
instagram: <><rect x="2" y="2" width="20" height="20" rx="5" ry="5" /><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" /><line x1="17.5" y1="6.5" x2="17.51" y2="6.5" /></>,
|
||||
twitter: <path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z" />,
|
||||
x: <path d="M4 4l16 16M4 20L20 4" />,
|
||||
linkedin: <><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" /><rect x="2" y="9" width="4" height="12" /><circle cx="4" cy="4" r="2" /></>,
|
||||
youtube: <><path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33 2.78 2.78 0 0 0 1.94 2c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.33 29 29 0 0 0-.46-5.33z" /><polygon points="9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02" /></>,
|
||||
tiktok: <path d="M9 12a4 4 0 1 0 4 4V2a5 5 0 0 0 5 5h-2a3 3 0 0 1-3-3V16a2 2 0 1 1-2-2v-2z" />,
|
||||
snapchat: <path d="M12 2C8.5 2 6 5 6 8.5c0 1.5.5 3 1.5 4-1 .5-2.5 1-3.5 1-.5 0-1 .5-1 1s.5 1 1.5 1h15c1 0 1.5-.5 1.5-1s-.5-1-1-1c-1 0-2.5-.5-3.5-1 1-1 1.5-2.5 1.5-4C18 5 15.5 2 12 2zm0 15c-3 0-5-1-5-1s.5 1.5 1.5 2h7C16.5 17.5 17 16 17 16s-2 1-5 1z" />
|
||||
};
|
||||
|
||||
export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
|
|
@ -74,11 +85,14 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
const greenfeeRaw = parseJson(facility.greenfee, []);
|
||||
const shotzoom = parseJson(facility.shotzoom, []);
|
||||
|
||||
// Parse Golfamore og NSG
|
||||
const golfamoreData = parseJson(facility.golfamore_data, {});
|
||||
const nsgData = parseJson(facility.nsg_data, {});
|
||||
const socialLinksRaw = parseJson(facility.social_links, []);
|
||||
const socialLinks = Array.isArray(socialLinksRaw) ? socialLinksRaw : [];
|
||||
|
||||
const coopClubsRaw = parseJson(facility.cooperating_clubs, []);
|
||||
const cooperatingClubs = Array.isArray(coopClubsRaw) ? coopClubsRaw : [];
|
||||
|
||||
// Sjekker om de er aktive
|
||||
const hasGolfamore = facility.golfamore === true;
|
||||
const hasNSG = facility.nsg_url || (nsgData && Object.keys(nsgData).length > 0);
|
||||
|
||||
|
|
@ -89,8 +103,8 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
return acc;
|
||||
}, {});
|
||||
|
||||
const sidebarLinkClass = "flex items-center gap-4 hover:text-[#ff5722] transition-colors group";
|
||||
const resourceBtnClass = "flex justify-between items-center p-5 bg-gray-50 rounded-2xl text-[11px] font-black uppercase hover:bg-[#ff5722] hover:text-white transition-all group";
|
||||
const sidebarLinkClass = "flex items-center gap-4 text-[#11280f] hover:text-[#ff5722] transition-colors group";
|
||||
const resourceBtnClass = "flex justify-between items-center p-5 bg-gray-50 rounded-2xl text-[11px] font-black uppercase text-[#11280f] hover:bg-[#ff5722] hover:text-white transition-all group";
|
||||
|
||||
useEffect(() => {
|
||||
if (gallery.length <= 1) return;
|
||||
|
|
@ -138,8 +152,8 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* FLYTENDE HURTIGKNAPPER (Inkludert Turneringer) */}
|
||||
<div className="absolute bottom-8 right-8 z-40 flex gap-2.5 bg-black/30 backdrop-blur-md p-2 rounded-2xl border border-white/10 shadow-2xl">
|
||||
{/* FLYTENDE HURTIGKNAPPER */}
|
||||
<div className="absolute bottom-8 right-8 z-40 flex gap-2.5 bg-black/30 backdrop-blur-md p-2 rounded-2xl border border-white/10 shadow-2xl text-[#11280f]">
|
||||
{facility.website_url && <a href={facility.website_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.web} /></a>}
|
||||
{facility.golfbox_booking_url && <a href={facility.golfbox_booking_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.booking} /></a>}
|
||||
{facility.golfbox_tournament_url && <a href={facility.golfbox_tournament_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.trophy} /></a>}
|
||||
|
|
@ -176,7 +190,11 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
<section id="intro" className="flex flex-col lg:flex-row gap-0 md:gap-8 items-stretch">
|
||||
{/* HOVEDINNHOLD (78%) */}
|
||||
<div className="lg:w-[78%] bg-white p-10 md:p-16 md:rounded-[3rem] shadow-sm border-b md:border-none">
|
||||
{facility.footnote && <div className="mb-8 pb-8 border-b border-gray-50 italic text-gray-400 text-lg font-serif">{facility.footnote}</div>}
|
||||
{facility.footnote && (
|
||||
<div className="mb-8 pb-8 border-b border-gray-50 italic text-[#ff5722] text-lg font-serif">
|
||||
{facility.footnote}
|
||||
</div>
|
||||
)}
|
||||
<div className="leading-relaxed text-lg md:text-xl text-gray-600" dangerouslySetInnerHTML={{ __html: facility.description || 'Ingen beskrivelse tilgjengelig.' }} />
|
||||
</div>
|
||||
|
||||
|
|
@ -197,7 +215,25 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto pt-10 border-t border-gray-50">
|
||||
|
||||
{/* SOSIALE MEDIER IKONER */}
|
||||
{socialLinks.length > 0 && (
|
||||
<div className="pt-6 border-t border-gray-50 mt-6 flex flex-wrap gap-3">
|
||||
{socialLinks.map((social: any, idx: number) => {
|
||||
const platform = (social.platform || '').toLowerCase().trim();
|
||||
// Finn riktig ikon, fall tilbake til en generell link-pil
|
||||
const iconData = SOCIAL_ICONS[platform] || <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3" />;
|
||||
|
||||
return (
|
||||
<a key={idx} href={social.url} target="_blank" rel="noreferrer" title={social.platform} className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center text-[#11280f] hover:bg-[#ff5722] hover:text-white transition-all shadow-sm">
|
||||
<Icon children={iconData} className="w-4 h-4 text-current" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-10 pt-6 border-t border-gray-50">
|
||||
<Link href={`/`} className="text-[10px] font-black uppercase tracking-widest text-[#7ca982] hover:text-[#11280f] transition-all flex items-center gap-1">
|
||||
Se alle baner i {facility.county} →
|
||||
</Link>
|
||||
|
|
@ -230,7 +266,6 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
<span className="flex items-center gap-3"><Icon children={ICONS.camera} className="group-hover:text-white" /> Flyfoto</span><span>→</span>
|
||||
</a>
|
||||
)}
|
||||
{/* Ny rad for Webkamera */}
|
||||
{facility.webcam_url && (
|
||||
<a href={facility.webcam_url} target="_blank" className={resourceBtnClass}>
|
||||
<span className="flex items-center gap-3"><Icon children={ICONS.webcam} className="group-hover:text-white" /> Webkamera</span><span>→</span>
|
||||
|
|
@ -243,9 +278,9 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm text-sm font-bold text-gray-700">
|
||||
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm text-sm font-bold text-gray-700 flex flex-col">
|
||||
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter text-[#11280f]">Banen</h3>
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-5 flex-grow">
|
||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Hull:</span><span>{amenities.antall_hull || '--'}</span></div>
|
||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Lengde:</span><span>{facility.length_meters ? `${facility.length_meters}m` : '--'}</span></div>
|
||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Sesong:</span><span>{facility.season || '--'}</span></div>
|
||||
|
|
@ -263,17 +298,16 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Kølleutleie:</span><span>{amenities.kolleutleie || 'Ja'}</span></div>
|
||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Bilutleie:</span><span>{amenities.bilutleie || 'Nei'}</span></div>
|
||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Simulator:</span><span className="text-right ml-4">{renderValue(amenities.simulator)}</span></div>
|
||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Head Pro:</span><span className="text-right ml-4">{renderValue(amenities.pro)}</span></div>
|
||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Kafé:</span><span className="text-right ml-4">{renderValue(amenities.kafe)}</span></div>
|
||||
|
||||
{/* NYE RADER: Golfamore og NSG */}
|
||||
{/* Golfamore og NSG */}
|
||||
<div className="flex justify-between border-b border-gray-50 pb-2">
|
||||
<span className="text-gray-400">Golfamore:</span>
|
||||
<span className="text-right ml-4">
|
||||
{hasGolfamore ? <span className="text-[#ff5722] font-black">{golfamoreData.gyldighet || "Ja"}</span> : "Nei"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex justify-between border-b border-gray-50 pb-2">
|
||||
<span className="text-gray-400">Seniorgolf (NSG):</span>
|
||||
<span className="text-right ml-4">
|
||||
{hasNSG && facility.nsg_url
|
||||
|
|
@ -282,6 +316,20 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* SAMARBEIDENDE KLUBBER */}
|
||||
{cooperatingClubs.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<span className="text-gray-400 block mb-2">Samarbeider med:</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{cooperatingClubs.map((slug: string) => (
|
||||
<Link key={slug} href={`/golfbaner/${slug}`} className="px-3 py-1 bg-gray-100 rounded-lg text-[10px] uppercase font-black tracking-widest hover:bg-[#8bc34a] hover:text-white transition-colors">
|
||||
{slug.replace('-golfklubb', '').replace(/-/g, ' ')}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
64
kode_eksport_1/backend_create_admin_py.txt
Normal file
64
kode_eksport_1/backend_create_admin_py.txt
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""
|
||||
TEE OFF ADMIN GENERATOR v1.9 (DEBUG & BULLETPROOF)
|
||||
---------------------------------------------------------------------------
|
||||
FUNKSJON: Genererer SQL-kommando for administrator.
|
||||
STATUS: Beholder TRUNCATE for feilsøking, men sikrer SQL-innsendingen.
|
||||
---------------------------------------------------------------------------
|
||||
"""
|
||||
import pyotp
|
||||
from passlib.hash import pbkdf2_sha256
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
def generate_admin():
|
||||
print("\n" + "="*50)
|
||||
print(" TEE OFF ADMIN GENERATOR v1.9 (DEBUG MODE)")
|
||||
print("="*50)
|
||||
|
||||
username = input("Brukernavn (f.eks Envide Webutvikling): ").strip()
|
||||
email = input("E-post: ").strip()
|
||||
|
||||
# Sikre mot SQL-feil hvis navnet/eposten inneholder apostrof
|
||||
safe_username = username.replace("'", "''")
|
||||
safe_email = email.replace("'", "''")
|
||||
|
||||
# Passord-verifisering
|
||||
while True:
|
||||
password = getpass.getpass("Skriv inn passord: ")
|
||||
password_confirm = getpass.getpass("Gjenta passord: ")
|
||||
|
||||
if password == password_confirm:
|
||||
if len(password) < 8:
|
||||
print("⚠️ Advarsel: Passordet bør være minst 8 tegn.")
|
||||
print(f"\n[DEBUG] Passord akseptert. Lengde: {len(password)} tegn.")
|
||||
break
|
||||
else:
|
||||
print("❌ Passordene er ikke like. Prøv igjen.\n")
|
||||
|
||||
otp_secret = pyotp.random_base32()
|
||||
|
||||
print("⏳ Genererer PBKDF2-hash...")
|
||||
password_hash = pbkdf2_sha256.hash(password)
|
||||
print(f"[DEBUG] Hash generert. Lengde: {len(password_hash)} tegn.")
|
||||
|
||||
print("\n✅ GENERERING VELLYKKET!")
|
||||
print("-" * 50)
|
||||
print("SLIK LEGGER DU INN BRUKEREN TRYGT:")
|
||||
print("-" * 50)
|
||||
print("1. Gå inn i databasen:")
|
||||
print(" docker exec -it teeoff_db psql -U teeoff_admin -d teeoff")
|
||||
print("\n2. Lim inn disse to linjene nøyaktig slik de står:")
|
||||
print("TRUNCATE admins;")
|
||||
print(f"INSERT INTO admins (username, email, password_hash, otp_secret) VALUES ('{safe_username}', '{safe_email}', '{password_hash}', '{otp_secret}');")
|
||||
print("\n3. Skriv 'exit' for å gå ut.")
|
||||
print("-" * 50)
|
||||
print("4. KONFIGURER 2FA I GOOGLE AUTHENTICATOR:")
|
||||
print(f"Bruk denne nøkkelen: {otp_secret}")
|
||||
print("-" * 50 + "\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
generate_admin()
|
||||
except KeyboardInterrupt:
|
||||
print("\nAvbrutt.")
|
||||
sys.exit(0)
|
||||
111
kode_eksport_1/backend_import_gallery_py.txt
Normal file
111
kode_eksport_1/backend_import_gallery_py.txt
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import asyncio
|
||||
import asyncpg
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
||||
|
||||
async def fetch_json(url):
|
||||
"""Hjelpefunksjon for å hente JSON fra en URL"""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'TeeOff-Migrator/2.0'})
|
||||
with urllib.request.urlopen(req) as response:
|
||||
return json.loads(response.read().decode())
|
||||
except Exception as e:
|
||||
# print(f"⚠️ Kunne ikke hente {url}: {e}")
|
||||
return None
|
||||
|
||||
async def fetch_media_urls_by_ids(media_ids):
|
||||
"""Henter URLer for en liste med media-IDer (ACF Slides)"""
|
||||
if not media_ids or not isinstance(media_ids, list) or len(media_ids) == 0:
|
||||
return []
|
||||
|
||||
valid_ids = [str(mid) for mid in media_ids if isinstance(mid, (int, str)) and str(mid).isdigit()]
|
||||
if not valid_ids: return []
|
||||
|
||||
ids_str = ",".join(valid_ids)
|
||||
url = f"https://teeoff.no/wp-json/wp/v2/media?include={ids_str}"
|
||||
data = await fetch_json(url)
|
||||
|
||||
urls = []
|
||||
if data:
|
||||
for m in data:
|
||||
if 'source_url' in m:
|
||||
urls.append(m['source_url'])
|
||||
return urls
|
||||
|
||||
async def run_robust_import():
|
||||
print("🕵️♂️ Starter den store bildejakten (sjekker både Utvalgt bilde og Slides)...")
|
||||
conn = await asyncpg.connect(DB_URL)
|
||||
|
||||
# VIKTIG: Vi tømmer tabellen for å starte med blanke ark og unngå duplikater
|
||||
await conn.execute("TRUNCATE facility_images CASCADE;")
|
||||
print("🗑️ Tømte gammel bilde-tabell. Starter import...")
|
||||
|
||||
# Hent alle anleggene fra vår egen database
|
||||
facilities = await conn.fetch("SELECT id, slug, name FROM facilities ORDER BY name")
|
||||
|
||||
total_images_saved = 0
|
||||
|
||||
for i, fac in enumerate(facilities):
|
||||
fac_id = fac['id']
|
||||
slug = fac['slug']
|
||||
name = fac['name']
|
||||
print(f"[{i+1}/{len(facilities)}] Sjekker: {name} ({slug})...")
|
||||
|
||||
# Hent data fra WP med ?_embed for å få tak i Utvalgt bilde lett
|
||||
wp_url = f"https://teeoff.no/wp-json/wp/v2/golfbaner?slug={slug}&_embed"
|
||||
wp_data_list = await fetch_json(wp_url)
|
||||
|
||||
if not wp_data_list:
|
||||
print(" ❌ Fant ikke anlegget i WordPress API.")
|
||||
continue
|
||||
|
||||
post = wp_data_list[0]
|
||||
final_image_urls = []
|
||||
|
||||
# 1. SJEKK: "Utvalgt bilde" (Standard WordPress)
|
||||
try:
|
||||
embedded = post.get('_embedded', {})
|
||||
if 'wp:featuredmedia' in embedded and len(embedded['wp:featuredmedia']) > 0:
|
||||
feat_media = embedded['wp:featuredmedia'][0]
|
||||
feat_url = feat_media.get('source_url')
|
||||
if feat_url:
|
||||
final_image_urls.append(feat_url)
|
||||
# print(f" -> Fant utvalgt bilde.")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Feil ved sjekk av utvalgt bilde: {e}")
|
||||
|
||||
# 2. SJEKK: ACF Slides (Bildekarusell)
|
||||
try:
|
||||
acf = post.get('acf') or {}
|
||||
slides_ids = acf.get('slides')
|
||||
slide_urls = await fetch_media_urls_by_ids(slides_ids)
|
||||
if slide_urls:
|
||||
final_image_urls.extend(slide_urls)
|
||||
# print(f" -> Fant {len(slide_urls)} bilder i slider.")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Feil ved sjekk av slides: {e}")
|
||||
|
||||
# Fjern duplikater (hvis samme bilde er brukt begge steder) og bevar rekkefølgen
|
||||
unique_urls = list(dict.fromkeys(final_image_urls))
|
||||
|
||||
# LAGRE I DATABASEN
|
||||
if unique_urls:
|
||||
sort_order = 0
|
||||
for url in unique_urls:
|
||||
await conn.execute(
|
||||
"INSERT INTO facility_images (facility_id, image_url, sort_order) VALUES ($1, $2, $3)",
|
||||
fac_id, url, sort_order
|
||||
)
|
||||
sort_order += 1
|
||||
print(f" ✅ Lagret {len(unique_urls)} unike bilder.")
|
||||
total_images_saved += len(unique_urls)
|
||||
else:
|
||||
print(" ⚠️ Fant INGEN bilder for dette anlegget.")
|
||||
|
||||
print(f"\n🎉 FERDIG! Totalt {total_images_saved} bilder er nå trygt lagret i galleriet.")
|
||||
await conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_robust_import())
|
||||
150
kode_eksport_1/backend_import_nye_felter_py.txt
Normal file
150
kode_eksport_1/backend_import_nye_felter_py.txt
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import asyncio
|
||||
import asyncpg
|
||||
import requests
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Laster miljøvariabler
|
||||
load_dotenv()
|
||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
||||
|
||||
# Grunn-URL uten page-parameter
|
||||
WP_API_BASE_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100"
|
||||
|
||||
def extract_price(text):
|
||||
"""Finner første hele tall i en tekst og returnerer det som integer."""
|
||||
if not text:
|
||||
return None
|
||||
clean_text = str(text).replace(" ", "").replace(".", "")
|
||||
match = re.search(r'\d+', clean_text)
|
||||
if match:
|
||||
return int(match.group())
|
||||
return None
|
||||
|
||||
def parse_date(date_string):
|
||||
"""Forsøker å konvertere ulike tekstformater for dato til et ekte Date-objekt."""
|
||||
if not date_string:
|
||||
return None
|
||||
ds = str(date_string).strip().lower()
|
||||
|
||||
if ds in ["ukjent", "ikke oppgitt", "har ikke", ""]:
|
||||
return None
|
||||
|
||||
formats = ['%Y-%m-%d', '%d.%m.%Y', '%d/%m/%Y', '%Y%m%d', '%d.%m.%y']
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(ds, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
def clean_jsonb(value):
|
||||
"""Sørger for at vi ikke fyller databasen med 'Ikke oppgitt', men bruker tomme lister."""
|
||||
if not value or str(value).lower() in ["ikke oppgitt", "har ikke / ikke oppgitt"]:
|
||||
return []
|
||||
|
||||
if isinstance(value, str):
|
||||
return [{"beskrivelse": value}]
|
||||
|
||||
if isinstance(value, list):
|
||||
cleaned = [v for v in value if v and "ikke oppgitt" not in str(v).lower()]
|
||||
return cleaned
|
||||
|
||||
return value
|
||||
|
||||
async def run_import():
|
||||
print("📡 Henter anleggsdata fra WordPress (inkluderer paginering)...")
|
||||
|
||||
all_data = []
|
||||
page = 1
|
||||
|
||||
# --- LØKKE SOM HENTER ALLE SIDER FRA WORDPRESS ---
|
||||
while True:
|
||||
url = f"{WP_API_BASE_URL}&page={page}"
|
||||
print(f" -> Henter side {page}...")
|
||||
response = requests.get(url)
|
||||
|
||||
# Hvis vi får 400 Bad Request, betyr det at vi har nådd forbi siste side
|
||||
if response.status_code != 200:
|
||||
break
|
||||
|
||||
data = response.json()
|
||||
if not data:
|
||||
break
|
||||
|
||||
all_data.extend(data)
|
||||
page += 1
|
||||
|
||||
print(f"✅ Fant totalt {len(all_data)} anlegg. Starter oppdatering av database...")
|
||||
|
||||
conn = await asyncpg.connect(DB_URL)
|
||||
success_count = 0
|
||||
|
||||
for item in all_data:
|
||||
slug = item.get('slug')
|
||||
acf = item.get('acf', {})
|
||||
|
||||
# Ekstraher og vask verdiene
|
||||
golfpakker = clean_jsonb(acf.get('golfpakke'))
|
||||
rabattert_greenfee = clean_jsonb(acf.get('rabattert_greenfee'))
|
||||
|
||||
vtg_presentasjon = acf.get('vtg_presentasjon') or None
|
||||
vtg_lenke = acf.get('lenke_til_kurssider') or None
|
||||
vtg_pris = extract_price(acf.get('vtg_pris'))
|
||||
vtg_kursdatoer = clean_jsonb(acf.get('kursdatoer'))
|
||||
|
||||
slope_hovedbane = parse_date(acf.get('gyldig_til_og_med'))
|
||||
slope_bane_to = parse_date(acf.get('gyldig_til_og_med_bane_to'))
|
||||
|
||||
try:
|
||||
# 1. Oppdater fasilitets-tabellen
|
||||
await conn.execute("""
|
||||
UPDATE facilities
|
||||
SET
|
||||
golfpakker = $1::jsonb,
|
||||
rabattert_greenfee = $2::jsonb,
|
||||
vtg_presentasjon = $3,
|
||||
vtg_lenke = $4,
|
||||
vtg_pris = $5,
|
||||
vtg_kursdatoer = $6::jsonb
|
||||
WHERE slug = $7
|
||||
""",
|
||||
json.dumps(golfpakker),
|
||||
json.dumps(rabattert_greenfee),
|
||||
vtg_presentasjon,
|
||||
vtg_lenke,
|
||||
vtg_pris,
|
||||
json.dumps(vtg_kursdatoer),
|
||||
slug)
|
||||
|
||||
# 2. Oppdater utløpsdato på hovedbanen
|
||||
if slope_hovedbane:
|
||||
await conn.execute("""
|
||||
UPDATE courses
|
||||
SET slope_valid_until = $1
|
||||
WHERE facility_id = (SELECT id FROM facilities WHERE slug = $2)
|
||||
AND is_main_course = true
|
||||
""", slope_hovedbane, slug)
|
||||
|
||||
# 3. Oppdater utløpsdato på bane 2
|
||||
if slope_bane_to:
|
||||
await conn.execute("""
|
||||
UPDATE courses
|
||||
SET slope_valid_until = $1
|
||||
WHERE facility_id = (SELECT id FROM facilities WHERE slug = $2)
|
||||
AND is_main_course = false
|
||||
""", slope_bane_to, slug)
|
||||
|
||||
success_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Feil ved oppdatering av {slug}: {e}")
|
||||
|
||||
await conn.close()
|
||||
print(f"\n🎉 Kjøring fullført! Målrettet import for {success_count} anlegg er lagret.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_import())
|
||||
157
kode_eksport_1/backend_import_wp_py.txt
Normal file
157
kode_eksport_1/backend_import_wp_py.txt
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import asyncio, asyncpg, urllib.request, json, re, os, requests
|
||||
|
||||
# --- KONFIGURASJON ---
|
||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
||||
WP_API_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100&_embed"
|
||||
MEDIA_ENDPOINT = "https://teeoff.no/wp-json/wp/v2/media"
|
||||
MEDIA_DIR = "./public/media"
|
||||
|
||||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||
media_cache = {}
|
||||
|
||||
def get_url_from_id(media_id):
|
||||
if not media_id or not isinstance(media_id, int): return None
|
||||
if media_id in media_cache: return media_cache[media_id]
|
||||
try:
|
||||
resp = requests.get(f"{MEDIA_ENDPOINT}/{media_id}", timeout=10)
|
||||
if resp.status_code == 200:
|
||||
url = resp.json().get('source_url')
|
||||
media_cache[media_id] = url
|
||||
return url
|
||||
except: return None
|
||||
|
||||
def download_media(url, slug, prefix):
|
||||
if not isinstance(url, str) or not url: return None
|
||||
clean_url = url.replace("https:///", "https://").replace("http:///", "http://")
|
||||
if "teeoff.no" not in clean_url: return clean_url
|
||||
try:
|
||||
ext = clean_url.split('.')[-1].split('?')[0].lower()
|
||||
if len(ext) > 4 or len(ext) < 3: ext = "jpg"
|
||||
filename = f"{prefix}_{slug}.{ext}"
|
||||
filepath = os.path.join(MEDIA_DIR, filename)
|
||||
if os.path.exists(filepath): return f"/media/{filename}"
|
||||
response = requests.get(clean_url, timeout=15)
|
||||
if response.status_code == 200:
|
||||
with open(filepath, 'wb') as f: f.write(response.content)
|
||||
return f"/media/{filename}"
|
||||
except: pass
|
||||
return None
|
||||
|
||||
def decode_html(text):
|
||||
if not text: return ""
|
||||
return str(text).replace('&', '&').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())
|
||||
478
kode_eksport_1/backend_main_py.txt
Normal file
478
kode_eksport_1/backend_main_py.txt
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
"""
|
||||
TEE OFF BACKEND API v3.7.0 - KOBLET PÅ FULL ADMIN REDIGERING
|
||||
---------------------------------------------------------------------------
|
||||
REGEL 1: Bruk str (ikke string) for type-hinting.
|
||||
REGEL 2: Inkluder alle subqueries for banestatus og hull-data.
|
||||
REGEL 3: Robust JSON-parsing (format_row) for å hindre Frontend-krasj.
|
||||
REGEL 4: JWT-sesjoner lagres i HTTP-only cookies.
|
||||
LOV: Aldri trunker eller slett logikk for "effektivitet".
|
||||
---------------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Response, Cookie, Depends, Request, BackgroundTasks
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import asyncpg
|
||||
import json
|
||||
import pyotp
|
||||
import os
|
||||
from datetime import datetime, date, timedelta
|
||||
from jose import jwt, JWTError
|
||||
from passlib.context import CryptContext
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# NYE IMPORTER FOR ADMIN PANELET OG BAKGRUNNSJOBBER
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Any
|
||||
import subprocess
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# --- KONFIGURASJON ---
|
||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
||||
SECRET_KEY = os.getenv("JWT_SECRET", "super_secret_change_this_in_production")
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
|
||||
# --- PYDANTIC MODELLER ---
|
||||
class CourseStatusUpdate(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
|
||||
class ScrapeSettingsUpdate(BaseModel):
|
||||
scrape_method: Optional[str] = None
|
||||
scrape_status_url: Optional[str] = None
|
||||
scrape_status_selector: Optional[str] = None
|
||||
ai_instruction: Optional[str] = None
|
||||
courses: Optional[List[CourseStatusUpdate]] = []
|
||||
|
||||
# NY MODELL FOR Å TA IMOT IDER FOR SCRAPING
|
||||
class ScrapeRunRequest(BaseModel):
|
||||
facility_ids: List[int]
|
||||
|
||||
class MembershipDraftApproval(BaseModel):
|
||||
facility_id: int
|
||||
navn_standard_medlemskap: Optional[str] = None
|
||||
standard_medlemskap: Optional[int] = None
|
||||
standard_medlemskap_kommentarer: Optional[str] = None
|
||||
navn_rimeligste_alternativ: Optional[str] = None
|
||||
rimeligste_alternativ: Optional[int] = None
|
||||
|
||||
class BulkApprovalRequest(BaseModel):
|
||||
approvals: List[MembershipDraftApproval]
|
||||
|
||||
# --- FUNKSJONER ---
|
||||
def format_row(row):
|
||||
"""
|
||||
Vasker data fra databasen:
|
||||
1. Konverterer datoer til ISO-format.
|
||||
2. Tvinger tekst-JSON (stringified JSON) over til ekte Python objekter/lister.
|
||||
3. Sikrer at lister og objekter aldri er None for å hindre Frontend-krasj.
|
||||
"""
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
d = dict(row)
|
||||
|
||||
for key in ['status_updated_at', 'created_at', 'slope_valid_until', 'membership_updated_at']:
|
||||
if isinstance(d.get(key), (date, datetime)):
|
||||
d[key] = d[key].isoformat()
|
||||
|
||||
json_list_fields = [
|
||||
'course_statuses', 'courses', 'gallery', 'greenfee',
|
||||
'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs'
|
||||
]
|
||||
json_dict_fields = [
|
||||
'amenities', 'vtg', 'nsg_data', 'golfamore_data', 'membership_draft'
|
||||
]
|
||||
|
||||
for field in json_list_fields:
|
||||
if field in d:
|
||||
val = d[field]
|
||||
if val is None:
|
||||
d[field] = []
|
||||
elif isinstance(val, str):
|
||||
try:
|
||||
d[field] = json.loads(val)
|
||||
except:
|
||||
d[field] = []
|
||||
elif not isinstance(val, list):
|
||||
d[field] = []
|
||||
|
||||
for field in json_dict_fields:
|
||||
if field in d:
|
||||
val = d[field]
|
||||
if val is None:
|
||||
d[field] = {}
|
||||
elif isinstance(val, str):
|
||||
try:
|
||||
d[field] = json.loads(val)
|
||||
except:
|
||||
d[field] = {}
|
||||
elif not isinstance(val, dict):
|
||||
d[field] = {}
|
||||
|
||||
return d
|
||||
|
||||
# --- BAKGRUNNSARBEIDER: FUNKSJON SOM KJØRER SKRAPEREN I BAKGRUNNEN ---
|
||||
def run_scrape_worker(facility_ids: List[int]):
|
||||
"""
|
||||
Kjører selve skraping-scriptet i bakgrunnen.
|
||||
Slik kan frontenden få et umiddelbart svar, mens skraperen jobber.
|
||||
"""
|
||||
print(f"🔄 STARTER BAKGRUNNSSKRAPING FOR FØLGENDE IDER: {facility_ids}")
|
||||
|
||||
try:
|
||||
ids_arg = ",".join(map(str, facility_ids))
|
||||
|
||||
# NYTT: Bruker "python -u" for LIVE logging, og fjerner "> /dev/null 2>&1"
|
||||
command = f"python -u scrape_status.py --ids {ids_arg}"
|
||||
|
||||
subprocess.run(command, shell=True, check=True)
|
||||
|
||||
print(f"✅ BAKGRUNNSSKRAPING FULLFØRT FOR IDER: {facility_ids}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"❌ FEIL UNDER BAKGRUNNSSKRAPING: {e}")
|
||||
except Exception as e:
|
||||
print(f"🔥 UFORUTSETT FEIL UNDER BAKGRUNNSSKRAPING: {e}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Opprett database-pool ved start
|
||||
try:
|
||||
print(f"📡 Forsøker å koble til database på: {DB_URL}")
|
||||
app.state.pool = await asyncpg.create_pool(
|
||||
DB_URL,
|
||||
min_size=5,
|
||||
max_size=20,
|
||||
command_timeout=60
|
||||
)
|
||||
print("✅ Database tilkoblet og pool opprettet")
|
||||
except Exception as e:
|
||||
print(f"❌ Databasefeil under oppstart: {e}")
|
||||
raise e
|
||||
yield
|
||||
# Lukk pool ved avslutning
|
||||
await app.state.pool.close()
|
||||
|
||||
app = FastAPI(title="TeeOff API v3.7.0", lifespan=lifespan)
|
||||
|
||||
# CORS - Tillater både lokal utvikling og produksjonsdomene
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"https://nye.teeoff.no",
|
||||
"http://nye.teeoff.no",
|
||||
"http://localhost:3000"
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# --- AUTH ENDPOINTS ---
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def login(data: dict):
|
||||
"""Steg 1: Sjekk passord og returner temp_token for 2FA."""
|
||||
print(f"🔐 Loggin-forsøk for: {data.get('username')}")
|
||||
|
||||
async with app.state.pool.acquire() as conn:
|
||||
admin = await conn.fetchrow(
|
||||
"SELECT * FROM admins WHERE username = $1 OR email = $1",
|
||||
data.get('username')
|
||||
)
|
||||
|
||||
if not admin:
|
||||
print(" - ❌ Bruker ikke funnet i databasen")
|
||||
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
|
||||
|
||||
h = admin['password_hash']
|
||||
print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)")
|
||||
|
||||
try:
|
||||
is_valid = pwd_context.verify(data.get('password'), h)
|
||||
except Exception as e:
|
||||
print(f" - 🔥 FEIL VED LESING AV HASH: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internt problem med passord-format")
|
||||
|
||||
if not is_valid:
|
||||
print(" - ❌ Passordet samsvarer ikke med hashen")
|
||||
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
|
||||
|
||||
temp_token = jwt.encode(
|
||||
{"sub": admin['username'], "partial": True, "exp": datetime.utcnow() + timedelta(minutes=5)},
|
||||
SECRET_KEY, algorithm=ALGORITHM
|
||||
)
|
||||
print(" - ✅ Steg 1 fullført. Temp-token generert.")
|
||||
return {"step": "2fa", "temp_token": temp_token}
|
||||
|
||||
@app.post("/api/auth/verify-2fa")
|
||||
async def verify_2fa(data: dict, response: Response):
|
||||
"""Steg 2: Verifiser TOTP-kode og sett session cookie."""
|
||||
try:
|
||||
payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM])
|
||||
if not payload.get("partial"):
|
||||
raise JWTError()
|
||||
username = payload.get("sub")
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401, detail="Sesjonen har utløpt eller er ugyldig")
|
||||
|
||||
async with app.state.pool.acquire() as conn:
|
||||
admin = await conn.fetchrow("SELECT otp_secret FROM admins WHERE username = $1", username)
|
||||
|
||||
totp = pyotp.TOTP(admin['otp_secret'])
|
||||
if not totp.verify(data.get('code')):
|
||||
print(f" - ❌ Feil 2FA-kode oppgitt for {username}")
|
||||
raise HTTPException(status_code=401, detail="Feil 2FA-kode")
|
||||
|
||||
final_token = jwt.encode(
|
||||
{"sub": username, "exp": datetime.utcnow() + timedelta(hours=12)},
|
||||
SECRET_KEY, algorithm=ALGORITHM
|
||||
)
|
||||
|
||||
# Sett som HTTP-only cookie
|
||||
response.set_cookie(
|
||||
key="admin_session",
|
||||
value=final_token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=False # Sett til True i produksjon (HTTPS)
|
||||
)
|
||||
return {"status": "success"}
|
||||
|
||||
# --- DATA ENDPOINTS ---
|
||||
|
||||
@app.get("/api/facilities")
|
||||
async def get_facilities():
|
||||
"""Henter alle golfanlegg med aggregert banestatus for forsiden."""
|
||||
async with app.state.pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT f.*, (
|
||||
SELECT jsonb_agg(cs) FROM (
|
||||
SELECT id, name, status FROM courses
|
||||
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to'
|
||||
ORDER BY is_main_course DESC, id ASC
|
||||
) cs
|
||||
) as course_statuses
|
||||
FROM facilities f
|
||||
ORDER BY f.name ASC
|
||||
""")
|
||||
return [format_row(row) for row in rows]
|
||||
|
||||
@app.get("/api/facilities/{slug}")
|
||||
async def get_facility(slug: str):
|
||||
"""Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull."""
|
||||
async with app.state.pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT f.*, (
|
||||
SELECT jsonb_agg(c_data) FROM (
|
||||
SELECT c.*, (
|
||||
SELECT jsonb_agg(h_data ORDER BY h_data.hole_number ASC)
|
||||
FROM (SELECT * FROM holes WHERE course_id = c.id) h_data
|
||||
) as holes
|
||||
FROM courses c
|
||||
WHERE c.facility_id = f.id
|
||||
AND (c.is_main_course = true OR (c.status NOT IN ('finnes_ingen_bane_to', 'ukjent')))
|
||||
ORDER BY c.is_main_course DESC, c.id ASC
|
||||
) c_data
|
||||
) as courses
|
||||
FROM facilities f WHERE f.slug = $1
|
||||
""", slug)
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
|
||||
|
||||
return format_row(row)
|
||||
|
||||
# --- ADMIN ENDPOINTS ---
|
||||
|
||||
@app.patch("/api/admin/facilities/{facility_id}/scrape-settings")
|
||||
async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate):
|
||||
"""Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL)."""
|
||||
async with app.state.pool.acquire() as conn:
|
||||
try:
|
||||
# Sjekk først at anlegget eksisterer
|
||||
facility = await conn.fetchrow("SELECT id FROM facilities WHERE id = $1", facility_id)
|
||||
if not facility:
|
||||
raise HTTPException(status_code=404, detail="Anlegget finnes ikke.")
|
||||
|
||||
# Oppdater verdiene i databasen inkludert AI instruks
|
||||
await conn.execute("""
|
||||
UPDATE facilities
|
||||
SET scrape_method = $1,
|
||||
scrape_status_url = $2,
|
||||
scrape_status_selector = $3,
|
||||
ai_instruction = $4
|
||||
WHERE id = $5
|
||||
""",
|
||||
settings.scrape_method,
|
||||
settings.scrape_status_url,
|
||||
settings.scrape_status_selector,
|
||||
settings.ai_instruction,
|
||||
facility_id)
|
||||
|
||||
# Hvis metoden er manuell, tvinger vi gjennom de nye banestatusene direkte
|
||||
if settings.scrape_method == 'manual' and settings.courses:
|
||||
for c in settings.courses:
|
||||
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", c.status, c.id)
|
||||
|
||||
return {"status": "success", "message": f"Skrapeinnstillinger for anlegg ID {facility_id} ble oppdatert."}
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, HTTPException):
|
||||
raise e
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# --- NYTT ADMIN ENDPOINT FOR FULL OPPDATERING (JSON-EDITOR) ---
|
||||
@app.put("/api/admin/facilities/{facility_id}/full")
|
||||
async def update_facility_full(facility_id: int, request: Request):
|
||||
"""Dynamisk endpoint som oppdaterer anlegg, baner og hull (den fulle editoren)."""
|
||||
data = await request.json()
|
||||
|
||||
# Felter som er trygge å oppdatere manuelt på anlegget
|
||||
allowed_fields = [
|
||||
'name', 'description', 'established_year', 'season', 'banetype', 'architect', 'length_meters',
|
||||
'address', 'zipcode', 'city', 'county', 'lat', 'lng',
|
||||
'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
|
||||
'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url',
|
||||
'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee',
|
||||
'nsg_url', 'nsg_data', 'golfamore', 'golfamore_data',
|
||||
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
|
||||
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url',
|
||||
'vtg_presentasjon', 'vtg_lenke', 'vtg_pris', 'vtg_kursdatoer',
|
||||
'guest_requirements', 'scrape_method', 'scrape_status_url',
|
||||
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at'
|
||||
]
|
||||
|
||||
update_data = {k: v for k, v in data.items() if k in allowed_fields}
|
||||
|
||||
async with app.state.pool.acquire() as conn:
|
||||
async with conn.transaction(): # Sikrer at alt lagres samlet
|
||||
|
||||
# 1. OPPDATER ANLEGG (FACILITIES)
|
||||
if update_data:
|
||||
set_clauses = []
|
||||
values = []
|
||||
for i, (k, v) in enumerate(update_data.items(), 1):
|
||||
if isinstance(v, (dict, list)):
|
||||
set_clauses.append(f"{k} = ${i}::jsonb")
|
||||
values.append(json.dumps(v))
|
||||
else:
|
||||
set_clauses.append(f"{k} = ${i}")
|
||||
values.append(v)
|
||||
|
||||
values.append(facility_id)
|
||||
query = f"UPDATE facilities SET {', '.join(set_clauses)} WHERE id = ${len(values)}"
|
||||
await conn.execute(query, *values)
|
||||
|
||||
# 2. OPPDATER BANER (COURSES) OG HULL (HOLES)
|
||||
courses = data.get('courses', [])
|
||||
for course in courses:
|
||||
course_id = course.get('id')
|
||||
if course_id:
|
||||
# Rens datoformat for PostgreSQL (håndterer Next.js date input)
|
||||
valid_until = course.get('slope_valid_until')
|
||||
if valid_until == "" or valid_until is None:
|
||||
valid_until = None
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE courses
|
||||
SET name=$1, par=$2, length_meters=$3, architect=$4,
|
||||
status=$5, is_main_course=$6, tee_boxes=$7::jsonb,
|
||||
slope_valid_until=$8
|
||||
WHERE id=$9 AND facility_id=$10
|
||||
""",
|
||||
course.get('name'), course.get('par'), course.get('length_meters'),
|
||||
course.get('architect'), course.get('status'), course.get('is_main_course'),
|
||||
json.dumps(course.get('tee_boxes', {})), valid_until, course_id, facility_id)
|
||||
|
||||
# 3. OPPDATER HULL PÅ BANEN (HOLES)
|
||||
holes = course.get('holes', [])
|
||||
for hole in holes:
|
||||
hole_id = hole.get('id')
|
||||
if hole_id:
|
||||
await conn.execute("""
|
||||
UPDATE holes
|
||||
SET par=$1, hcp_index=$2, lengths=$3::jsonb
|
||||
WHERE id=$4 AND course_id=$5
|
||||
""",
|
||||
hole.get('par'), hole.get('hcp_index'),
|
||||
json.dumps(hole.get('lengths', {})), hole_id, course_id)
|
||||
|
||||
return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."}
|
||||
|
||||
# --- NYTT ADMIN ENDPOINT: KJØRER SKRAPEREN FOR VALGTE IDER ---
|
||||
@app.post("/api/admin/run-scraper")
|
||||
async def run_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
|
||||
"""
|
||||
Tar imot IDer for skraping, og starter en bakgrunnsjobb.
|
||||
Gir et umiddelbart svar tilbake til frontenden slik at den slipper å vente.
|
||||
"""
|
||||
if not request.facility_ids:
|
||||
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
|
||||
|
||||
print(f"📡 API mottok forespørsel om å kjøre skraping for IDer: {request.facility_ids}")
|
||||
|
||||
background_tasks.add_task(run_scrape_worker, request.facility_ids)
|
||||
|
||||
return {"status": "queued", "message": f"Skraping for {len(request.facility_ids)} anlegg ble lagt i kø."}
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
"""Enkel sjekk for å se at API og DB lever."""
|
||||
try:
|
||||
async with app.state.pool.acquire() as conn:
|
||||
await conn.execute("SELECT 1")
|
||||
return {"status": "healthy", "database": "connected"}
|
||||
except Exception as e:
|
||||
return {"status": "unhealthy", "error": str(e)}
|
||||
|
||||
# --- MEDLEMSKAP "VASKERI" ENDEPUNKTER ---
|
||||
|
||||
@app.get("/api/admin/membership/drafts")
|
||||
async def get_membership_drafts():
|
||||
"""Henter alle anlegg som har et ventende forslag fra AI-skraperen."""
|
||||
async with app.state.pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT id, name, slug, medlemskap_url,
|
||||
navn_standard_medlemskap, standard_medlemskap,
|
||||
navn_rimeligste_alternativ, rimeligste_alternativ,
|
||||
membership_draft
|
||||
FROM facilities
|
||||
WHERE membership_draft IS NOT NULL
|
||||
AND membership_draft::text != '{}'
|
||||
ORDER BY name ASC
|
||||
""")
|
||||
return [format_row(row) for row in rows]
|
||||
|
||||
@app.post("/api/admin/membership/approve-bulk")
|
||||
async def approve_membership_bulk(request: BulkApprovalRequest):
|
||||
"""Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet."""
|
||||
async with app.state.pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
for approval in request.approvals:
|
||||
await conn.execute("""
|
||||
UPDATE facilities
|
||||
SET navn_standard_medlemskap = $1,
|
||||
standard_medlemskap = $2,
|
||||
standard_medlemskap_kommentarer = $3,
|
||||
navn_rimeligste_alternativ = $4,
|
||||
rimeligste_alternativ = $5,
|
||||
membership_updated_at = NOW(),
|
||||
membership_draft = NULL
|
||||
WHERE id = $6
|
||||
""",
|
||||
approval.navn_standard_medlemskap,
|
||||
approval.standard_medlemskap,
|
||||
approval.standard_medlemskap_kommentarer,
|
||||
approval.navn_rimeligste_alternativ,
|
||||
approval.rimeligste_alternativ,
|
||||
approval.facility_id)
|
||||
return {"status": "success", "message": f"{len(request.approvals)} anlegg ble oppdatert med nye priser!"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
124
kode_eksport_1/backend_scrape_golfamore1_3_py.txt
Normal file
124
kode_eksport_1/backend_scrape_golfamore1_3_py.txt
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import asyncio
|
||||
import asyncpg
|
||||
import json
|
||||
import re
|
||||
|
||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
||||
|
||||
# Data hentet direkte fra bildet du sendte
|
||||
GOLFAMORE_DATA = {
|
||||
"borre": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 14, 19, 20, 21",
|
||||
"nesfjellet": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
|
||||
"vradal": "Kortet er gyldig alle dager, ikke uke 28, 29, 30, 31",
|
||||
"alta": "Kortet er gyldig alle dager",
|
||||
"elverum": "Kortet er gyldig hverdager (ikke helligdager)",
|
||||
"gronmo": "Kortet er gyldig alle dager",
|
||||
"notteroy": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
|
||||
"roros": "Kortet er gyldig alle dager",
|
||||
"stiklestad": "Kortet er gyldig alle dager",
|
||||
"arendalomegn": "Kortet er gyldig alle dager, ikke uke 27, 28, 29, 30",
|
||||
"northcape": "Kortet er gyldig alle dager",
|
||||
"trysil": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 28, 29, 30, 31",
|
||||
"mork": "Kortet er gyldig hverdager (ikke helligdager)",
|
||||
"norsjo": "Kortet er gyldig alle dager",
|
||||
"ringerike": "Kortet er gyldig alle dager",
|
||||
"stord": "Kortet er gyldig alle dager",
|
||||
"sunnmore": "Kortet er gyldig alle dager",
|
||||
"bodogolfparksalten": "Kortet er gyldig alle dager",
|
||||
"drammen": "Kortet er gyldig alle dager",
|
||||
"gjoviktoten": "Kortet er gyldig alle dager",
|
||||
"grenlandomegn": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
|
||||
"nes09": "Kortet er gyldig alle dager, ikke uke 15, 16, 17, 18",
|
||||
"romerike": "Kortet er gyldig alle dager",
|
||||
"bamble": "Kortet er gyldig alle dager",
|
||||
"bleik": "Kortet er gyldig alle dager",
|
||||
"krokhol": "Kortet er gyldig alle dager",
|
||||
"skjeberg": "Kortet er gyldig hverdager (ikke helligdager)",
|
||||
"utsikten": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
|
||||
"eiker": "Kortet er gyldig alle dager",
|
||||
"hafjell": "Kortet er gyldig alle dager",
|
||||
"mandal": "Kortet er gyldig alle dager, ikke uke 27, 28, 29, 30",
|
||||
"mjosen": "Kortet er gyldig alle dager",
|
||||
"randsfjorden": "Kortet er gyldig alle dager",
|
||||
"ski": "Kortet er gyldig alle dager",
|
||||
"bjornefjorden": "Kortet er gyldig alle dager",
|
||||
"sande": "Kortet er gyldig alle dager",
|
||||
"haugesund": "Kortet er gyldig alle dager",
|
||||
"midttroms": "Kortet er gyldig alle dager",
|
||||
"skei": "Kortet er gyldig hverdager (ikke helligdager)",
|
||||
"sorknes": "Kortet er gyldig alle dager",
|
||||
"gjerdrum": "Kortet er gyldig alle dager",
|
||||
"herdla": "Kortet er gyldig alle dager",
|
||||
"hovden": "Kortet er gyldig alle dager",
|
||||
"oppdal": "Kortet er gyldig alle dager",
|
||||
"gjersjoen": "Kortet er gyldig alle dager",
|
||||
"ogna": "Kortet er gyldig alle dager",
|
||||
"tonsberg": "Kortet er gyldig alle dager",
|
||||
"ullensaker": "Kortet er gyldig alle dager",
|
||||
"hof": "Kortet er gyldig hverdager (ikke helligdager)",
|
||||
"klabu": "Kortet er gyldig alle dager",
|
||||
"hemsedal": "Kortet er gyldig alle dager",
|
||||
"narvik": "Kortet er gyldig alle dager",
|
||||
"norefjell": "Kortet er gyldig hverdager (ikke helligdager)",
|
||||
"austratt": "Kortet er gyldig alle dager",
|
||||
"hammerfest": "Kortet er gyldig alle dager",
|
||||
"helgeland": "Kortet er gyldig alle dager",
|
||||
"jaren": "Kortet er gyldig alle dager",
|
||||
"namdal": "Kortet er gyldig alle dager",
|
||||
"namsos": "Kortet er gyldig alle dager",
|
||||
"nordfjord": "Kortet er gyldig alle dager",
|
||||
"polarsirkelen": "Kortet er gyldig alle dager",
|
||||
"sandnesbarheim": "Kortet er gyldig alle dager",
|
||||
"steinkjer": "Kortet er gyldig alle dager",
|
||||
"varanger": "Kortet er gyldig alle dager"
|
||||
}
|
||||
|
||||
def clean(text):
|
||||
if not text: return ""
|
||||
# Fjerner alt som ikke er bokstaver/tall for matching
|
||||
s = text.lower().replace("golfklubb", "").replace("gk", "").replace(" og ", "").replace("&", "").strip()
|
||||
return re.sub(r'[^a-z0-9]', '', s)
|
||||
|
||||
async def update_golfamore():
|
||||
print("\n🚀 OPPDATERER GOLFAMORE FRA BILDE-DATA...")
|
||||
conn = await asyncpg.connect(DB_URL)
|
||||
facilities = await conn.fetch("SELECT id, name FROM facilities")
|
||||
|
||||
# Lag et vasket map av bilde-dataen
|
||||
image_data_clean = {clean(name): val for name, val in GOLFAMORE_DATA.items()}
|
||||
|
||||
matches = 0
|
||||
for fac in facilities:
|
||||
fac_id = fac['id']
|
||||
fac_name = fac['name']
|
||||
fac_clean = clean(fac_name)
|
||||
|
||||
validity = None
|
||||
# Prøv eksakt match først
|
||||
if fac_clean in image_data_clean:
|
||||
validity = image_data_clean[fac_clean]
|
||||
else:
|
||||
# Prøv delvis match (f.eks "Arendal" i "Arendal & Omegn")
|
||||
for key, val in image_data_clean.items():
|
||||
if len(fac_clean) > 4 and (fac_clean in key or key in fac_clean):
|
||||
validity = val
|
||||
break
|
||||
|
||||
if validity:
|
||||
print(f"✅ Match funnet: {fac_name}")
|
||||
ga_data = {"validity": validity}
|
||||
await conn.execute("""
|
||||
UPDATE facilities
|
||||
SET golfamore = true, golfamore_data = $1
|
||||
WHERE id = $2
|
||||
""", json.dumps(ga_data), fac_id)
|
||||
matches += 1
|
||||
else:
|
||||
# Hvis den ikke er i listen fra bildet, sett til false
|
||||
await conn.execute("UPDATE facilities SET golfamore = false, golfamore_data = '{}' WHERE id = $1", fac_id)
|
||||
|
||||
await conn.close()
|
||||
print(f"\n🎉 Ferdig! {matches} baner ble oppdatert med Golfamore-info.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(update_golfamore())
|
||||
163
kode_eksport_1/backend_scrape_membership_py.txt
Normal file
163
kode_eksport_1/backend_scrape_membership_py.txt
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""
|
||||
TEE OFF - MEDLEMSKAPSSKRAPER MED GEMINI AI
|
||||
---------------------------------------------------------------------------
|
||||
Går til oppgitte medlemskaps-URLer, henter ut tekst, og bruker Gemini til å
|
||||
finne 'Standard' og 'Rimeligste' medlemskap basert på TeeOffs definisjoner.
|
||||
Lagrer resultatet som et utkast i databasen (membership_draft).
|
||||
---------------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
from bs4 import BeautifulSoup
|
||||
from playwright.async_api import async_playwright
|
||||
import google.generativeai as genai
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Last inn miljøvariabler
|
||||
load_dotenv()
|
||||
|
||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
||||
|
||||
if not GEMINI_API_KEY:
|
||||
raise ValueError("🚨 GEMINI_API_KEY mangler i .env filen!")
|
||||
|
||||
# Konfigurer Gemini
|
||||
genai.configure(api_key=GEMINI_API_KEY)
|
||||
model = genai.GenerativeModel('gemini-2.5-flash') # Eller gemini-1.5-pro avhengig av hva du har tilgang til
|
||||
|
||||
async def fetch_page_text(url: str) -> str:
|
||||
"""Bruker Playwright for å hente all synlig tekst fra nettsiden."""
|
||||
print(f" 🌐 Laster inn: {url}")
|
||||
try:
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
page = await browser.new_page()
|
||||
# Setter timeout til 15 sekunder
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=15000)
|
||||
|
||||
# Hent hele HTML-innholdet
|
||||
html_content = await page.content()
|
||||
await browser.close()
|
||||
|
||||
# Bruk BeautifulSoup til å renske ut bare den synlige teksten
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
# Fjern script og style tags
|
||||
for script in soup(["script", "style", "nav", "footer", "header"]):
|
||||
script.extract()
|
||||
|
||||
text = soup.get_text(separator=' ', strip=True)
|
||||
|
||||
# Begrens teksten slik at vi ikke sprenger token-grensen til AI (f.eks max 15000 tegn)
|
||||
return text[:15000]
|
||||
except Exception as e:
|
||||
print(f" ❌ Feil ved lasting av side: {e}")
|
||||
return ""
|
||||
|
||||
def analyze_with_gemini(text: str, club_name: str) -> dict:
|
||||
"""Sender teksten til Gemini for å trekke ut priser."""
|
||||
print(f" 🧠 Sender {len(text)} tegn til Gemini for analyse...")
|
||||
|
||||
prompt = f"""
|
||||
Du er en ekspert på norske golfklubber og medlemskap.
|
||||
Din oppgave er å lese teksten hentet fra nettsiden til "{club_name}" og trekke ut to spesifikke medlemskapspriser.
|
||||
|
||||
DEFINISJONER DU MÅ FØLGE STRENGT:
|
||||
1. "Standard medlemskap": Hva vil det koste for en gjennomsnittsgolfer (voksen over 25/30 år, ikke student/senior) å spille SÅ RYE VEDKOMMENDE ØNSKER (Fritt spill) på denne banen i år?
|
||||
2. "Rimeligste alternativ": Det absolutt billigste medlemskapet som gir medlemskap i klubben (golfkortet), forutsatt at man aksepterer å måtte betale greenfee for hver runde man spiller. (Ofte kalt Greenfeemedlem, Postkassemedlem, Fjernmedlem el.l.)
|
||||
|
||||
TEKST FRA NETTSIDEN:
|
||||
{text}
|
||||
|
||||
OPPGAVE:
|
||||
Returner KUN et gyldig JSON-objekt med følgende struktur (og ingenting annet, ingen markdown):
|
||||
{{
|
||||
"foreslatt_standard_navn": "Navnet på medlemskapet (eks: Hovedmedlem Voksen)",
|
||||
"foreslatt_standard_pris": 1234,
|
||||
"foreslatt_standard_kommentar": "Kort evt kommentar (eks: Inkluderer ikke 500kr i dugnadsavgift)",
|
||||
"foreslatt_rimeligste_navn": "Navnet (eks: Greenfeemedlemskap)",
|
||||
"foreslatt_rimeligste_pris": 500,
|
||||
"ai_begrunnelse": "Kort forklaring på hvorfor du valgte disse to, f.eks: 'Valgte Hovedmedlem for fritt spill og Greenfeemedlem fordi...'."
|
||||
}}
|
||||
|
||||
Merk: Hvis prisene mangler, sett pris til null og skriv "Fant ikke" i navnet. Prisen SKAL være et tall (integer), ikke en tekststreng (bruk 6500, ikke "6 500").
|
||||
"""
|
||||
|
||||
try:
|
||||
response = model.generate_content(prompt)
|
||||
raw_response = response.text.strip()
|
||||
|
||||
# Rensker vekk eventuell markdown-formatering som ```json
|
||||
if raw_response.startswith("```json"):
|
||||
raw_response = raw_response[7:]
|
||||
if raw_response.endswith("```"):
|
||||
raw_response = raw_response[:-3]
|
||||
|
||||
return json.loads(raw_response.strip())
|
||||
except Exception as e:
|
||||
print(f" ❌ AI-analyse feilet: {e}")
|
||||
return None
|
||||
|
||||
async def run_scraper(facility_ids=None):
|
||||
"""Hovedfunksjon som henter fra DB, skraper, og lagrer utkast."""
|
||||
print("🚀 Starter Medlemskaps-skraperen...")
|
||||
|
||||
conn = await asyncpg.connect(DB_URL)
|
||||
|
||||
try:
|
||||
# Hent anlegg som har en url for medlemskap
|
||||
query = "SELECT id, name, medlemskap_url FROM facilities WHERE medlemskap_url IS NOT NULL AND medlemskap_url != ''"
|
||||
if facility_ids:
|
||||
query += f" AND id IN ({','.join(map(str, facility_ids))})"
|
||||
|
||||
facilities = await conn.fetch(query)
|
||||
print(f"📋 Fant {len(facilities)} anlegg å skrape.")
|
||||
|
||||
for facility in facilities:
|
||||
fac_id = facility['id']
|
||||
name = facility['name']
|
||||
url = facility['medlemskap_url']
|
||||
|
||||
print(f"\n▶️ Behandler: {name} (ID: {fac_id})")
|
||||
|
||||
# 1. Hent tekst
|
||||
page_text = await fetch_page_text(url)
|
||||
if not page_text or len(page_text) < 50:
|
||||
print(" ⚠️ Fant for lite tekst på siden, hopper over.")
|
||||
continue
|
||||
|
||||
# 2. Analyser med Gemini
|
||||
draft_data = analyze_with_gemini(page_text, name)
|
||||
|
||||
if not draft_data:
|
||||
continue
|
||||
|
||||
# 3. Lagre i databasen som utkast
|
||||
print(f" ✅ AI foreslår: Standard: {draft_data.get('foreslatt_standard_pris')} | Rimeligste: {draft_data.get('foreslatt_rimeligste_pris')}")
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE facilities
|
||||
SET membership_draft = $1::jsonb
|
||||
WHERE id = $2
|
||||
""", json.dumps(draft_data), fac_id)
|
||||
|
||||
print(" 💾 Utkast lagret i databasen!")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
print("\n🏁 Skraping fullført.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Skrap medlemskapspriser via AI.")
|
||||
parser.add_argument("--ids", type=str, help="Kommaseparert liste med facility IDs (eks: 1,5,12)")
|
||||
args = parser.parse_args()
|
||||
|
||||
ids_to_scrape = None
|
||||
if args.ids:
|
||||
ids_to_scrape = [int(x.strip()) for x in args.ids.split(",")]
|
||||
|
||||
asyncio.run(run_scraper(ids_to_scrape))
|
||||
96
kode_eksport_1/backend_scrape_nsg_3_py.txt
Normal file
96
kode_eksport_1/backend_scrape_nsg_3_py.txt
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import asyncio
|
||||
import asyncpg
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
import json
|
||||
|
||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
||||
|
||||
def clean_name(text):
|
||||
if not text: return ""
|
||||
s = text.lower().replace("golfklubb", "").replace("gk", "").replace("par3golf", "").replace(" & ", "").strip()
|
||||
return re.sub(r'[^a-z]', '', s)
|
||||
|
||||
def clean_nsg_content(text):
|
||||
"""Fjerner doble linjeskift og kutter teksten før websidemenyen starter"""
|
||||
if not text: return ""
|
||||
# Fjern alt som ligner på bunn-menyen til NSG
|
||||
garbage_phrases = [
|
||||
"Klubbens hjemmeside", "Resultatlister i Golfbox", "Livescoring",
|
||||
"Scoreinntasting", "Lagserie", "Turneringer", "Innmelding"
|
||||
]
|
||||
for phrase in garbage_phrases:
|
||||
text = text.split(phrase)[0]
|
||||
|
||||
# Rydd opp i linjeskift og doble mellomrom
|
||||
text = text.replace('\r', '').replace('\n', ' ')
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
return text
|
||||
|
||||
async def get_nsg_links(client):
|
||||
links = []
|
||||
urls = ["https://seniorgolf.no/lojalitetskort-sitemap.xml", "https://seniorgolf.no/fordelskortet/"]
|
||||
for url in urls:
|
||||
try:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code == 200:
|
||||
if ".xml" in url:
|
||||
found = re.findall(r'<loc>(https://seniorgolf.no/lojalitetskort/.*?/)</loc>', resp.text)
|
||||
if found: return list(set(found))
|
||||
else:
|
||||
soup = BeautifulSoup(resp.text, 'html.parser')
|
||||
links.extend([l['href'] for l in soup.select('a[href*="/lojalitetskort/"]')])
|
||||
except: continue
|
||||
return list(set(links))
|
||||
|
||||
async def scrape_nsg():
|
||||
print("🚀 Starter NSG VASKEMASKIN v3.8...")
|
||||
conn = await asyncpg.connect(DB_URL)
|
||||
facilities = await conn.fetch("SELECT id, name FROM facilities")
|
||||
|
||||
async with httpx.AsyncClient(timeout=20.0, headers={'User-Agent': 'Mozilla/5.0'}) as client:
|
||||
all_nsg_links = await get_nsg_links(client)
|
||||
link_map = {clean_name(l.split('/')[-2].replace('-', ' ')): l for l in all_nsg_links}
|
||||
|
||||
matches_found = 0
|
||||
for fac in facilities:
|
||||
fac_name_clean = clean_name(fac['name'])
|
||||
match_url = link_map.get(fac_name_clean)
|
||||
|
||||
if not match_url:
|
||||
for slug, url in link_map.items():
|
||||
if fac_name_clean in slug or slug in fac_name_clean:
|
||||
match_url = url
|
||||
break
|
||||
|
||||
if match_url:
|
||||
try:
|
||||
f_resp = await client.get(match_url)
|
||||
f_soup = BeautifulSoup(f_resp.text, 'html.parser')
|
||||
|
||||
# Finn hovedinnholdet i stedet for hele siden for å unngå menyer
|
||||
main_content = f_soup.find('div', {'class': 'entry-content'}) or f_soup
|
||||
text = main_content.get_text()
|
||||
|
||||
st = re.search(r"Starttider:?\s*(.*?)(?=Greenfee|Booking|Adresse|Kontakt|$)", text, re.S | re.I)
|
||||
gf = re.search(r"Greenfee:?\s*(.*?)(?=Booking|Adresse|Kontakt|$)", text, re.S | re.I)
|
||||
bk = re.search(r"Booking:?\s*(.*?)(?=Adresse|Kontakt|$)", text, re.S | re.I)
|
||||
|
||||
nsg_data = {
|
||||
"url": match_url,
|
||||
"starttider": clean_nsg_content(st.group(1)) if st else "Se nettside",
|
||||
"greenfee": clean_nsg_content(gf.group(1)) if gf else "Se nettside",
|
||||
"booking": clean_nsg_content(bk.group(1)) if bk else "Se nettside"
|
||||
}
|
||||
|
||||
await conn.execute("UPDATE facilities SET nsg_data = $1 WHERE id = $2", json.dumps(nsg_data), fac['id'])
|
||||
print(f"✅ Vasket & Lagret: {fac['name']}")
|
||||
matches_found += 1
|
||||
except: pass
|
||||
|
||||
await conn.close()
|
||||
print(f"\n🎉 Vask ferdig! {matches_found} baner er nå 100% klare.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(scrape_nsg())
|
||||
332
kode_eksport_1/backend_scrape_status_py.txt
Normal file
332
kode_eksport_1/backend_scrape_status_py.txt
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import asyncio
|
||||
import os
|
||||
import asyncpg
|
||||
import smtplib
|
||||
import re
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from playwright.async_api import async_playwright
|
||||
try:
|
||||
from playwright_stealth import stealth_async as apply_stealth
|
||||
except ImportError:
|
||||
from playwright_stealth import stealth as apply_stealth
|
||||
|
||||
from google import genai
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
||||
|
||||
# ==========================================
|
||||
# KONFIGURERER GEMINI AI (NY SDK)
|
||||
# ==========================================
|
||||
client = genai.Client()
|
||||
|
||||
async def ask_llm_status(text, course_name, is_single_course, ai_instruction=None):
|
||||
if is_single_course:
|
||||
bane_instruks = "Finn den generelle banestatusen for dette golfanlegget. Se bort fra spesifikke banenavn, da anlegget kun har én bane."
|
||||
else:
|
||||
bane_instruks = f'Finn banestatusen SPESIFIKT for banen som heter/omtales som: "{course_name}".'
|
||||
|
||||
ekstra_tekst = f"\n!!! VIKTIG EKSTRA-INSTRUKS FRA ADMIN (DENNE OVERSTYRER ALLE ANDRE REGLER) !!!:\n{ai_instruction}\n" if ai_instruction else ""
|
||||
|
||||
prompt = f"""
|
||||
Du er en ekspert på å lese norske golfklubbers nettsider for å finne banestatus.
|
||||
{bane_instruks}
|
||||
{ekstra_tekst}
|
||||
Svar KUN med nøyaktig ETT av disse ordene:
|
||||
- aapen (hvis banen er åpen/sommergreener)
|
||||
- stengt (hvis banen er lukket/stengt/frost/snø)
|
||||
- aapen_med_vintergreener (hvis det spilles på vintergreener)
|
||||
- aapner_snart (hvis den åpner om kort tid)
|
||||
- stenger_snart (hvis den stenger for sesongen om kort tid)
|
||||
- under_utvikling (hvis den er under utvikling)
|
||||
- nedlagt (hvis den er nedlagt)
|
||||
- ukjent (hvis du ikke finner noe info om banen i teksten)
|
||||
|
||||
Tekst fra nettsiden:
|
||||
{text[:15000]}
|
||||
"""
|
||||
|
||||
print("\n" + "="*60)
|
||||
print(f"🤖 SENDER PROMPT TIL GEMINI FOR: '{course_name}'")
|
||||
print(f"👉 STANDARD-INSTRUKS: {bane_instruks}")
|
||||
if ai_instruction:
|
||||
print(f"👉 ADMIN-HVISKER: {ai_instruction}")
|
||||
clean_text_sample = " ".join(text.split())[:250]
|
||||
print(f"👉 TEKST FRA NETTSIDEN (utdrag): '{clean_text_sample}...'")
|
||||
print("="*60 + "\n")
|
||||
|
||||
try:
|
||||
response = await client.aio.models.generate_content(
|
||||
model='gemini-2.5-flash',
|
||||
contents=prompt
|
||||
)
|
||||
svar = response.text.strip().lower()
|
||||
|
||||
print(f" 🧠 GEMINI RÅ-SVAR: '{svar}'")
|
||||
|
||||
# --- NYTT: SORTERT SIKKERHETSFILTER ---
|
||||
gyldige_svar = [
|
||||
"aapen_med_vintergreener",
|
||||
"aapner_snart",
|
||||
"stenger_snart",
|
||||
"under_utvikling",
|
||||
"nedlagt",
|
||||
"stengt",
|
||||
"aapen",
|
||||
"ukjent"
|
||||
]
|
||||
|
||||
for gyldig in gyldige_svar:
|
||||
if gyldig in svar:
|
||||
return gyldig
|
||||
return "ukjent"
|
||||
except Exception as e:
|
||||
print(f"❌ Gemini Feil: {e}")
|
||||
return "ukjent"
|
||||
|
||||
|
||||
# ==========================================
|
||||
# EKSISTERENDE LOGIKK FOR MANUELL SCRAPING
|
||||
# ==========================================
|
||||
def clean_text(text):
|
||||
return re.sub(r'[^a-zA-Z0-9æøåÆØÅ]', '', text).lower()
|
||||
|
||||
def interpret_status(text, keyword=None):
|
||||
t_raw = text.lower()
|
||||
|
||||
if keyword:
|
||||
k_clean = clean_text(keyword)
|
||||
if k_clean not in clean_text(t_raw):
|
||||
return "NOT_FOUND"
|
||||
|
||||
parts = re.split(re.escape(keyword), t_raw, flags=re.IGNORECASE)
|
||||
if len(parts) > 1:
|
||||
t_raw = parts[1][:150]
|
||||
else:
|
||||
t_raw = t_raw[-200:]
|
||||
|
||||
if any(word in t_raw for word in ["stengt", "lukket", "frost", "snø", "is", "closed", "stenger"]):
|
||||
return "stengt"
|
||||
if any(word in t_raw for word in ["vintergreen", "vintergrønn", "vinter"]):
|
||||
return "aapen_med_vintergreener"
|
||||
if any(word in t_raw for word in ["snart", "åpner kl"]):
|
||||
return "aapner_snart"
|
||||
if any(word in t_raw for word in ["åpen", "åpent", "aapen", "open"]):
|
||||
return "aapen"
|
||||
return "ukjent"
|
||||
|
||||
def send_report(changes, warnings, successes):
|
||||
if not changes and not warnings and not successes: return
|
||||
subject = f"TeeOff Banestatus Rapport - {datetime.now().strftime('%d.%m.%Y')}"
|
||||
|
||||
body = "BANESTATUS RAPPORT\n" + "="*30 + "\n\n"
|
||||
|
||||
if changes: body += "✅ OPPDATERINGER:\n" + "\n".join(changes) + "\n\n"
|
||||
if warnings: body += "⚠️ MERKNADER / ADVARSLER:\n" + "\n".join(warnings) + "\n\n"
|
||||
if successes: body += "🆗 VELLYKKEDE SJEKKER (INGEN ENDRING):\n" + "\n".join(successes) + "\n"
|
||||
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = os.getenv("SMTP_USER")
|
||||
msg['To'] = os.getenv("EMAIL_TO")
|
||||
msg['Subject'] = subject
|
||||
msg.attach(MIMEText(body, 'plain'))
|
||||
try:
|
||||
with smtplib.SMTP_SSL(os.getenv("SMTP_SERVER"), int(os.getenv("SMTP_PORT"))) as server:
|
||||
server.login(os.getenv("SMTP_USER"), os.getenv("SMTP_PASS"))
|
||||
server.send_message(msg)
|
||||
print("✅ Rapport sendt på e-post.")
|
||||
except Exception as e:
|
||||
print(f"❌ E-post feil: {e}")
|
||||
|
||||
|
||||
# ==========================================
|
||||
# HOVEDMOTOR
|
||||
# ==========================================
|
||||
async def run_daily_scraping(facility_ids=None):
|
||||
print(f"🚀 Starter sjekk {datetime.now().strftime('%H:%M:%S')}...")
|
||||
conn = await asyncpg.connect(DB_URL)
|
||||
|
||||
if facility_ids:
|
||||
print(f"📌 Kjører skraping KUN for anlegg-ID(er): {facility_ids}")
|
||||
facilities = await conn.fetch(
|
||||
"SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method, ai_instruction FROM facilities WHERE scrape_status_url IS NOT NULL AND id = ANY($1::int[])",
|
||||
facility_ids
|
||||
)
|
||||
else:
|
||||
print("🌍 Kjører skraping for ALLE anlegg med scrape_status_url...")
|
||||
facilities = await conn.fetch(
|
||||
"SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method, ai_instruction FROM facilities WHERE scrape_status_url IS NOT NULL"
|
||||
)
|
||||
|
||||
if not facilities:
|
||||
print("⚠️ Fant ingen anlegg å skrape.")
|
||||
await conn.close()
|
||||
return
|
||||
|
||||
changes, warnings, successes = [], [], []
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
context = await browser.new_context()
|
||||
|
||||
for f in facilities:
|
||||
method = f.get('scrape_method') or 'css_selector'
|
||||
|
||||
if method == 'manual':
|
||||
successes.append(f"⏸️ {f['name']}: Hoppet over (Manuell overstyring)")
|
||||
print(f" ⏸️ Hopper over skraping av {f['name']} (Satt til Manuell)")
|
||||
continue
|
||||
|
||||
page = await context.new_page()
|
||||
try: await apply_stealth(page)
|
||||
except: pass
|
||||
|
||||
try:
|
||||
print(f"🔍 Besøker {f['name']} (Metode: {method})...")
|
||||
await page.goto(f['scrape_status_url'], timeout=60000, wait_until="domcontentloaded")
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
full_text = ""
|
||||
|
||||
if method == 'css_selector':
|
||||
element = page.locator(f['scrape_status_selector']).first
|
||||
if await element.count() == 0:
|
||||
warnings.append(f"❌ {f['name']}: Fant ikke CSS-elementet '{f['scrape_status_selector']}'")
|
||||
continue
|
||||
full_text = await element.inner_text()
|
||||
|
||||
elif method == 'iframe_golfbox':
|
||||
frame = page.frame_locator('iframe[src*="golfbox"]')
|
||||
element = frame.locator(f['scrape_status_selector']).first
|
||||
if await element.count() == 0:
|
||||
warnings.append(f"❌ {f['name']}: Fant ikke elementet '{f['scrape_status_selector']}' i iframen")
|
||||
continue
|
||||
full_text = await element.inner_text()
|
||||
|
||||
elif method == 'click_then_css':
|
||||
parts = f['scrape_status_selector'].split('||')
|
||||
if len(parts) != 2:
|
||||
warnings.append(f"❌ {f['name']}: Ugyldig selector for click_then_css (mangler ||)")
|
||||
continue
|
||||
|
||||
btn_selector, text_selector = parts
|
||||
btn = page.locator(btn_selector).first
|
||||
if await btn.count() == 0:
|
||||
warnings.append(f"❌ {f['name']}: Fant ikke knappen å klikke på: '{btn_selector}'")
|
||||
continue
|
||||
|
||||
await btn.click(force=True)
|
||||
await page.wait_for_timeout(2000)
|
||||
|
||||
element = page.locator(text_selector).first
|
||||
if await element.count() == 0:
|
||||
warnings.append(f"❌ {f['name']}: Fant ikke tekstboksen '{text_selector}' etter klikk")
|
||||
continue
|
||||
|
||||
full_text = await element.inner_text()
|
||||
|
||||
elif method == 'llm_parse':
|
||||
print(" 🖱️ Leter etter knapper å klikke på for å avdekke skjult tekst...")
|
||||
knapper = await page.get_by_text(re.compile(r"banestatus|dagens status|se status|se dagens status|baneinfo|\bstatus\b", re.IGNORECASE)).all()
|
||||
|
||||
klikk_count = 0
|
||||
for knapp in knapper:
|
||||
try:
|
||||
if await knapp.is_visible():
|
||||
await knapp.click(timeout=2000, force=True)
|
||||
klikk_count += 1
|
||||
await page.wait_for_timeout(2000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if klikk_count > 0:
|
||||
print(f" 🎯 Tvangsklikket på {klikk_count} status-knapp(er)! Venter ekstra på at innholdet laster...")
|
||||
await page.wait_for_timeout(2000)
|
||||
else:
|
||||
print(" ⚠️ Fant ingen knapper å klikke på.")
|
||||
|
||||
# --- NYTT: HENTER OGSÅ SKJULT TEKST (For Scangolf megamenyer) ---
|
||||
element = page.locator("body").first
|
||||
if await element.count() == 0:
|
||||
warnings.append(f"❌ {f['name']}: Klarte ikke å lese siden for AI-tolkning")
|
||||
continue
|
||||
|
||||
synlig_tekst = await element.inner_text() or ""
|
||||
skjult_tekst = await element.text_content() or ""
|
||||
|
||||
# Slår sammen all tekst slik at Gemini får med seg menyer som er gjemt med CSS
|
||||
råtekst = synlig_tekst + " " + skjult_tekst
|
||||
full_text = " ".join(råtekst.split())
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
else:
|
||||
warnings.append(f"⚠️ {f['name']}: Ukjent skrapemetode i databasen: '{method}'")
|
||||
continue
|
||||
|
||||
await conn.execute("UPDATE facilities SET status_updated_at = CURRENT_DATE WHERE id = $1", f['id'])
|
||||
|
||||
courses = await conn.fetch("SELECT id, name, status, scrape_keyword FROM courses WHERE facility_id = $1", f['id'])
|
||||
|
||||
is_single_course = len(courses) == 1
|
||||
|
||||
for c in courses:
|
||||
old_status = c['status'] or "ukjent"
|
||||
|
||||
if method == 'llm_parse':
|
||||
print(f" 🤖 Spør Gemini om status for '{c['name']}' (Singelbane: {is_single_course})...")
|
||||
new_status = await ask_llm_status(full_text, c['name'], is_single_course, f.get('ai_instruction'))
|
||||
|
||||
print(" ⏳ Tar 5 sekunders pause for å spare Gemini-kvoten...")
|
||||
await asyncio.sleep(5)
|
||||
else:
|
||||
new_status = interpret_status(full_text, c['scrape_keyword'])
|
||||
|
||||
if new_status == "NOT_FOUND":
|
||||
warnings.append(f"❓ {f['name']} ({c['name']}): Fant ikke søkeordet '{c['scrape_keyword']}' i teksten.")
|
||||
continue
|
||||
|
||||
# --- OPPDATERT LOGIKK (Fikser logg-buggen) ---
|
||||
if new_status == "ukjent":
|
||||
# Sikkerhetsnettet slår inn: Vi beholder gammel status!
|
||||
warnings.append(f"⚠️ {f['name']} ({c['name']}): Fant ikke status. Beholder '{old_status.upper()}'.")
|
||||
print(f" 🟡 KONKLUSJON: Fant ikke status i teksten (Sikkerhetsnett). Beholder gammel status ({old_status.upper()}).")
|
||||
elif new_status != old_status:
|
||||
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", new_status, c['id'])
|
||||
changes.append(f"🔹 {f['name']} ({c['name']}): {old_status.upper()} ➔ {new_status.upper()}")
|
||||
print(f" 🟢 KONKLUSJON: Status endret fra {old_status.upper()} til {new_status.upper()}")
|
||||
else:
|
||||
successes.append(f"✅ {f['name']} ({c['name']}): {new_status.upper()}")
|
||||
print(f" ⚪ KONKLUSJON: Ingen endring. Banen er fortsatt {old_status.upper()}")
|
||||
# ---------------------------------------------
|
||||
|
||||
except Exception as e:
|
||||
err_msg = str(e).split('\n')[0]
|
||||
warnings.append(f"🔥 {f['name']}: Feil under skraping: {err_msg}")
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
await browser.close()
|
||||
|
||||
await conn.close()
|
||||
send_report(changes, warnings, successes)
|
||||
print("🏁 Ferdig.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="TeeOff Status Scraper")
|
||||
parser.add_argument("--ids", type=str, help="Kommaseparert liste med anleggs-IDer", default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
facility_ids_list = None
|
||||
if args.ids:
|
||||
try:
|
||||
facility_ids_list = [int(id_str.strip()) for id_str in args.ids.split(",") if id_str.strip()]
|
||||
except ValueError:
|
||||
print("❌ Feil format på --ids. Må være kommaseparerte tall, f.eks: 1,4,12")
|
||||
exit(1)
|
||||
|
||||
asyncio.run(run_daily_scraping(facility_ids_list))
|
||||
79
kode_eksport_1/backend_sync_greenfee_py.txt
Normal file
79
kode_eksport_1/backend_sync_greenfee_py.txt
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import asyncio, asyncpg, urllib.request, json
|
||||
|
||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
||||
# Vi fjerner acf_format=standard da rå-feltnavnene er tryggere her
|
||||
WP_API_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100"
|
||||
|
||||
def decode_html(text):
|
||||
if not text: return ""
|
||||
return str(text).replace('&', '&').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())
|
||||
116
kode_eksport_1/backend_test_gemini_py.txt
Normal file
116
kode_eksport_1/backend_test_gemini_py.txt
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from playwright.async_api import async_playwright
|
||||
from google import genai
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Den nye pakken henter automatisk GEMINI_API_KEY fra .env-filen din
|
||||
client = genai.Client()
|
||||
|
||||
async def ask_llm_status(text, course_name, is_single_course):
|
||||
if is_single_course:
|
||||
bane_instruks = "Finn den generelle banestatusen for dette golfanlegget. Se bort fra spesifikke banenavn, da anlegget kun har én bane."
|
||||
else:
|
||||
bane_instruks = f'Finn banestatusen SPESIFIKT for banen som heter/omtales som: "{course_name}".'
|
||||
|
||||
prompt = f"""
|
||||
Du er en ekspert på å lese norske golfklubbers nettsider for å finne banestatus.
|
||||
{bane_instruks}
|
||||
Svar KUN med nøyaktig ETT av disse ordene:
|
||||
- aapen (hvis banen er åpen/sommergreener)
|
||||
- stengt (hvis banen er lukket/stengt/frost/snø)
|
||||
- aapen_med_vintergreener (hvis det spilles på vintergreener)
|
||||
- aapner_snart (hvis den åpner om kort tid)
|
||||
- stenger_snart (hvis den stenger for sesongen om kort tid)
|
||||
- under_utvikling (hvis den er under utvikling)
|
||||
- nedlagt (hvis den er nedlagt)
|
||||
- ukjent (hvis du ikke finner noe info om banen i teksten)
|
||||
|
||||
Tekst fra nettsiden:
|
||||
{text[:15000]}
|
||||
"""
|
||||
|
||||
try:
|
||||
# Ny måte å kalle modellen asynkront på med google-genai
|
||||
response = await client.aio.models.generate_content(
|
||||
model='gemini-2.5-flash',
|
||||
contents=prompt
|
||||
)
|
||||
svar = response.text.strip().lower()
|
||||
|
||||
gyldige_svar = [
|
||||
"aapen", "stengt", "aapen_med_vintergreener",
|
||||
"aapner_snart", "stenger_snart", "under_utvikling",
|
||||
"nedlagt", "ukjent"
|
||||
]
|
||||
|
||||
for gyldig in gyldige_svar:
|
||||
if gyldig in svar:
|
||||
return gyldig
|
||||
return "ukjent"
|
||||
except Exception as e:
|
||||
print(f"❌ Gemini Feil: {e}")
|
||||
return "ukjent"
|
||||
|
||||
async def run_test():
|
||||
print("\n" + "="*50)
|
||||
print(" 🧪 TEE OFF: GEMINI TEST-VERKTØY (MED AUTO-KLIKKER)")
|
||||
print("="*50)
|
||||
|
||||
url = input("🌐 Skriv inn URL til golfklubben (f.eks. https://oslogk.no): ").strip()
|
||||
if not url.startswith("http"):
|
||||
url = "https://" + url
|
||||
|
||||
course_name = input("⛳ Skriv inn banenavn (eller trykk ENTER hvis anlegget kun har 1 bane): ").strip()
|
||||
is_single = len(course_name) == 0
|
||||
|
||||
print("\n⏳ 1. Starter nettleser og besøker siden...")
|
||||
|
||||
full_text = ""
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
page = await browser.new_page()
|
||||
try:
|
||||
await page.goto(url, timeout=30000, wait_until="domcontentloaded")
|
||||
await asyncio.sleep(3) # Vent på animasjoner og iframes
|
||||
|
||||
# --- NY LOGIKK: AUTO-KLIKKER ---
|
||||
print("🖱️ Leter etter 'banestatus'-knapper å klikke på...")
|
||||
# Vi leter etter tekst som inneholder "banestatus" (ignorerer store/små bokstaver)
|
||||
knapper = await page.get_by_text(re.compile(r"banestatus", re.IGNORECASE)).all()
|
||||
|
||||
for knapp in knapper:
|
||||
try:
|
||||
if await knapp.is_visible():
|
||||
await knapp.click(timeout=3000)
|
||||
print(" 🎯 Klikket på en banestatus-knapp! Venter 2 sekunder...")
|
||||
await asyncio.sleep(2) # Venter på at modalen/pop-upen åpner seg
|
||||
break # Vi trenger bare å klikke på den første vi finner
|
||||
except Exception as e:
|
||||
# Ignorerer hvis knappen ikke er klikkbar, prøver neste
|
||||
pass
|
||||
# --------------------------------
|
||||
|
||||
element = page.locator("body").first
|
||||
råtekst = await element.inner_text()
|
||||
full_text = " ".join(råtekst.split())
|
||||
print(f"✅ Hentet {len(full_text)} tegn med tekst fra nettsiden.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Feil ved innlasting av side: {e}")
|
||||
await browser.close()
|
||||
return
|
||||
await browser.close()
|
||||
|
||||
print("🧠 2. Sender teksten til Gemini for analyse...")
|
||||
status = await ask_llm_status(full_text, course_name, is_single)
|
||||
|
||||
print("\n" + "="*50)
|
||||
print(f"🎯 GEMINI SITT SVAR: {status.upper()}")
|
||||
print("="*50 + "\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_test())
|
||||
47
kode_eksport_1/backend_test_login_py.txt
Normal file
47
kode_eksport_1/backend_test_login_py.txt
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import asyncio
|
||||
import asyncpg
|
||||
import os
|
||||
from passlib.context import CryptContext
|
||||
|
||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
||||
|
||||
# Vi setter opp passord-sjekkeren AKKURAT slik main.py gjør det
|
||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
|
||||
async def test_sannheten():
|
||||
print("\n" + "="*50)
|
||||
print(" 🔍 TEE OFF SANNHETSSERUM")
|
||||
print("="*50)
|
||||
|
||||
username = "Envide Webutvikling"
|
||||
test_password = "Solveig Vilde Ingvild Gina" # Sørg for at dette er det du satte sist!
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DB_URL)
|
||||
row = await conn.fetchrow("SELECT password_hash FROM admins WHERE username = $1", username)
|
||||
|
||||
if not row:
|
||||
print("❌ FEIL: Fant ikke brukeren i det hele tatt!")
|
||||
return
|
||||
|
||||
db_hash = row['password_hash']
|
||||
print(f"1. Hash funnet i databasen: {db_hash[:30]}...")
|
||||
|
||||
print(f"2. Tester mot passordet: '{test_password}'")
|
||||
|
||||
# Den magiske testen
|
||||
is_valid = pwd_context.verify(test_password, db_hash)
|
||||
|
||||
print("-" * 50)
|
||||
if is_valid:
|
||||
print("✅ SUKSESS! Passordet og hashen stemmer 100% overens.")
|
||||
print("➡️ KONKLUSJON: Hashingen fungerer perfekt. Problemet MÅ være at FastAPI (main.py) ikke klarer å lese JSON-dataene fra curl/frontend riktig.")
|
||||
else:
|
||||
print("❌ FEIL! Passordet stemmer IKKE med hashen i databasen.")
|
||||
print("➡️ KONKLUSJON: Scriptet som oppdaterer passordet gjør en feil (f.eks. legger til usynlige tegn), eller lagringen i databasen blir korrupt.")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_sannheten())
|
||||
85
kode_eksport_1/backend_update_admin_py.txt
Normal file
85
kode_eksport_1/backend_update_admin_py.txt
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""
|
||||
TEE OFF ADMIN PASSWORD UPDATER (API CONTAINER VERSION)
|
||||
---------------------------------------------------------------------------
|
||||
FUNKSJON: Kobler direkte til databasen inni API-containeren, sjekker at
|
||||
brukeren finnes, og utfører passordoppdateringen automatisk.
|
||||
STATUS: Påvirker IKKE tofaktor (2FA). Gjør jobben fra start til slutt.
|
||||
---------------------------------------------------------------------------
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import os
|
||||
import sys
|
||||
import getpass
|
||||
from passlib.hash import pbkdf2_sha256
|
||||
|
||||
# Henter database-URL fra miljøvariabler (samme metode som backenden din bruker)
|
||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
||||
|
||||
async def update_admin_password():
|
||||
print("\n" + "="*50)
|
||||
print(" TEE OFF ADMIN PASSORD-OPPDATERER (DIREKTE TILKOBLING)")
|
||||
print("="*50)
|
||||
|
||||
# Kobler til databasen på ekte backend-vis
|
||||
try:
|
||||
conn = await asyncpg.connect(DB_URL)
|
||||
except Exception as e:
|
||||
print(f"❌ Kunne ikke koble til databasen: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
# Brukernavn-verifisering
|
||||
while True:
|
||||
username = input("Brukernavn på admin som skal oppdateres: ").strip()
|
||||
|
||||
print("⏳ Sjekker databasen...")
|
||||
# Spør databasen direkte hvor mange som har dette navnet
|
||||
count = await conn.fetchval("SELECT COUNT(*) FROM admins WHERE username = $1", username)
|
||||
|
||||
if count == 0:
|
||||
print(f"❌ Fant ingen bruker med navnet '{username}'. Prøv igjen.\n")
|
||||
elif count > 1:
|
||||
print(f"⚠️ KRITISK FEIL: Fant {count} brukere med navnet '{username}'. Avbryter.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"✅ Bruker '{username}' funnet i databasen!\n")
|
||||
break
|
||||
|
||||
# Passord-verifisering
|
||||
while True:
|
||||
password = getpass.getpass("Skriv inn NYTT passord: ")
|
||||
password_confirm = getpass.getpass("Gjenta NYTT passord: ")
|
||||
|
||||
if password == password_confirm:
|
||||
if len(password) < 8:
|
||||
print("⚠️ Advarsel: Passordet bør være minst 8 tegn.")
|
||||
print(f"\n[DEBUG] Passord akseptert.")
|
||||
break
|
||||
else:
|
||||
print("❌ Passordene er ikke like. Prøv igjen.\n")
|
||||
|
||||
print("⏳ Genererer PBKDF2-hash...")
|
||||
password_hash = pbkdf2_sha256.hash(password)
|
||||
|
||||
print("⏳ Oppdaterer databasen automatisk...")
|
||||
# Utfører selve oppdateringen (sikret mot SQL-injeksjoner)
|
||||
await conn.execute("UPDATE admins SET password_hash = $1 WHERE username = $2", password_hash, username)
|
||||
|
||||
print("\n✅ PASSORD OPPDATERT VELLYKKET!")
|
||||
print("-" * 50)
|
||||
print(f"Passordet for '{username}' er nå endret i databasen.")
|
||||
print("Tofaktor (2FA) og alt annet er beholdt urørt.")
|
||||
print("-" * 50 + "\n")
|
||||
|
||||
finally:
|
||||
# Lukk tilkoblingen pent
|
||||
await conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
# Siden vi bruker asyncpg, må scriptet kjøres i en asyncio-loop
|
||||
asyncio.run(update_admin_password())
|
||||
except KeyboardInterrupt:
|
||||
print("\nAvbrutt.")
|
||||
sys.exit(0)
|
||||
72
kode_eksport_1/eksport_script_py.txt
Normal file
72
kode_eksport_1/eksport_script_py.txt
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
# --- KONFIGURASJON ---
|
||||
KILDE_MAPPE = "/opt/teeoff/"
|
||||
EKSPORT_MAPPE = "/opt/teeoff/kode_eksport_1/"
|
||||
TRE_FIL = "/opt/teeoff/fil-tre.txt"
|
||||
|
||||
# Filtyper vi vil kopiere
|
||||
FILTYPER = ['.py', '.ts', '.tsx']
|
||||
|
||||
# Mapper vi IKKE vil ha med i treet eller skanne (sparer tid og rot)
|
||||
IGNORER_MAPPER = ['.git', 'node_modules', '__pycache__', 'kode_eksport', '.next']
|
||||
|
||||
def generer_tre_og_kopier():
|
||||
kilde_sti = Path(KILDE_MAPPE)
|
||||
eksport_sti = Path(EKSPORT_MAPPE)
|
||||
|
||||
# 1. Opprett eksportmappen hvis den ikke finnes
|
||||
eksport_sti.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
tre_linjer = []
|
||||
kopierte_filer = 0
|
||||
|
||||
print("Skanner filer og genererer tre...")
|
||||
|
||||
# 2. Gå gjennom alle mapper og filer
|
||||
for root, dirs, files in os.walk(kilde_sti):
|
||||
# Fjern ignorerte mapper så vi ikke går inn i dem
|
||||
dirs[:] = [d for d in dirs if d not in IGNORER_MAPPER]
|
||||
|
||||
# Regn ut innrykk basert på hvor dypt vi er i mappestrukturen
|
||||
nivaa = root.replace(KILDE_MAPPE, '').count(os.sep)
|
||||
innrykk = ' ' * 4 * nivaa
|
||||
mappe_navn = os.path.basename(root)
|
||||
|
||||
# Legg til mappen i treet
|
||||
if mappe_navn:
|
||||
tre_linjer.append(f"{innrykk}📁 {mappe_navn}/")
|
||||
else:
|
||||
tre_linjer.append(f"📁 {kilde_sti.name}/")
|
||||
|
||||
sub_innrykk = ' ' * 4 * (nivaa + 1)
|
||||
|
||||
# 3. Gå gjennom filene i mappen
|
||||
for fil in files:
|
||||
tre_linjer.append(f"{sub_innrykk}📄 {fil}")
|
||||
|
||||
fil_sti = Path(root) / fil
|
||||
|
||||
# 4. Sjekk om filen har riktig endelse og skal kopieres
|
||||
if fil_sti.suffix in FILTYPER:
|
||||
# Lag et unikt filnavn for å unngå overskriving
|
||||
relativ_sti = fil_sti.relative_to(kilde_sti)
|
||||
nytt_navn = str(relativ_sti).replace(os.sep, '_').replace('.', '_') + '.txt'
|
||||
ny_sti = eksport_sti / nytt_navn
|
||||
|
||||
# Kopier filen
|
||||
shutil.copy2(fil_sti, ny_sti)
|
||||
kopierte_filer += 1
|
||||
|
||||
# 5. Lagre filteret til tekstfilen
|
||||
with open(TRE_FIL, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(tre_linjer))
|
||||
|
||||
print(f"\n✅ Ferdig!")
|
||||
print(f"📁 Filtre er lagret i: {TRE_FIL}")
|
||||
print(f"📝 Kopierte {kopierte_filer} kodefiler til: {EKSPORT_MAPPE}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
generer_tre_og_kopier()
|
||||
6
kode_eksport_1/frontend_next-env_d_ts.txt
Normal file
6
kode_eksport_1/frontend_next-env_d_ts.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
7
kode_eksport_1/frontend_next_config_ts.txt
Normal file
7
kode_eksport_1/frontend_next_config_ts.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
217
kode_eksport_1/frontend_src_app_FacilitySearch_tsx.txt
Normal file
217
kode_eksport_1/frontend_src_app_FacilitySearch_tsx.txt
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"use client";
|
||||
/**
|
||||
* TEE OFF SYSTEM INSTRUCTIONS - FACILITY CARDS v3.8 (BLOB SEARCH)
|
||||
* ---------------------------------------------------------------------------
|
||||
* REGEL 1: Status-badge SKAL vises øverst til venstre FOR ALLE BANER.
|
||||
* Bruk STATUS_MAP for tekst.
|
||||
* REGEL 2: DATA-PARSING: Bruk parseJson() for 'course_statuses', 'amenities' og 'nsg_data'.
|
||||
* REGEL 3: Avstand-pillen skal ha fargen #2d3319 (Mørk oliven) med hvit tekst.
|
||||
* REGEL 4: NSG (Blå 'N') og Golfamore (Oransje 'G') sirkler skal ha hvit kant (border-2).
|
||||
* REGEL 5: Bunnen: Antall Hull (grønn pill), Banetype (grå pill), og Ikon-sirkler.
|
||||
* REGEL 6: Viser dato (f.eks "05. mars 2026") rett til høyre for øverste status-pille.
|
||||
* REGEL 7: Natural Language Search bruker en "Search Blob" for å støtte delvise
|
||||
* ord og skrivefeil slik at listen ikke tømmes mens brukeren skriver.
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { STATUS_MAP, REGIONS } from "@/config/constants";
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
function getDistance(lat1: number, lon1: number, lat2: number, lon2: number) {
|
||||
try {
|
||||
const R = 6371;
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon/2) * Math.sin(dLon/2);
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
} catch (e) { return Infinity; }
|
||||
}
|
||||
|
||||
export default function FacilitySearch({ initialFacilities }: { initialFacilities: any[] }) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [userLocation, setUserLocation] = useState<{ lat: number, lng: number } | null>(null);
|
||||
const [sortMethod, setSortMethod] = useState<'dist' | 'alpha'>('alpha');
|
||||
|
||||
useEffect(() => {
|
||||
if ("geolocation" in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(p => {
|
||||
setUserLocation({ lat: p.coords.latitude, lng: p.coords.longitude });
|
||||
setSortMethod('dist');
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const processed = useMemo(() => {
|
||||
if (!Array.isArray(initialFacilities)) return [];
|
||||
|
||||
// Fyllord som fjernes slik at "Åpne baner i Oslo" blir til søkeordene ["åpne", "oslo"]
|
||||
const stopWords = new Set(["i", "på", "for", "med", "av", "og"]);
|
||||
|
||||
return initialFacilities.map(f => {
|
||||
// --- ROBUST DATA-PARSING ---
|
||||
const parseJson = (val: any, fallback: any) => {
|
||||
if (!val) return fallback;
|
||||
if (typeof val === 'object') return val;
|
||||
try { return JSON.parse(val); } catch (e) { return fallback; }
|
||||
};
|
||||
|
||||
const rawStatuses = parseJson(f.course_statuses, []);
|
||||
const sArr = Array.isArray(rawStatuses) && rawStatuses.length > 0
|
||||
? rawStatuses
|
||||
: [{ status: 'ukjent', name: 'Hovedbane' }];
|
||||
|
||||
const amenities = parseJson(f.amenities, {});
|
||||
const nsgData = parseJson(f.nsg_data, {});
|
||||
|
||||
const dist = userLocation && f.lat && f.lng ? getDistance(userLocation.lat, userLocation.lng, f.lat, f.lng) : Infinity;
|
||||
const hasNSG = nsgData && Object.keys(nsgData).length > 0;
|
||||
const hasGolfamore = f.golfamore === true;
|
||||
|
||||
// --- THE SEARCH BLOB ---
|
||||
// Vi starter med å legge navn, by og fylke i en stor, usynlig tekststreng
|
||||
let searchableText = `${f.name} ${f.city} ${f.county}`.toLowerCase();
|
||||
|
||||
// 1. Injiser statuser i tekststrengen
|
||||
const hasOpen = sArr.some((c: any) => (c.status || "") === 'aapen');
|
||||
const hasClosed = sArr.some((c: any) => (c.status || "") === 'stengt');
|
||||
const hasWinter = sArr.some((c: any) => (c.status || "") === 'aapen_med_vintergreener');
|
||||
const hasNedlagt = sArr.some((c: any) => (c.status || "") === 'nedlagt');
|
||||
|
||||
if (hasOpen) searchableText += " åpen åpne aapen";
|
||||
if (hasClosed) searchableText += " stengt stengte";
|
||||
if (hasWinter) searchableText += " vinter vintergreener vinterbane";
|
||||
if (hasNedlagt) searchableText += " nedlagt nedlagte";
|
||||
|
||||
// 2. Injiser spesial-tags
|
||||
if (hasNSG) searchableText += " nsg norsk seniorgolf";
|
||||
if (hasGolfamore) searchableText += " golfamore amore";
|
||||
|
||||
// 3. Injiser landsdel (f.eks. hvis fylket er Akershus, legger vi til "østlandet")
|
||||
const fylke = (f.county || "").toLowerCase();
|
||||
Object.entries(REGIONS).forEach(([regionName, counties]) => {
|
||||
if (counties.includes(fylke)) {
|
||||
searchableText += ` ${regionName}`;
|
||||
}
|
||||
});
|
||||
|
||||
// Splitter brukerens søk inn i enkeltord og fjerner stopWords + ordene "bane"/"baner"
|
||||
const words = searchQuery
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(w => w.length > 0 && !stopWords.has(w) && w !== "bane" && w !== "baner");
|
||||
|
||||
// Sjekker at ALLE ordene brukeren har skrevet, finnes et sted i "Search Blob"-en
|
||||
const matches = words.every(w => searchableText.includes(w));
|
||||
|
||||
return { ...f, statuses: sArr, amenities, dist, hasNSG, hasGolfamore, matches };
|
||||
})
|
||||
.filter(f => f.matches)
|
||||
.sort((a, b) => {
|
||||
if (sortMethod === 'dist' && a.dist !== b.dist) return a.dist - b.dist;
|
||||
return a.name.localeCompare(b.name, 'nb');
|
||||
});
|
||||
}, [searchQuery, initialFacilities, userLocation, sortMethod]);
|
||||
|
||||
return (
|
||||
<div className="max-w-[1400px] mx-auto px-6 py-12 relative z-40">
|
||||
<div className="text-center mb-6">
|
||||
<button onClick={() => setSortMethod(sortMethod === 'dist' ? 'alpha' : 'dist')} className="bg-white px-6 py-3 rounded-full shadow-md text-[10px] font-black text-[#8bc34a] uppercase tracking-widest border border-gray-100 transition-colors">
|
||||
{sortMethod === 'dist' ? "📍 Nærmeste baner først" : "🔠 Alfabetisk visning"} • {processed.length} baner
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input className="w-full p-8 rounded-[2.5rem] shadow-2xl mb-16 text-gray-900 border-none ring-1 ring-black/5 text-2xl outline-none focus:ring-4 focus:ring-[#8bc34a]/20 transition-all bg-white" placeholder='Søk baner, fylke, status eller spesial (f.eks "Åpne baner i Akershus" eller "NSG")...' value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
|
||||
{processed.map((f: any) => {
|
||||
const sArr = f.statuses; // Sikret via pre-prosesseringen over
|
||||
|
||||
// Formater datoen pent: "05. mars 2026"
|
||||
const lastUpdated = f.status_updated_at
|
||||
? new Date(f.status_updated_at).toLocaleDateString('nb-NO', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
: 'Ukjent';
|
||||
|
||||
return (
|
||||
<Link href={`/golfbaner/${f.slug}`} key={f.id} className="bg-white rounded-[2.5rem] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-gray-100 flex flex-col group relative">
|
||||
<div className="h-64 relative overflow-hidden bg-gray-100">
|
||||
<img src={f.image_url || "/Toppbilde-standard.jpg"} className="w-full h-full object-cover transition duration-1000 group-hover:scale-105" alt={f.name} />
|
||||
|
||||
{/* Status Badges for ALLE baner på anlegget */}
|
||||
<div className="absolute top-5 left-5 flex flex-col gap-2 z-20">
|
||||
{sArr.map((course: any, idx: number) => {
|
||||
const rawStatus = (course.status || "ukjent").toLowerCase();
|
||||
|
||||
let statusColor = "bg-gray-400";
|
||||
if (rawStatus === 'aapen') statusColor = "bg-[#8bc34a]";
|
||||
else if (rawStatus.includes('vinter') || rawStatus === 'stenger_snart') statusColor = "bg-[#ff5722]";
|
||||
else if (rawStatus === 'aapner_snart') statusColor = "bg-amber-500";
|
||||
else if (rawStatus === 'stengt') statusColor = "bg-red-600";
|
||||
else if (rawStatus === 'nedlagt') statusColor = "bg-black";
|
||||
else if (rawStatus === 'under_utvikling') statusColor = "bg-blue-500";
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-3">
|
||||
<div className={`${statusColor} text-white px-3 py-1.5 rounded-xl text-[9px] font-black uppercase shadow-lg backdrop-blur-sm bg-opacity-90 flex items-center gap-2 max-w-[200px]`}>
|
||||
{sArr.length > 1 && (
|
||||
<span className="opacity-80 border-r border-white/30 pr-2 truncate max-w-[90px]" title={course.name}>
|
||||
{course.name}
|
||||
</span>
|
||||
)}
|
||||
<span>{STATUS_MAP[rawStatus] || rawStatus}</span>
|
||||
</div>
|
||||
|
||||
{/* Dato-pille ved siden av den øverste status-pillen */}
|
||||
{idx === 0 && (
|
||||
<div className="bg-white/30 backdrop-blur-sm text-[#11280f]/90 px-3 py-1.5 rounded-xl text-[11px] font-bold shadow-lg">
|
||||
{lastUpdated}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Avstandspille */}
|
||||
{f.dist !== Infinity && (
|
||||
<div className="absolute bottom-5 right-5 bg-[#2d3319] text-white px-4 py-2 rounded-2xl text-[10px] font-black shadow-lg z-20">
|
||||
{Math.round(f.dist)} km unna
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-8 flex flex-col flex-grow">
|
||||
<h3 className="font-black text-3xl text-[#11280f] mb-1 group-hover:text-[#8bc34a] transition-colors leading-tight">{f.name}</h3>
|
||||
<p className="text-gray-400 text-[11px] font-bold uppercase tracking-widest mb-8">{f.city} • {f.county}</p>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Hull-pille */}
|
||||
<span className="bg-[#f1f7ed] text-[#8bc34a] px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest">
|
||||
{f.amenities?.antall_hull || '--'} HULL
|
||||
</span>
|
||||
{/* Banetype-pille */}
|
||||
<span className="bg-gray-50 text-gray-400 px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-gray-100">
|
||||
{f.banetype || 'SKOGSBANE'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sirkel-ikoner (NSG / Golfamore) */}
|
||||
<div className="flex gap-2">
|
||||
{f.hasNSG && (
|
||||
<div className="w-9 h-9 bg-blue-600 text-white rounded-full flex items-center justify-center font-black text-sm shadow-lg border-2 border-white translate-y-1">N</div>
|
||||
)}
|
||||
{f.hasGolfamore && (
|
||||
<div className="w-9 h-9 bg-[#ff5722] text-white rounded-full flex items-center justify-center font-black text-sm shadow-lg border-2 border-white translate-y-1">G</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
kode_eksport_1/frontend_src_app_HeroSlider_tsx.txt
Normal file
130
kode_eksport_1/frontend_src_app_HeroSlider_tsx.txt
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"use client";
|
||||
/**
|
||||
* TEE OFF SYSTEM INSTRUCTIONS - HERO SLIDER v2.4
|
||||
* ---------------------------------------------------------------------------
|
||||
* REGEL 1: Kun baner med status 'aapen', 'aapner_snart', 'stenger_snart'
|
||||
* eller 'aapen_med_vintergreener' skal prioriteres.
|
||||
* REGEL 2: Baner med status 'nedlagt' eller 'under_utvikling' skal ALDRI vises.
|
||||
* REGEL 3: Baner med generiske bilder (inneholder 'standard') skal ALDRI vises.
|
||||
* REGEL 4: MANUELL EKSKLUDERING: Slugs i MANUAL_EXCLUSION_LIST skal aldri vises.
|
||||
* REGEL 5: Slideren skal vise nøyaktig 5 baner.
|
||||
* REGEL 6: Maks høyde er låst til 624px. Ingen badges.
|
||||
* REGEL 7: Typografi: Nedjustert fontstørrelse (4xl mobil / 7xl desktop) for eleganse.
|
||||
* REGEL 8: Utvalget skal være stabilt i én time (Hourly Seed) før det refreshes.
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
const MANUAL_EXCLUSION_LIST = [
|
||||
'alsten-golfklubb', 'askim-golfklubb', 'bergen-golfklubb', 'eidskog-golfklubb',
|
||||
'eiker-golfklubb', 'floro-golfklubb', 'garder-golfklubb', 'hafjell-golfklubb',
|
||||
'halden-golfklubb', 'haugesund-golfklubb', 'hinnoy-golfklubb', 'hitra-golfklubb',
|
||||
'hurum-golfklubb', 'imjelt-pitch-putt', 'karmoy-golfklubb', 'kristiansund-og-omegn-golfklubb',
|
||||
'lommedalen-golfklubb', 'laerdal-golfklubb', 'moa-golfsenter', 'modum-golfklubb',
|
||||
'nes-golfklubb-09', 'nittedal-golfklubb', 'selbu-golfklubb', 'stryn-golfklubb',
|
||||
'sunnfjord-golfklubb', 'tysnes-golfklubb', 'vanylven-golfklubb', 'vesteralen-golfklubb',
|
||||
'vestlia-golf'
|
||||
];
|
||||
|
||||
export default function HeroSlider({ facilities }: { facilities: any[] }) {
|
||||
const [currentIndex, setCurrentSlide] = useState(0);
|
||||
|
||||
const sliderItems = useMemo(() => {
|
||||
if (!Array.isArray(facilities) || facilities.length === 0) return [];
|
||||
|
||||
const preferredStatuses = ['aapen', 'aapner_snart', 'stenger_snart', 'aapen_med_vintergreener'];
|
||||
const forbiddenStatuses = ['nedlagt', 'under_utvikling'];
|
||||
|
||||
const validCandidates = facilities.filter(f => {
|
||||
if (MANUAL_EXCLUSION_LIST.includes(f.slug)) return false;
|
||||
const img = f.image_url || "";
|
||||
if (!img || img.toLowerCase().includes('standard') || img.length < 5) return false;
|
||||
|
||||
const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : [];
|
||||
const isForbidden = statuses.some((s: any) =>
|
||||
forbiddenStatuses.includes((s.status || "").toLowerCase())
|
||||
);
|
||||
return !isForbidden;
|
||||
});
|
||||
|
||||
const highPriority = validCandidates.filter(f => {
|
||||
const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : [];
|
||||
return statuses.some((s: any) => preferredStatuses.includes((s.status || "").toLowerCase()));
|
||||
});
|
||||
|
||||
const fallbackPool = validCandidates.filter(f => !highPriority.includes(f));
|
||||
const now = new Date();
|
||||
const hourlySeed = parseInt(`${now.getFullYear()}${now.getMonth()}${now.getDate()}${now.getHours()}`);
|
||||
|
||||
const seededShuffle = (arr: any[]) => {
|
||||
return [...arr].sort((a, b) => ((a.id * hourlySeed) % 100) - ((b.id * hourlySeed) % 100));
|
||||
};
|
||||
|
||||
let selection = seededShuffle(highPriority);
|
||||
if (selection.length < 5) {
|
||||
selection = [...selection, ...seededShuffle(fallbackPool)].slice(0, 5);
|
||||
} else {
|
||||
selection = selection.slice(0, 5);
|
||||
}
|
||||
return selection;
|
||||
}, [facilities]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sliderItems.length <= 1) return;
|
||||
const interval = setInterval(() => setCurrentSlide((p) => (p + 1) % sliderItems.length), 8000);
|
||||
return () => clearInterval(interval);
|
||||
}, [sliderItems.length]);
|
||||
|
||||
if (sliderItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="relative h-[65vh] max-h-[624px] w-full overflow-hidden bg-[#11280f]">
|
||||
{sliderItems.map((f, i) => (
|
||||
<div
|
||||
key={f.id}
|
||||
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
|
||||
i === currentIndex ? 'opacity-100 z-10' : 'opacity-0 z-0'
|
||||
}`}
|
||||
>
|
||||
<Link href={`/golfbaner/${f.slug}`} className="block h-full relative group">
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#11280f] via-[#11280f]/40 to-black/10 z-10" />
|
||||
|
||||
<img
|
||||
src={f.image_url}
|
||||
alt={f.name}
|
||||
className="w-full h-full object-cover transition-transform duration-[10s] scale-100 group-hover:scale-105"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 z-20 flex items-center">
|
||||
<div className="max-w-[1400px] mx-auto px-6 w-full">
|
||||
<div className="max-w-4xl animate-in fade-in slide-in-from-bottom-8 duration-1000">
|
||||
{/* FONT NEDJUSTERT FRA text-6xl md:text-9xl TIL text-4xl md:text-7xl */}
|
||||
<h2 className="text-4xl md:text-7xl font-black text-white tracking-tighter drop-shadow-2xl leading-[0.9] mb-4">
|
||||
{f.name}
|
||||
</h2>
|
||||
<p className="text-white/90 text-sm md:text-xl font-bold uppercase tracking-[0.4em] drop-shadow-md">
|
||||
{f.county} <span className="text-[#8bc34a] mx-2">•</span> {f.city}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-30 flex gap-4">
|
||||
{sliderItems.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrentSlide(i)}
|
||||
className={`h-1 transition-all duration-500 rounded-full ${
|
||||
i === currentIndex ? 'w-16 bg-[#8bc34a]' : 'w-4 bg-white/20'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
103
kode_eksport_1/frontend_src_app_admin_login_page_tsx.txt
Normal file
103
kode_eksport_1/frontend_src_app_admin_login_page_tsx.txt
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
/**
|
||||
* TEE OFF ADMIN LOGIN v1.2
|
||||
* ---------------------------------------------------------------------------
|
||||
* PLASSERING: frontend/src/app/admin/login/page.tsx
|
||||
* FUNKSJON: Offentlig tilgjengelig innlogging for administratorer.
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { API_URL } from "@/config/constants";
|
||||
|
||||
export default function AdminLogin() {
|
||||
const [step, setStep] = useState(1);
|
||||
const [formData, setFormData] = useState({ username: '', password: '', code: '' });
|
||||
const [tempToken, setTempToken] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: formData.username, password: formData.password })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setTempToken(data.temp_token);
|
||||
setStep(2);
|
||||
} else {
|
||||
setError(data.detail || 'Ugyldig pålogging');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("🔥 DEN EKTE FEILEN ER:", err);
|
||||
setError('Systemfeil: Kunne ikke koble til API-et');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify2FA = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/auth/verify-2fa`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ temp_token: tempToken, code: formData.code })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// VIKTIG: Etter suksess sender vi brukeren til selve dashbordet
|
||||
router.push('/admin');
|
||||
router.refresh();
|
||||
} else {
|
||||
setError('Ugyldig 2FA-kode');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Tilkoblingsfeil ved 2FA-verifisering');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#f1f7ed] p-6 font-sans">
|
||||
<div className="max-w-md w-full bg-white rounded-[3rem] shadow-2xl p-12 border border-white">
|
||||
<div className="flex justify-center mb-10">
|
||||
<img src="/TeeOff-logo-Retina-1.png" className="h-10 w-auto" alt="TeeOff" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-black text-center uppercase tracking-tighter mb-8 text-[#11280f]">
|
||||
{step === 1 ? "Admin Portalen" : "Tofaktor Sjekk"}
|
||||
</h2>
|
||||
<form onSubmit={step === 1 ? handleLogin : handleVerify2FA} className="space-y-4">
|
||||
{step === 1 ? (
|
||||
<>
|
||||
<input type="text" placeholder="Brukernavn eller E-post" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData(prevState => ({...prevState, username: e.target.value}))} required />
|
||||
<input type="password" placeholder="Passord" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData(prevState => ({...prevState, password: e.target.value}))} required />
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] text-gray-400 font-black uppercase text-center tracking-widest">Tast inn 6 siffer fra appen din</p>
|
||||
<input type="text" placeholder="000 000" className="w-full p-6 text-center text-4xl tracking-[0.3em] font-black bg-gray-50 rounded-3xl border-none ring-2 ring-[#ff5722]/20 outline-none focus:ring-[#ff5722] transition-all text-[#ff5722]" onChange={e => setFormData({...formData, code: e.target.value})} autoFocus required />
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="bg-red-50 p-4 rounded-xl text-red-600 text-[10px] font-black uppercase tracking-widest text-center border border-red-100">⚠️ {error}</div>}
|
||||
<button type="submit" disabled={isLoading} className={`w-full p-6 rounded-2xl font-black uppercase text-xs tracking-widest text-white transition-all shadow-xl ${step === 1 ? 'bg-[#11280f]' : 'bg-[#ff5722]'}`}>
|
||||
{isLoading ? "Venter..." : (step === 1 ? "Fortsett" : "Logg inn")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
kode_eksport_1/frontend_src_app_admin_medlemskap_page_tsx.txt
Normal file
179
kode_eksport_1/frontend_src_app_admin_medlemskap_page_tsx.txt
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"use client";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { API_URL } from "@/config/constants";
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function MembershipWasher() {
|
||||
const [drafts, setDrafts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchDrafts = () => {
|
||||
setLoading(true);
|
||||
fetch(`${API_URL}/admin/membership/drafts`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
// Konverter innkommende drafts til editerbare felter lokalt
|
||||
const editableDrafts = data.map((f: any) => ({
|
||||
...f,
|
||||
edit_standard_navn: f.membership_draft?.foreslatt_standard_navn || f.navn_standard_medlemskap || "",
|
||||
edit_standard_pris: f.membership_draft?.foreslatt_standard_pris || f.standard_medlemskap || "",
|
||||
edit_standard_kommentar: f.membership_draft?.foreslatt_standard_kommentar || "",
|
||||
edit_rimeligste_navn: f.membership_draft?.foreslatt_rimeligste_navn || f.navn_rimeligste_alternativ || "",
|
||||
edit_rimeligste_pris: f.membership_draft?.foreslatt_rimeligste_pris || f.rimeligste_alternativ || "",
|
||||
}));
|
||||
setDrafts(editableDrafts);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDrafts();
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = (checked: boolean) => {
|
||||
if (checked) setSelectedIds(drafts.map(d => d.id));
|
||||
else setSelectedIds([]);
|
||||
};
|
||||
|
||||
const toggleOne = (id: number) => {
|
||||
if (selectedIds.includes(id)) setSelectedIds(selectedIds.filter(i => i !== id));
|
||||
else setSelectedIds([...selectedIds, id]);
|
||||
};
|
||||
|
||||
const updateDraftField = (id: number, field: string, value: any) => {
|
||||
setDrafts(drafts.map(d => d.id === id ? { ...d, [field]: value } : d));
|
||||
};
|
||||
|
||||
const handleApprove = async () => {
|
||||
const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({
|
||||
facility_id: d.id,
|
||||
navn_standard_medlemskap: d.edit_standard_navn,
|
||||
standard_medlemskap: Number(d.edit_standard_pris) || null,
|
||||
standard_medlemskap_kommentarer: d.edit_standard_kommentar,
|
||||
navn_rimeligste_alternativ: d.edit_rimeligste_navn,
|
||||
rimeligste_alternativ: Number(d.edit_rimeligste_pris) || null,
|
||||
}));
|
||||
|
||||
if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne.");
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/admin/membership/approve-bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ approvals: toApprove })
|
||||
});
|
||||
if (res.ok) {
|
||||
alert(`${toApprove.length} anlegg ble oppdatert og lagret til live!`);
|
||||
setSelectedIds([]);
|
||||
fetchDrafts(); // Oppdaterer listen (fjerner de godkjente)
|
||||
} else {
|
||||
alert("Noe gikk galt under lagring.");
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Nettverksfeil");
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-20 text-center font-black animate-pulse">Laster utkast...</div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f1f7ed] p-8 text-[#11280f]">
|
||||
<div className="max-w-[1400px] mx-auto">
|
||||
<div className="flex justify-between items-end mb-10 border-b border-gray-200 pb-6">
|
||||
<div>
|
||||
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
||||
<h1 className="text-4xl font-black">Medlemskaps-Vaskeriet</h1>
|
||||
<p className="text-sm text-gray-600 mt-2">Gå gjennom AI-ens forslag, juster hvis nødvendig, og godkjenn for å publisere. Oppdatert-dato settes automatisk i dag.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
disabled={saving || selectedIds.length === 0}
|
||||
className="bg-[#8bc34a] text-white px-8 py-4 rounded-xl font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50 disabled:scale-100"
|
||||
>
|
||||
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{drafts.length === 0 ? (
|
||||
<div className="bg-white p-20 rounded-[2rem] text-center shadow-sm">
|
||||
<span className="text-6xl mb-4 block">🧹</span>
|
||||
<h2 className="text-2xl font-black text-gray-400">Alt er rent og pent!</h2>
|
||||
<p className="text-gray-500">Ingen ventende forslag fra AI-skraperen akkurat nå.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-4 rounded-2xl shadow-sm flex items-center gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-5 h-5 accent-[#8bc34a] ml-2"
|
||||
checked={selectedIds.length === drafts.length}
|
||||
onChange={(e) => toggleSelectAll(e.target.checked)}
|
||||
/>
|
||||
<span className="font-black uppercase tracking-widest text-xs text-gray-500">Velg Alle</span>
|
||||
</div>
|
||||
|
||||
{drafts.map(draft => (
|
||||
<div key={draft.id} className={`bg-white p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/5' : 'border-transparent'}`}>
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-6 h-6 accent-[#8bc34a] cursor-pointer"
|
||||
checked={selectedIds.includes(draft.id)}
|
||||
onChange={() => toggleOne(draft.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow space-y-4">
|
||||
|
||||
{/* OPPDATERT: Navn + ID Badge */}
|
||||
<div className="flex justify-between items-center border-b pb-4">
|
||||
<h3 className="text-2xl font-black flex items-center gap-3">
|
||||
{draft.name}
|
||||
<span className="text-xs font-mono font-bold bg-gray-100 text-gray-400 px-2 py-1 rounded-md">ID: {draft.id}</span>
|
||||
</h3>
|
||||
<a href={draft.medlemskap_url} target="_blank" className="text-xs font-bold text-blue-600 hover:underline bg-blue-50 px-4 py-2 rounded-lg">Sjekk Klubbens Nettside ↗</a>
|
||||
</div>
|
||||
|
||||
{draft.membership_draft?.ai_begrunnelse && (
|
||||
<div className="bg-blue-50/50 p-4 rounded-xl text-sm italic text-blue-900 border border-blue-100">
|
||||
<strong>🤖 AI Begrunnelse:</strong> {draft.membership_draft.ai_begrunnelse}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-2">
|
||||
{/* Standard */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-black uppercase tracking-widest text-gray-400">Standard Medlemskap (Ubegrenset)</h4>
|
||||
<div className="flex gap-2">
|
||||
<input className="w-2/3 p-3 rounded-xl border border-gray-200 font-bold focus:border-[#8bc34a] outline-none" value={draft.edit_standard_navn} onChange={e => updateDraftField(draft.id, 'edit_standard_navn', e.target.value)} placeholder="Navn (eks. Hovedmedlem)" />
|
||||
<input className="w-1/3 p-3 rounded-xl border border-gray-200 font-bold text-right focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_standard_pris} onChange={e => updateDraftField(draft.id, 'edit_standard_pris', e.target.value)} placeholder="Pris" />
|
||||
</div>
|
||||
<input className="w-full p-3 rounded-xl border border-gray-200 text-sm focus:border-[#8bc34a] outline-none" value={draft.edit_standard_kommentar} onChange={e => updateDraftField(draft.id, 'edit_standard_kommentar', e.target.value)} placeholder="Kommentar (F.eks: Inkluderer ikke treningsavgift)" />
|
||||
<p className="text-[10px] text-gray-400">Gammel pris var: {draft.standard_medlemskap ? `kr ${draft.standard_medlemskap} (${draft.navn_standard_medlemskap})` : 'Ikke registrert'}</p>
|
||||
</div>
|
||||
|
||||
{/* Rimeligste */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-black uppercase tracking-widest text-gray-400">Rimeligste (Betaler Greenfee)</h4>
|
||||
<div className="flex gap-2">
|
||||
<input className="w-2/3 p-3 rounded-xl border border-gray-200 font-bold focus:border-[#8bc34a] outline-none" value={draft.edit_rimeligste_navn} onChange={e => updateDraftField(draft.id, 'edit_rimeligste_navn', e.target.value)} placeholder="Navn (eks. Greenfeemedlem)" />
|
||||
<input className="w-1/3 p-3 rounded-xl border border-gray-200 font-bold text-right focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_rimeligste_pris} onChange={e => updateDraftField(draft.id, 'edit_rimeligste_pris', e.target.value)} placeholder="Pris" />
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 mt-2">Gammel pris var: {draft.rimeligste_alternativ ? `kr ${draft.rimeligste_alternativ} (${draft.navn_rimeligste_alternativ})` : 'Ikke registrert'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
452
kode_eksport_1/frontend_src_app_admin_page_tsx.txt
Normal file
452
kode_eksport_1/frontend_src_app_admin_page_tsx.txt
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
"use client";
|
||||
/**
|
||||
* TEE OFF ADMIN DASHBOARD v1.9 - RESPONSIVT MED AI-HVISKER, KILL SWITCH, FILTER & FULL EDIT
|
||||
* ---------------------------------------------------------------------------
|
||||
* PLASSERING: frontend/src/app/admin/page.tsx
|
||||
* FUNKSJON: Live-oppdatering, massevalg, redigering, meny og smart-filtrering.
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { API_URL } from "@/config/constants";
|
||||
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [facilities, setFacilities] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedFacilities, setSelectedFacilities] = useState<number[]>([]);
|
||||
const [isScraping, setIsScraping] = useState(false);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [editingFacility, setEditingFacility] = useState<any | null>(null);
|
||||
|
||||
// NYTT: State for å holde styr på hvilket filter som er aktivt
|
||||
const [statusFilter, setStatusFilter] = useState('alle');
|
||||
|
||||
const [editForm, setEditForm] = useState({
|
||||
scrape_status_url: '',
|
||||
scrape_status_selector: '',
|
||||
scrape_method: '',
|
||||
ai_instruction: '',
|
||||
courses: [] as any[]
|
||||
});
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const fetchFacilities = () => {
|
||||
fetch(`${API_URL}/facilities`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setFacilities(Array.isArray(data) ? data : []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFacilities();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (isScraping) {
|
||||
interval = setInterval(() => {
|
||||
fetchFacilities();
|
||||
}, 10000);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [isScraping]);
|
||||
|
||||
// Filtreringslogikken som kjører automatisk når facilities eller filteret endres
|
||||
const filteredFacilities = useMemo(() => {
|
||||
if (statusFilter === 'alle') return facilities;
|
||||
|
||||
return facilities.map(facility => {
|
||||
if (!facility.course_statuses) return facility;
|
||||
|
||||
// Filtrer banene innad i hvert anlegg
|
||||
const filteredCourses = facility.course_statuses.filter((cs: any) => {
|
||||
const s = cs.status || 'ukjent';
|
||||
|
||||
if (statusFilter === 'aapne') {
|
||||
return s === 'aapen';
|
||||
}
|
||||
if (statusFilter === 'ikke_stengt') {
|
||||
return ['aapen', 'aapen_med_vintergreener', 'aapner_snart'].includes(s);
|
||||
}
|
||||
if (statusFilter === 'stengt') {
|
||||
return s === 'stengt' || s === 'nedlagt';
|
||||
}
|
||||
if (statusFilter === 'ukjent_feil') {
|
||||
return s === 'ukjent' || s === 'NOT_FOUND';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Returner anlegget kun med de banene som matcher
|
||||
return { ...facility, course_statuses: filteredCourses };
|
||||
|
||||
}).filter(facility => facility.course_statuses && facility.course_statuses.length > 0);
|
||||
|
||||
}, [facilities, statusFilter]);
|
||||
|
||||
// "Velg alle" gjelder kun de anleggene som er synlige i filteret
|
||||
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedFacilities(filteredFacilities.map(f => f.id));
|
||||
} else {
|
||||
setSelectedFacilities([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectOne = (id: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedFacilities([...selectedFacilities, id]);
|
||||
} else {
|
||||
setSelectedFacilities(selectedFacilities.filter(facilityId => facilityId !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunScrapers = async () => {
|
||||
if (isScraping) {
|
||||
setIsScraping(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsScraping(true);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/run-scraper`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ facility_ids: selectedFacilities })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Kunne ikke starte skraping");
|
||||
|
||||
const timeoutMs = Math.max(selectedFacilities.length * 40 * 1000, 60000);
|
||||
setSelectedFacilities([]);
|
||||
setTimeout(() => setIsScraping(false), timeoutMs);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Feil ved start av skraperen.");
|
||||
setIsScraping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditModal = (facility: any) => {
|
||||
setEditingFacility(facility);
|
||||
setEditForm({
|
||||
scrape_status_url: facility.scrape_status_url || '',
|
||||
scrape_status_selector: facility.scrape_status_selector || '',
|
||||
scrape_method: facility.scrape_method || 'css_selector',
|
||||
ai_instruction: facility.ai_instruction || '',
|
||||
courses: facility.course_statuses ? facility.course_statuses.map((c: any) => ({id: c.id, name: c.name, status: c.status})) : []
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/facilities/${editingFacility.id}/scrape-settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(editForm)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Feil ved lagring");
|
||||
|
||||
setEditingFacility(null);
|
||||
fetchFacilities();
|
||||
} catch (error) {
|
||||
alert("Kunne ikke lagre endringene.");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-20 text-center font-black animate-pulse">LASTER DASHBORD...</div>;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#f1f7ed] font-sans relative overflow-hidden">
|
||||
|
||||
{/* REDIGER-MODAL FOR SKRAPING */}
|
||||
{editingFacility && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<div className="bg-[#11280f] text-white p-6 shrink-0">
|
||||
<h3 className="text-xl font-black uppercase tracking-widest">Skrape-innstillinger</h3>
|
||||
<p className="text-sm text-[#7ca982]">{editingFacility.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-6 overflow-y-auto flex-grow">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Scrape URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.scrape_status_url}
|
||||
onChange={(e) => setEditForm({...editForm, scrape_status_url: e.target.value})}
|
||||
className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors"
|
||||
placeholder="f.eks. https://golfklubb.no/banestatus"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Skrapemetode</label>
|
||||
<select
|
||||
value={editForm.scrape_method}
|
||||
onChange={(e) => setEditForm({...editForm, scrape_method: e.target.value})}
|
||||
className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors"
|
||||
>
|
||||
<option value="css_selector">Standard (CSS)</option>
|
||||
<option value="llm_parse">✨ Gemini AI (LLM)</option>
|
||||
<option value="iframe_golfbox">Golfbox iframe</option>
|
||||
<option value="click_then_css">Auto-klikk + CSS</option>
|
||||
<option value="manual">🚨 Manuell (Ikke skrap)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{editForm.scrape_method === 'llm_parse' && (
|
||||
<div className="animate-fade-in">
|
||||
<label className="block text-xs font-bold text-[#8bc34a] uppercase tracking-widest mb-2">✨ AI-Hviskeren (Instruks til Gemini)</label>
|
||||
<textarea
|
||||
value={editForm.ai_instruction || ''}
|
||||
onChange={(e) => setEditForm({...editForm, ai_instruction: e.target.value})}
|
||||
className="w-full border-2 border-[#8bc34a]/30 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors"
|
||||
placeholder="F.eks: Ignorer info om korthullsbanen. Banen er åpen."
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-[10px] text-gray-400 mt-1">Hjelper Gemini hvis nettsiden er forvirrende.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editForm.scrape_method === 'manual' && (
|
||||
<div className="bg-red-50 border border-red-100 rounded-xl p-4 animate-fade-in">
|
||||
<label className="block text-xs font-black text-red-500 uppercase tracking-widest mb-4">🚨 Sett Status Manuelt</label>
|
||||
<div className="space-y-4">
|
||||
{editForm.courses.map((course: any, idx: number) => (
|
||||
<div key={course.id} className="flex justify-between items-center bg-white p-3 rounded-lg shadow-sm">
|
||||
<span className="text-xs font-bold text-gray-700 uppercase tracking-widest truncate mr-2" title={course.name}>{course.name}</span>
|
||||
<select
|
||||
value={course.status || 'ukjent'}
|
||||
onChange={(e) => {
|
||||
const newCourses = [...editForm.courses];
|
||||
newCourses[idx].status = e.target.value;
|
||||
setEditForm({...editForm, courses: newCourses});
|
||||
}}
|
||||
className="border border-gray-200 rounded-lg p-2 text-xs font-bold focus:outline-none focus:border-red-400 shrink-0"
|
||||
>
|
||||
<option value="aapen">🟢 Åpen</option>
|
||||
<option value="aapen_med_vintergreener">🟡 Vintergreener</option>
|
||||
<option value="aapner_snart">🟡 Åpner Snart</option>
|
||||
<option value="stengt">🔴 Stengt</option>
|
||||
<option value="stenger_snart">🔴 Stenger Snart</option>
|
||||
<option value="under_utvikling">🔨 Under Utvikling</option>
|
||||
<option value="nedlagt">⚫ Nedlagt</option>
|
||||
<option value="ukjent">⚪ Ukjent</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(editForm.scrape_method === 'css_selector' || editForm.scrape_method === 'click_then_css' || editForm.scrape_method === 'iframe_golfbox') && (
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">CSS Selector</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.scrape_status_selector}
|
||||
onChange={(e) => setEditForm({...editForm, scrape_status_selector: e.target.value})}
|
||||
className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors font-mono"
|
||||
placeholder="f.eks. .status-text"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-6 flex justify-end gap-4 shrink-0">
|
||||
<button onClick={() => setEditingFacility(null)} className="px-6 py-3 rounded-xl text-xs font-bold uppercase tracking-widest text-gray-500 hover:bg-gray-200 transition-colors">Avbryt</button>
|
||||
<button onClick={handleSaveEdit} disabled={isSaving} className="bg-[#8bc34a] text-white px-6 py-3 rounded-xl text-xs font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50">
|
||||
{isSaving ? 'Lagrer...' : 'Lagre endringer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SIDEBAR */}
|
||||
<aside className={`bg-[#11280f] text-white flex flex-col transition-all duration-300 shrink-0 ${isSidebarCollapsed ? 'w-16 p-4' : 'w-64 p-8'} hidden md:flex`}>
|
||||
<div className={`flex items-center mb-10 ${isSidebarCollapsed ? 'justify-center' : 'justify-between'}`}>
|
||||
{!isSidebarCollapsed && <h1 className="text-2xl font-black uppercase tracking-tighter">TeeOff</h1>}
|
||||
<button onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)} className="text-2xl hover:text-[#8bc34a] transition-colors" title="Skjul/Vis meny">
|
||||
☰
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow">
|
||||
<div className={`text-white border-l-4 border-[#8bc34a] py-1 ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4'}`} title="Scraping Monitor">
|
||||
{isSidebarCollapsed ? 'SM' : 'Scraping Monitor'}
|
||||
</div>
|
||||
<Link href="/admin/medlemskap" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Medlemskap">
|
||||
{isSidebarCollapsed ? 'M' : 'Medlemskap'}
|
||||
</Link>
|
||||
<div className={`hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Bildegalleri">
|
||||
{isSidebarCollapsed ? 'B' : 'Bildegalleri'}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className={`mt-auto pt-8 border-t border-white/10 ${isSidebarCollapsed ? 'text-center' : ''}`}>
|
||||
<button onClick={() => window.location.href='/'} className={`text-[10px] font-black uppercase tracking-widest text-red-400 hover:text-red-300 ${isSidebarCollapsed ? 'writing-vertical' : ''}`} title="Logg ut">
|
||||
{isSidebarCollapsed ? 'UT' : 'Logg ut'}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* HOVEDINNHOLD */}
|
||||
<main className="flex-1 min-w-0 p-4 md:p-8 lg:p-10 h-screen overflow-y-auto">
|
||||
|
||||
<div className="md:hidden flex justify-between items-center mb-6 bg-[#11280f] text-white p-4 rounded-2xl">
|
||||
<h1 className="text-xl font-black uppercase tracking-tighter">TeeOff Admin</h1>
|
||||
<span className="text-xs font-bold text-[#8bc34a]">MONITOR</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-[2rem] shadow-2xl p-6 lg:p-10 border border-white">
|
||||
<header className="flex flex-col xl:flex-row justify-between items-start xl:items-center gap-6 mb-6">
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-4xl font-black tracking-tighter text-[#11280f] mb-2">Scraping Monitor</h2>
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Sjekker status på {filteredFacilities.length} anlegg (av {facilities.length})</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRunScrapers}
|
||||
disabled={selectedFacilities.length === 0 && !isScraping}
|
||||
className={`text-white px-6 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl transition-all whitespace-nowrap
|
||||
${isScraping
|
||||
? 'bg-yellow-500 animate-pulse cursor-pointer hover:bg-yellow-600'
|
||||
: 'bg-[#8bc34a] hover:scale-105 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isScraping ? '🤖 Skraper... Klikk for å avslutte' : `Kjør valgte skrapere (${selectedFacilities.length})`}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 bg-gray-50 p-4 rounded-2xl border border-gray-100 mb-8">
|
||||
<label htmlFor="statusFilter" className="text-xs font-bold text-gray-500 uppercase tracking-widest">Filtrer på status:</label>
|
||||
<select
|
||||
id="statusFilter"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="border-2 border-gray-200 rounded-xl p-2 text-sm font-bold text-[#11280f] focus:border-[#8bc34a] focus:outline-none transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="alle">Vis alle anlegg</option>
|
||||
<option value="aapne">🟢 Kun åpne baner</option>
|
||||
<option value="ikke_stengt">🟡 Ikke stengt (Åpne/Vintergreen/Snart)</option>
|
||||
<option value="stengt">🔴 Kun stengte baner</option>
|
||||
<option value="ukjent_feil">⚪ Ukjent / Skrapefeil (Krever tilsyn)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto pb-4">
|
||||
<table className="w-full text-left border-collapse min-w-[800px]">
|
||||
<thead>
|
||||
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-300 border-b border-gray-50">
|
||||
<th className="pb-4 pl-4 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 cursor-pointer accent-[#8bc34a]"
|
||||
checked={selectedFacilities.length === filteredFacilities.length && filteredFacilities.length > 0}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="pb-4 w-12 text-center">ID</th>
|
||||
<th className="pb-4 pr-6">Anlegg</th>
|
||||
<th className="pb-4">Konfigurasjon</th>
|
||||
<th className="pb-4">Metode</th>
|
||||
<th className="pb-4">Siste Sjekk</th>
|
||||
<th className="pb-4">Banestatus</th>
|
||||
<th className="pb-4 text-right pr-4">Handling</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm font-bold text-[#11280f]">
|
||||
{filteredFacilities.map((f: any) => (
|
||||
<tr key={f.id} className="border-b border-gray-50 group hover:bg-gray-50/50 transition-colors">
|
||||
<td className="py-6 pl-4 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 cursor-pointer accent-[#8bc34a]"
|
||||
checked={selectedFacilities.includes(f.id)}
|
||||
onChange={(e) => handleSelectOne(f.id, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className="py-6 text-center text-xs font-mono text-gray-400">
|
||||
#{f.id}
|
||||
</td>
|
||||
|
||||
<td className="py-6 pr-6">
|
||||
<div className="font-black text-base md:text-lg whitespace-nowrap">{f.name}</div>
|
||||
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
|
||||
</td>
|
||||
<td className="py-6 pr-4">
|
||||
<div className="text-[10px] text-blue-600 truncate max-w-[150px] mb-1">
|
||||
{f.scrape_status_url ? f.scrape_status_url : <span className="text-red-400 italic">Mangler URL</span>}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-gray-300 truncate max-w-[150px]">{f.scrape_status_selector}</div>
|
||||
</td>
|
||||
<td className="py-6 pr-4">
|
||||
<ScrapeMethodSelect facility={f} />
|
||||
</td>
|
||||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">
|
||||
{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
|
||||
</td>
|
||||
<td className="py-6 pr-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
{f.course_statuses && f.course_statuses.map((cs: any, idx: number) => {
|
||||
let badgeColor = "bg-gray-100 text-gray-500";
|
||||
if (cs.status === "aapen") badgeColor = "bg-green-100 text-green-700";
|
||||
if (cs.status === "stengt" || cs.status === "nedlagt") badgeColor = "bg-red-100 text-red-700";
|
||||
if (cs.status === "aapen_med_vintergreener" || cs.status === "aapner_snart") badgeColor = "bg-yellow-100 text-yellow-700";
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<span className="text-[9px] uppercase tracking-widest text-gray-400 truncate max-w-[80px]" title={cs.name}>
|
||||
{cs.name}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-md text-[9px] font-black uppercase tracking-widest whitespace-nowrap ${badgeColor}`}>
|
||||
{cs.status || 'UKJENT'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="py-6 text-right pr-4">
|
||||
<div className="flex flex-col gap-2 items-end">
|
||||
<button
|
||||
onClick={() => openEditModal(f)}
|
||||
className="bg-gray-100 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-[#11280f] hover:bg-gray-200 transition-all whitespace-nowrap"
|
||||
>
|
||||
Skraper
|
||||
</button>
|
||||
<Link
|
||||
href={`/admin/rediger/${f.slug}`}
|
||||
className="bg-[#11280f] px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-white hover:bg-[#8bc34a] transition-all whitespace-nowrap text-center"
|
||||
>
|
||||
Rediger alt
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,616 @@
|
|||
"use client";
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
// KOMPONENT 1: MultiSelect for samarbeidende klubber
|
||||
const MultiSelect = ({ label, options, selected, onChange }: { label: string, options: any[], selected: string[], onChange: (s: string[]) => void }) => {
|
||||
const toggle = (val: string) => {
|
||||
if (selected.includes(val)) onChange(selected.filter(x => x !== val));
|
||||
else onChange([...selected, val]);
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-2 mb-8 col-span-1 md:col-span-2">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">{label}</label>
|
||||
<div className="p-4 rounded-2xl border-2 border-gray-300 bg-white shadow-sm max-h-64 overflow-y-auto grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{options.map(opt => (
|
||||
<label key={opt.slug} className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded-lg cursor-pointer border border-transparent hover:border-gray-200 transition-all">
|
||||
<input type="checkbox" checked={selected.includes(opt.slug)} onChange={() => toggle(opt.slug)} className="w-5 h-5 accent-[#8bc34a]" />
|
||||
<span className="text-sm font-bold text-gray-700">{opt.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// KOMPONENT 2: Viser flate JSON-objekter (som fasiliteter) som rader med Nøkkel og Verdi
|
||||
const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any, onChange: (v: any) => void }) => {
|
||||
const entries = Object.entries(value || {});
|
||||
|
||||
const updateKey = (oldKey: string, newKey: string, val: any) => {
|
||||
const newObj: any = {};
|
||||
for (const [k, v] of entries) {
|
||||
if (k === oldKey) {
|
||||
if (newKey.trim()) newObj[newKey] = val;
|
||||
} else {
|
||||
newObj[k] = v;
|
||||
}
|
||||
}
|
||||
onChange(newObj);
|
||||
};
|
||||
|
||||
const updateVal = (key: string, val: string) => {
|
||||
onChange({ ...value, [key]: val });
|
||||
};
|
||||
|
||||
const removeKey = (key: string) => {
|
||||
const newObj = { ...value };
|
||||
delete newObj[key];
|
||||
onChange(newObj);
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
const tempKey = `ny_rad_${Date.now()}`;
|
||||
onChange({ ...value, [tempKey]: "" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mb-8 bg-gray-100 p-6 md:p-8 rounded-[2rem] border border-gray-200 shadow-sm">
|
||||
<label className="text-sm font-black uppercase tracking-widest text-[#11280f]">{label}</label>
|
||||
<div className="space-y-3">
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k} className="flex gap-3 items-center">
|
||||
<input
|
||||
className="w-1/3 p-4 rounded-xl border-2 border-gray-300 text-sm font-bold text-black bg-white focus:border-[#8bc34a] outline-none shadow-sm"
|
||||
placeholder="Nøkkel (f.eks proshop)"
|
||||
defaultValue={k.startsWith('ny_rad_') ? '' : k}
|
||||
onBlur={e => updateKey(k, e.target.value, v)}
|
||||
/>
|
||||
<input
|
||||
className="w-full p-4 rounded-xl border-2 border-gray-300 text-base font-medium text-black bg-white focus:border-[#8bc34a] outline-none shadow-sm"
|
||||
placeholder="Verdi (f.eks Ja, eller et navn)"
|
||||
value={String(v)}
|
||||
onChange={e => updateVal(k, e.target.value)}
|
||||
/>
|
||||
<button onClick={() => removeKey(k)} className="p-4 bg-red-100 text-red-700 hover:bg-red-200 hover:text-red-900 rounded-xl font-black text-lg transition-colors border border-red-200">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={addRow} className="mt-2 text-left text-sm font-black text-[#8bc34a] hover:text-[#11280f] transition-colors bg-white px-6 py-3 rounded-xl border-2 border-[#8bc34a] self-start">+ Legg til ny rad</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// KOMPONENT 3: Viser Arrays med objekter (som Greenfee-lister) som små pene kort
|
||||
const ListObjectEditor = ({ label, value, templateKeys, onChange }: { label: string, value: any[], templateKeys: string[], onChange: (v: any[]) => void }) => {
|
||||
const items = Array.isArray(value) ? value : [];
|
||||
|
||||
const updateField = (index: number, key: string, val: string | number) => {
|
||||
const newItems = [...items];
|
||||
const parsedVal = (!isNaN(Number(val)) && val !== "") ? Number(val) : val;
|
||||
newItems[index] = { ...newItems[index], [key]: parsedVal };
|
||||
onChange(newItems);
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
const newItem: any = {};
|
||||
templateKeys.forEach(k => newItem[k] = "");
|
||||
onChange([...items, newItem]);
|
||||
};
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
const newItems = items.filter((_, i) => i !== index);
|
||||
onChange(newItems);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mb-8 bg-gray-100 p-6 md:p-8 rounded-[2rem] border border-gray-200 shadow-sm">
|
||||
<label className="text-sm font-black uppercase tracking-widest text-[#11280f]">{label}</label>
|
||||
<div className="space-y-6">
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className="flex flex-col bg-white p-6 rounded-2xl border-2 border-gray-300 shadow-sm relative group hover:border-[#8bc34a] transition-colors">
|
||||
<button onClick={() => removeRow(idx)} className="absolute top-4 right-4 w-8 h-8 flex items-center justify-center bg-red-100 text-red-700 hover:bg-red-200 hover:text-red-900 rounded-full text-sm font-black transition-colors border border-red-200">✕</button>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pr-10">
|
||||
{templateKeys.map(key => (
|
||||
<div key={key} className="flex flex-col gap-2">
|
||||
<label className="text-xs uppercase font-black text-gray-600 tracking-wider">{key.replace(/_/g, ' ')}</label>
|
||||
<input
|
||||
className="p-3 rounded-lg border-2 border-gray-300 text-base font-bold text-black bg-gray-50 focus:bg-white focus:border-[#8bc34a] outline-none transition-colors"
|
||||
value={item[key] || ""}
|
||||
onChange={e => updateField(idx, key, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={addRow} className="mt-2 text-left text-sm font-black text-[#8bc34a] hover:text-[#11280f] transition-colors bg-white px-6 py-3 rounded-xl border-2 border-[#8bc34a] self-start">+ Legg til nytt element</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// KOMPONENT 4: DEN NYE SCOREKORT-BYGGEREN
|
||||
const ScorecardBuilder = ({ course, onChange }: { course: any, onChange: (c: any) => void }) => {
|
||||
const ALL_KEYS = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest'];
|
||||
|
||||
const [holes, setHoles] = useState<any[]>(() => {
|
||||
const h = course.holes || [];
|
||||
if (h.length === 0) {
|
||||
return Array.from({length: 18}, (_, i) => ({ hole_number: i+1, par: '', hcp_index: '', lengths: {} }));
|
||||
}
|
||||
return h.sort((a: any, b: any) => a.hole_number - b.hole_number);
|
||||
});
|
||||
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(() => {
|
||||
const keys = new Set<string>();
|
||||
holes.forEach(h => {
|
||||
if (h.lengths) Object.keys(h.lengths).forEach(k => keys.add(k));
|
||||
});
|
||||
return ALL_KEYS.filter(k => keys.has(k));
|
||||
});
|
||||
|
||||
const [tees, setTees] = useState<any>(() => {
|
||||
const herrer = course.tee_boxes?.herrer || [];
|
||||
const damer = course.tee_boxes?.damer || [];
|
||||
const initialTees = { herrer: {} as any, damer: {} as any };
|
||||
activeKeys.forEach((key, idx) => {
|
||||
initialTees.herrer[key] = herrer[idx] || { navn_utslag: '', baneverdi: '', slopeverdi: '' };
|
||||
initialTees.damer[key] = damer[idx] || { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' };
|
||||
});
|
||||
return initialTees;
|
||||
});
|
||||
|
||||
const syncToParent = (newHoles: any[], newKeys: string[], newTees: any) => {
|
||||
const updatedTeeBoxes = {
|
||||
herrer: newKeys.map(k => newTees.herrer[k] || {}),
|
||||
damer: newKeys.map(k => newTees.damer[k] || {})
|
||||
};
|
||||
onChange({
|
||||
...course,
|
||||
holes: newHoles,
|
||||
tee_boxes: updatedTeeBoxes
|
||||
});
|
||||
};
|
||||
|
||||
const toggleKey = (key: string) => {
|
||||
const newKeys = activeKeys.includes(key)
|
||||
? activeKeys.filter(k => k !== key)
|
||||
: ALL_KEYS.filter(k => activeKeys.includes(k) || k === key);
|
||||
setActiveKeys(newKeys);
|
||||
|
||||
const newTees = { ...tees };
|
||||
if (!newTees.herrer[key]) newTees.herrer[key] = { navn_utslag: '', baneverdi: '', slopeverdi: '' };
|
||||
if (!newTees.damer[key]) newTees.damer[key] = { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' };
|
||||
setTees(newTees);
|
||||
syncToParent(holes, newKeys, newTees);
|
||||
};
|
||||
|
||||
const updateTee = (gender: 'herrer'|'damer', key: string, field: string, value: string) => {
|
||||
const newTees = { ...tees };
|
||||
newTees[gender][key] = { ...newTees[gender][key], [field]: value };
|
||||
setTees(newTees);
|
||||
syncToParent(holes, activeKeys, newTees);
|
||||
};
|
||||
|
||||
const updateHole = (index: number, field: string, value: string, lengthKey: string | null = null) => {
|
||||
const newHoles = [...holes];
|
||||
if (lengthKey) {
|
||||
newHoles[index].lengths = { ...newHoles[index].lengths, [lengthKey]: value === '' ? '' : Number(value) };
|
||||
} else {
|
||||
newHoles[index][field] = value === '' ? '' : Number(value);
|
||||
}
|
||||
setHoles(newHoles);
|
||||
syncToParent(newHoles, activeKeys, tees);
|
||||
};
|
||||
|
||||
const addHole = () => {
|
||||
const newHoles = [...holes, { hole_number: holes.length + 1, par: '', hcp_index: '', lengths: {} }];
|
||||
setHoles(newHoles);
|
||||
syncToParent(newHoles, activeKeys, tees);
|
||||
};
|
||||
|
||||
const removeLastHole = () => {
|
||||
const newHoles = holes.slice(0, -1);
|
||||
setHoles(newHoles);
|
||||
syncToParent(newHoles, activeKeys, tees);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mt-6">
|
||||
<div className="flex flex-wrap gap-4 items-center bg-gray-100 p-4 rounded-xl border-2 border-gray-200">
|
||||
<span className="text-xs font-black uppercase tracking-widest text-gray-600">Aktive Utslagskolonner:</span>
|
||||
{ALL_KEYS.map(k => (
|
||||
<label key={k} className="flex items-center gap-2 text-sm font-bold cursor-pointer text-black">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeKeys.includes(k)}
|
||||
onChange={() => toggleKey(k)}
|
||||
className="w-5 h-5 accent-[#8bc34a]"
|
||||
/>
|
||||
{k.toUpperCase()}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-2xl border-2 border-gray-300 shadow-sm bg-white pb-2">
|
||||
<table className="w-full text-center text-sm min-w-[800px] border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 text-gray-700 text-xs font-black uppercase tracking-widest border-b-2 border-gray-300">
|
||||
<th className="p-3 border-r border-gray-200">Hull</th>
|
||||
<th className="p-3 border-r border-gray-200">Par</th>
|
||||
<th className="p-3 border-r border-gray-300">HCP</th>
|
||||
{activeKeys.map(k => <th key={k} className="p-3 border-r border-gray-300 w-32">{k}</th>)}
|
||||
</tr>
|
||||
{/* Herrer */}
|
||||
<tr className="bg-blue-50 border-b border-gray-300">
|
||||
<th colSpan={3} className="p-3 text-right text-[10px] font-black text-blue-900 uppercase tracking-widest border-r border-gray-300">
|
||||
Herrer (Navn / CR / Slope)
|
||||
</th>
|
||||
{activeKeys.map(k => (
|
||||
<td key={k} className="p-2 border-r border-gray-300 align-top">
|
||||
<div className="flex flex-col gap-1">
|
||||
<input placeholder="Eks: Gul" className="w-full p-2 text-xs font-bold text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.navn_utslag || ''} onChange={e => updateTee('herrer', k, 'navn_utslag', e.target.value)} />
|
||||
<div className="flex gap-1">
|
||||
<input placeholder="CR" className="w-1/2 p-2 text-xs text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.baneverdi || ''} onChange={e => updateTee('herrer', k, 'baneverdi', e.target.value)} />
|
||||
<input placeholder="Slope" className="w-1/2 p-2 text-xs text-center border border-blue-200 rounded outline-none focus:border-blue-500 bg-white text-black" value={tees.herrer[k]?.slopeverdi || ''} onChange={e => updateTee('herrer', k, 'slopeverdi', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
{/* Damer */}
|
||||
<tr className="bg-red-50 border-b-4 border-gray-400">
|
||||
<th colSpan={3} className="p-3 text-right text-[10px] font-black text-red-900 uppercase tracking-widest border-r border-gray-300">
|
||||
Damer (Navn / CR / Slope)
|
||||
</th>
|
||||
{activeKeys.map(k => (
|
||||
<td key={k} className="p-2 border-r border-gray-300 align-top">
|
||||
<div className="flex flex-col gap-1">
|
||||
<input placeholder="Eks: Rød" className="w-full p-2 text-xs font-bold text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.navn_utslag_damer || ''} onChange={e => updateTee('damer', k, 'navn_utslag_damer', e.target.value)} />
|
||||
<div className="flex gap-1">
|
||||
<input placeholder="CR" className="w-1/2 p-2 text-xs text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.baneverdi_damer || ''} onChange={e => updateTee('damer', k, 'baneverdi_damer', e.target.value)} />
|
||||
<input placeholder="Slope" className="w-1/2 p-2 text-xs text-center border border-red-200 rounded outline-none focus:border-red-500 bg-white text-black" value={tees.damer[k]?.slopeverdi_damer || ''} onChange={e => updateTee('damer', k, 'slopeverdi_damer', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{holes.map((h, idx) => (
|
||||
<tr key={idx} className="border-b border-gray-200 hover:bg-gray-50">
|
||||
<td className="p-2 font-black text-lg text-gray-800 border-r border-gray-200">{h.hole_number}</td>
|
||||
<td className="p-2 border-r border-gray-200"><input type="number" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.par || ''} onChange={e => updateHole(idx, 'par', e.target.value)} /></td>
|
||||
<td className="p-2 border-r border-gray-300"><input type="number" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.hcp_index || ''} onChange={e => updateHole(idx, 'hcp_index', e.target.value)} /></td>
|
||||
{activeKeys.map(k => (
|
||||
<td key={k} className="p-2 border-r border-gray-300 bg-gray-50/50">
|
||||
<input type="number" placeholder="Lengde" className="w-full p-3 text-center border-2 border-gray-200 rounded-xl font-mono font-bold text-black outline-none focus:border-[#8bc34a] bg-white" value={h.lengths?.[k] || ''} onChange={e => updateHole(idx, 'lengths', e.target.value, k)} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 px-2">
|
||||
<button onClick={addHole} className="text-sm font-black text-[#8bc34a] hover:text-[#11280f] px-4 py-2 border-2 border-[#8bc34a] rounded-xl">+ Legg til hull</button>
|
||||
<button onClick={removeLastHole} className="text-sm font-black text-red-500 hover:text-red-700 px-4 py-2 border-2 border-red-500 rounded-xl">- Slett siste hull</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default function EditFacilityClient({ initialData, allFacilities }: { initialData: any, allFacilities: any[] }) {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState(initialData);
|
||||
const [activeTab, setActiveTab] = useState('generelt');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Trekk ut unike arkitekter fra alle anlegg
|
||||
const uniqueArchitects = Array.from(new Set(allFacilities.map(f => f.architect).filter(Boolean))).sort();
|
||||
|
||||
// Sørg for at cooperating_clubs er et array
|
||||
const [coopClubs, setCoopClubs] = useState<string[]>(
|
||||
Array.isArray(initialData.cooperating_clubs) ? initialData.cooperating_clubs :
|
||||
(typeof initialData.cooperating_clubs === 'string' ? JSON.parse(initialData.cooperating_clubs) : [])
|
||||
);
|
||||
|
||||
const handleChange = (field: string, value: any) => {
|
||||
setFormData((prev: any) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/facilities/${initialData.id}/full`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
alert("Lagret suksessfullt!");
|
||||
router.refresh();
|
||||
} else {
|
||||
alert("Noe gikk galt under lagring.");
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Nettverksfeil.");
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'generelt', label: 'Generelt' },
|
||||
{ id: 'lokasjon', label: 'Lokasjon & Kontakt' },
|
||||
{ id: 'linker', label: 'Lenker & Media' },
|
||||
{ id: 'okonomi', label: 'Økonomi & Medlemskap' },
|
||||
{ id: 'baner', label: 'Baner & Scorekort' }
|
||||
];
|
||||
|
||||
const Input = ({ field, label, type = "text" }: { field: string, label: string, type?: string }) => {
|
||||
// Håndter dato-formatet (YYYY-MM-DD) slik at HTML5 date picker forstår det
|
||||
let displayValue = formData[field] || "";
|
||||
if (type === 'date' && displayValue) {
|
||||
displayValue = displayValue.split('T')[0]; // Kutter vekk klokkeslettet
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 mb-8">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">{label}</label>
|
||||
{type === 'textarea' ? (
|
||||
<textarea
|
||||
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base shadow-sm focus:border-[#8bc34a] outline-none transition-all"
|
||||
rows={4}
|
||||
value={displayValue}
|
||||
onChange={e => handleChange(field, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none transition-all"
|
||||
value={displayValue}
|
||||
onChange={e => handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-[1400px] mx-auto p-4 md:p-8 relative z-40 bg-white min-h-screen">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-10 pb-6 border-b border-gray-200 gap-6">
|
||||
<div>
|
||||
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
||||
<h1 className="text-4xl font-black text-[#11280f]">Rediger: <span className="text-[#8bc34a]">{initialData.name}</span></h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="bg-[#11280f] text-white px-8 py-4 rounded-full font-black uppercase tracking-widest hover:bg-[#8bc34a] transition-colors shadow-xl disabled:opacity-50 w-full md:w-auto"
|
||||
>
|
||||
{saving ? "Lagrer..." : "Lagre endringer"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-10">
|
||||
{/* SIDEBAR MENY */}
|
||||
<div className="w-full md:w-1/4 flex flex-col gap-3">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`p-4 rounded-2xl text-left font-black uppercase text-sm tracking-widest transition-all ${activeTab === tab.id ? 'bg-[#8bc34a] text-white shadow-lg translate-x-2' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* SKJEMA OMRÅDE */}
|
||||
<div className="w-full md:w-3/4">
|
||||
{activeTab === 'generelt' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
||||
<div className="col-span-1 md:col-span-2"><Input field="name" label="Anleggsnavn" /></div>
|
||||
|
||||
{/* NYTT: Viktig beskjed / Kursiv intro */}
|
||||
<div className="col-span-1 md:col-span-2"><Input field="footnote" label="Viktig beskjed (Kursiv intro-tekst over beskrivelsen)" type="textarea" /></div>
|
||||
|
||||
<div className="col-span-1 md:col-span-2"><Input field="description" label="Hovedbeskrivelse" type="textarea" /></div>
|
||||
|
||||
<Input field="banetype" label="Banetype (f.eks Park/Skog)" />
|
||||
<Input field="season" label="Sesong (f.eks April-Oktober)" />
|
||||
<Input field="established_year" label="Byggeår" type="number" />
|
||||
|
||||
{/* NYTT: Arkitekt med forslag */}
|
||||
<div className="flex flex-col gap-2 mb-8">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Arkitekt</label>
|
||||
<input
|
||||
list="architect-list"
|
||||
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none transition-all"
|
||||
value={formData.architect || ""}
|
||||
onChange={e => handleChange('architect', e.target.value)}
|
||||
placeholder="Velg eller skriv inn ny..."
|
||||
/>
|
||||
<datalist id="architect-list">
|
||||
<option value="Ukjent" />
|
||||
{uniqueArchitects.map((arch: any) => <option key={arch} value={arch} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<Input field="length_meters" label="Totallengde (meter)" type="number" />
|
||||
|
||||
{/* NYTT: Samarbeidende klubber */}
|
||||
<MultiSelect
|
||||
label="Samarbeidende Klubber (Gjestespill etc.)"
|
||||
options={allFacilities.filter(f => f.id !== initialData.id)}
|
||||
selected={coopClubs}
|
||||
onChange={(val) => {
|
||||
setCoopClubs(val);
|
||||
handleChange('cooperating_clubs', val);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'lokasjon' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
||||
<div className="col-span-1 md:col-span-2"><Input field="address" label="Gateadresse" /></div>
|
||||
<Input field="zipcode" label="Postnummer" />
|
||||
<Input field="city" label="Poststed / By" />
|
||||
<Input field="county" label="Fylke" />
|
||||
<Input field="phone" label="Telefon" />
|
||||
<Input field="email" label="E-post" />
|
||||
<Input field="lat" label="Breddegrad (Latitude)" type="number" />
|
||||
<Input field="lng" label="Lengdegrad (Longitude)" type="number" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'linker' && (
|
||||
<div className="flex flex-col">
|
||||
<Input field="website_url" label="Nettside URL" />
|
||||
<Input field="golfbox_booking_url" label="Golfbox Booking URL" />
|
||||
<Input field="golfbox_tournament_url" label="Golfbox Turnering URL" />
|
||||
<Input field="baneguide_url" label="Baneguide URL" />
|
||||
<Input field="flyfoto_url" label="Flyfoto URL" />
|
||||
<Input field="weather_url" label="Vær URL (YR)" />
|
||||
<Input field="webcam_url" label="Webkamera URL" />
|
||||
<Input field="video_url" label="Video URL (YouTube/Vimeo)" />
|
||||
|
||||
{/* NYTT: Sosiale Medier lagt inn som liste-editor her */}
|
||||
<ListObjectEditor
|
||||
label="Sosiale Medier (Legg inn f.eks facebook, instagram, linkedin)"
|
||||
value={formData.social_links}
|
||||
templateKeys={['platform', 'url']}
|
||||
onChange={(v) => handleChange('social_links', v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'okonomi' && (
|
||||
<div className="flex flex-col">
|
||||
{/* NYTT FELT FOR MANUELL DATO */}
|
||||
<div className="mb-6">
|
||||
<Input field="membership_updated_at" label="Sist Oppdatert (Dato for Medlemskapspriser)" type="date" />
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-100 p-6 rounded-2xl mb-8 border border-gray-200">
|
||||
<h3 className="font-black uppercase tracking-widest text-gray-800 mb-6 pb-2 border-b-2 border-gray-200">Medlemskap</h3>
|
||||
<Input field="navn_standard_medlemskap" label="Navn på standard medlemskap" />
|
||||
<Input field="standard_medlemskap" label="Pris standard (kun tall)" type="number" />
|
||||
<Input field="standard_medlemskap_kommentarer" label="Kommentar standard" />
|
||||
<Input field="navn_rimeligste_alternativ" label="Navn på rimeligste" />
|
||||
<Input field="rimeligste_alternativ" label="Pris rimeligste (kun tall)" type="number" />
|
||||
<Input field="medlemskap_url" label="Lenke til medlemskapsside" />
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-100 p-6 rounded-2xl border border-gray-200">
|
||||
<h3 className="font-black uppercase tracking-widest text-gray-800 mb-6 pb-2 border-b-2 border-gray-200">Veien til Golf (VTG)</h3>
|
||||
<Input field="vtg_pris" label="Pris VTG kurs (kun tall)" type="number" />
|
||||
<Input field="vtg_lenke" label="Lenke til VTG påmelding" />
|
||||
</div>
|
||||
|
||||
{/* Tilbud (tidligere under Avansert) */}
|
||||
<div className="mt-8 border-t-2 border-gray-200 pt-8">
|
||||
<KeyValueEditor label="Fasiliteter (Proshop, Kafé etc.)" value={formData.amenities} onChange={(v) => handleChange('amenities', v)} />
|
||||
<KeyValueEditor label="Norsk Seniorgolf (NSG)" value={formData.nsg_data} onChange={(v) => handleChange('nsg_data', v)} />
|
||||
<KeyValueEditor label="Golfamore Info" value={formData.golfamore_data} onChange={(v) => handleChange('golfamore_data', v)} />
|
||||
|
||||
<ListObjectEditor
|
||||
label="Greenfee Priser"
|
||||
value={formData.greenfee}
|
||||
templateKeys={['banenavn', 'priskategori', 'pris_voksne', 'pris_junior']}
|
||||
onChange={(v) => handleChange('greenfee', v)}
|
||||
/>
|
||||
|
||||
<ListObjectEditor
|
||||
label="Golfpakker"
|
||||
value={formData.golfpakker}
|
||||
templateKeys={['navn', 'pris', 'beskrivelse']}
|
||||
onChange={(v) => handleChange('golfpakker', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BANER & SCOREKORT MED NY GRAFISK BYGGER */}
|
||||
{activeTab === 'baner' && (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="bg-[#f1f7ed] p-6 rounded-2xl border-2 border-[#7ca982] mb-4">
|
||||
<h3 className="font-black text-[#11280f] text-lg uppercase tracking-widest mb-2">Baner og Scorekort</h3>
|
||||
<p className="text-sm text-gray-800 font-medium">Bruk det interaktive skjemaet under for å redigere lengder, par og utslag.</p>
|
||||
</div>
|
||||
|
||||
{formData.courses?.map((course: any, cIdx: number) => (
|
||||
<div key={course.id || cIdx} className="bg-gray-100 p-8 rounded-[2rem] border-2 border-gray-200 shadow-sm mb-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4 border-b-2 border-gray-200 pb-4">
|
||||
<h4 className="text-2xl font-black text-black">{course.name}</h4>
|
||||
<span className={`px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest ${course.is_main_course ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-300 text-gray-700'}`}>
|
||||
{course.is_main_course ? 'Hovedbane' : 'Sekundærbane'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Banenavn</label>
|
||||
<input className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.name || ""} onChange={e => {
|
||||
const newCourses = [...formData.courses];
|
||||
newCourses[cIdx] = {...course, name: e.target.value};
|
||||
handleChange('courses', newCourses);
|
||||
}} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Status</label>
|
||||
<select className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.status || "ukjent"} onChange={e => {
|
||||
const newCourses = [...formData.courses];
|
||||
newCourses[cIdx] = {...course, status: e.target.value};
|
||||
handleChange('courses', newCourses);
|
||||
}}>
|
||||
<option value="aapen">🟢 Åpen</option>
|
||||
<option value="aapen_med_vintergreener">🟡 Vintergreener</option>
|
||||
<option value="aapner_snart">🟡 Åpner Snart</option>
|
||||
<option value="stengt">🔴 Stengt</option>
|
||||
<option value="nedlagt">⚫ Nedlagt</option>
|
||||
<option value="ukjent">⚪ Ukjent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Total Par (Bane)</label>
|
||||
<input type="number" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.par || ""} onChange={e => {
|
||||
const newCourses = [...formData.courses];
|
||||
newCourses[cIdx] = {...course, par: Number(e.target.value)};
|
||||
handleChange('courses', newCourses);
|
||||
}} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Utløpsdato Slope</label>
|
||||
<input type="date" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.slope_valid_until ? course.slope_valid_until.split('T')[0] : ""} onChange={e => {
|
||||
const newCourses = [...formData.courses];
|
||||
newCourses[cIdx] = {...course, slope_valid_until: e.target.value};
|
||||
handleChange('courses', newCourses);
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DET NYE SCOREKORTET INKLUDERES HER */}
|
||||
<ScorecardBuilder
|
||||
course={course}
|
||||
onChange={(updatedCourse) => {
|
||||
const newCourses = [...formData.courses];
|
||||
newCourses[cIdx] = updatedCourse;
|
||||
handleChange('courses', newCourses);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { API_URL } from "@/config/constants";
|
||||
import EditFacilityClient from "./EditFacilityClient";
|
||||
|
||||
export default async function EditFacilityPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
|
||||
// Henter anlegget vi skal redigere
|
||||
const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: 'no-store' });
|
||||
const facility = await res.json();
|
||||
|
||||
// Henter ALLE anlegg slik at vi kan bygge lister for samarbeid og arkitekter
|
||||
const allRes = await fetch(`${API_URL}/facilities`, { cache: 'no-store' });
|
||||
const allFacilities = await allRes.json();
|
||||
|
||||
if (!facility || facility.error) {
|
||||
return <div className="p-20 text-center font-bold text-2xl">Fant ikke anlegget...</div>;
|
||||
}
|
||||
|
||||
return <EditFacilityClient initialData={facility} allFacilities={allFacilities} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
"use client";
|
||||
import { useState } from 'react';
|
||||
import { STATUS_MAP } from "@/config/constants";
|
||||
|
||||
// Designerens definisjon av fargetemaer - Nå med kraftigere tints for kolonnene
|
||||
const getTeeTheme = (label: string) => {
|
||||
const name = label.toLowerCase();
|
||||
if (name.includes("svart") || name.includes("black")) {
|
||||
return { header: "bg-gray-900 text-white", col: "bg-gray-100", text: "text-gray-900" };
|
||||
}
|
||||
if (name.includes("hvit") || name.includes("white")) {
|
||||
return { header: "bg-white text-gray-800 border border-gray-300", col: "bg-gray-50", text: "text-gray-700" };
|
||||
}
|
||||
if (name.includes("gul") || name.includes("yellow")) {
|
||||
return { header: "bg-yellow-400 text-yellow-950", col: "bg-yellow-50", text: "text-yellow-900" };
|
||||
}
|
||||
if (name.includes("blå") || name.includes("bla") || name.includes("blue")) {
|
||||
return { header: "bg-blue-600 text-white", col: "bg-blue-50", text: "text-blue-900" };
|
||||
}
|
||||
if (name.includes("rød") || name.includes("rod") || name.includes("red")) {
|
||||
return { header: "bg-red-500 text-white", col: "bg-red-50", text: "text-red-900" };
|
||||
}
|
||||
if (name.includes("grønn") || name.includes("gronn") || name.includes("green")) {
|
||||
return { header: "bg-emerald-500 text-white", col: "bg-emerald-50", text: "text-emerald-900" };
|
||||
}
|
||||
|
||||
// DEFAULT: Nøytral grå for utslag med tall (f.eks "52", "45")
|
||||
return { header: "bg-gray-200 text-gray-700", col: "bg-gray-100/60", text: "text-gray-600" };
|
||||
};
|
||||
|
||||
export default function CourseDisplay({ course }: { course: any }) {
|
||||
const [hcp, setHcp] = useState("15.0");
|
||||
const [gender, setGender] = useState<'herrer' | 'damer'>('herrer');
|
||||
const [selectedTeeIndex, setSelectedTeeIndex] = useState(0);
|
||||
|
||||
const allHoles = course.holes || [];
|
||||
const holesOut = allHoles.filter((h: any) => h.hole_number <= 9);
|
||||
const holesIn = allHoles.filter((h: any) => h.hole_number > 9);
|
||||
const hasInHoles = holesIn.length > 0;
|
||||
|
||||
const lengthKeys = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest'];
|
||||
const availableTees = course.tee_boxes?.[gender] || [];
|
||||
|
||||
const activeColumns = lengthKeys
|
||||
.filter(k => allHoles.some((h: any) => h.lengths?.[k]))
|
||||
.map((key, idx) => {
|
||||
const info = availableTees[idx];
|
||||
const label = info?.navn_utslag || info?.navn_utslag_damer || key.toUpperCase();
|
||||
return { key, label, theme: getTeeTheme(label) };
|
||||
});
|
||||
|
||||
// Kalkulering av SpH
|
||||
const activeTee = availableTees[selectedTeeIndex];
|
||||
let playingHandicap = 0;
|
||||
|
||||
if (activeTee && hcp) {
|
||||
const exactHcp = Number(hcp.replace(',', '.'));
|
||||
const slope = Number(activeTee.slopeverdi || activeTee.slopeverdi_damer || 113);
|
||||
const cr = Number(String(activeTee.baneverdi || activeTee.baneverdi_damer || course.par).replace(',', '.'));
|
||||
playingHandicap = Math.round((exactHcp * (slope / 113)) + (cr - course.par));
|
||||
}
|
||||
|
||||
const getExtraStrokes = (hcpIndex: number) => {
|
||||
if (!hcpIndex || isNaN(playingHandicap)) return 0;
|
||||
const base = Math.floor(playingHandicap / 18);
|
||||
const rem = playingHandicap % 18;
|
||||
return base + (hcpIndex <= rem ? 1 : 0);
|
||||
};
|
||||
|
||||
const sumPar = (holes: any[]) => holes.reduce((acc, h) => acc + (h.par || 0), 0);
|
||||
const sumLen = (holes: any[], key: string) => holes.reduce((acc, h) => acc + (h.lengths?.[key] || 0), 0);
|
||||
|
||||
// Formater utløpsdato
|
||||
const slopeExpiry = course.slope_valid_until
|
||||
? new Date(course.slope_valid_until).toLocaleDateString('nb-NO', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
: 'Ukjent';
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-[3rem] shadow-sm border border-gray-200 overflow-hidden mb-12">
|
||||
|
||||
{/* HEADER / KALKULATOR */}
|
||||
<div className="p-8 md:p-12 flex flex-col md:flex-row justify-between items-center gap-8 border-b border-gray-100 bg-white">
|
||||
<div className="text-center md:text-left">
|
||||
<h2 className="text-5xl font-black text-[#11280f] tracking-tighter">{course.name}</h2>
|
||||
<p className="text-[#7ca982] font-black uppercase text-xs tracking-[0.2em] mt-2 mb-1">
|
||||
Par {course.par} • {course.length_meters || '--'} meter
|
||||
</p>
|
||||
<p className="text-gray-400 text-[10px] font-bold uppercase tracking-widest">
|
||||
Rating utløper: {slopeExpiry}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 bg-gray-50 p-6 rounded-[2.5rem] border border-gray-100">
|
||||
<div className="flex flex-col"><span className="text-[9px] font-black text-[#7ca982] uppercase ml-1">Kjønn</span>
|
||||
<select value={gender} onChange={e => { setGender(e.target.value as any); setSelectedTeeIndex(0); }} className="bg-transparent text-[#11280f] font-black outline-none border-b-2 border-[#7ca982]/30 pb-1 cursor-pointer">
|
||||
<option value="herrer">HERRER</option><option value="damer">DAMER</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col"><span className="text-[9px] font-black text-[#7ca982] uppercase ml-1">Utslag</span>
|
||||
<select value={selectedTeeIndex} onChange={e => setSelectedTeeIndex(Number(e.target.value))} className="bg-transparent text-[#11280f] font-black outline-none border-b-2 border-[#7ca982]/30 pb-1 cursor-pointer">
|
||||
{availableTees.map((t: any, i: number) => (<option key={i} value={i}>{t.navn_utslag || t.navn_utslag_damer}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col"><span className="text-[9px] font-black text-[#7ca982] uppercase ml-1">Ditt HCP</span>
|
||||
<input type="text" value={hcp} onChange={e => setHcp(e.target.value)} className="w-12 bg-transparent text-[#11280f] font-black text-center border-b-2 border-[#7ca982]/30" />
|
||||
</div>
|
||||
<div className="pl-6 border-l border-gray-200 text-center">
|
||||
<p className="text-[9px] uppercase font-black text-[#7ca982] mb-1">SpH</p>
|
||||
<p className="text-4xl font-black text-[#11280f] leading-none">{playingHandicap || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SCOREKORT TABELL */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-center border-collapse table-fixed min-w-[850px]">
|
||||
<thead>
|
||||
<tr className="bg-white text-[10px] text-gray-400 font-black uppercase">
|
||||
<th className="w-20 p-5 text-left pl-10 border-b border-gray-100">Hull</th>
|
||||
<th className="w-16 p-5 border-l border-gray-100 border-b border-gray-100">Par</th>
|
||||
<th className="w-16 p-5 border-l border-gray-100 border-b border-gray-100">HCP</th>
|
||||
<th className="w-24 p-5 border-l border-gray-100 border-b border-gray-100 bg-[#7ca982]/10 text-[#7ca982]">Mottatt</th>
|
||||
<th className="w-24 p-5 border-l border-gray-100 border-b border-gray-100 bg-[#7ca982]/20 text-[#11280f]">Din Par</th>
|
||||
{activeColumns.map((col, i) => (
|
||||
<th key={i} className={`p-5 border-l border-white font-black ${col.theme.header}`}>{col.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="font-bold text-[#11280f]">
|
||||
{/* UT-RUNDE */}
|
||||
{holesOut.map((h: any) => {
|
||||
const extra = getExtraStrokes(h.hcp_index);
|
||||
return (
|
||||
<tr key={h.id} className="border-t border-gray-100 group hover:bg-white transition-colors">
|
||||
<td className="p-4 text-left pl-10 font-black text-lg text-gray-800">{h.hole_number}</td>
|
||||
<td className="p-4 border-l border-gray-100 bg-white">{h.par}</td>
|
||||
<td className="p-4 border-l border-gray-100 text-gray-300 text-xs font-mono">{h.hcp_index}</td>
|
||||
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/5 text-[#7ca982] font-mono">{extra > 0 ? `+${extra}` : '-'}</td>
|
||||
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/10 text-lg font-mono">{h.par + extra}</td>
|
||||
{activeColumns.map((col, i) => (
|
||||
<td key={i} className={`p-4 border-l border-white font-mono transition-all ${col.theme.col} ${col.theme.text}`}>
|
||||
{h.lengths?.[col.key] || '--'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* UT RAD */}
|
||||
<tr className="bg-[#f1f7ed]/50 text-[#11280f] font-black border-y border-gray-200">
|
||||
<td className="p-4 text-left pl-10 uppercase tracking-widest text-[10px] text-gray-400">Ut</td>
|
||||
<td className="p-4 border-l border-gray-100">{sumPar(holesOut)}</td>
|
||||
<td colSpan={3} className="border-l border-gray-100 bg-white"></td>
|
||||
{activeColumns.map((col, i) => (
|
||||
<td key={i} className={`p-4 border-l border-white font-mono ${col.theme.col} text-gray-900`}>{sumLen(holesOut, col.key)}</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{/* INN-RUNDE */}
|
||||
{hasInHoles && holesIn.map((h: any) => {
|
||||
const extra = getExtraStrokes(h.hcp_index);
|
||||
return (
|
||||
<tr key={h.id} className="border-t border-gray-100 group hover:bg-white transition-colors">
|
||||
<td className="p-4 text-left pl-10 font-black text-lg text-gray-800">{h.hole_number}</td>
|
||||
<td className="p-4 border-l border-gray-100 bg-white">{h.par}</td>
|
||||
<td className="p-4 border-l border-gray-100 text-gray-300 text-xs font-mono">{h.hcp_index}</td>
|
||||
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/5 text-[#7ca982] font-mono">{extra > 0 ? `+${extra}` : '-'}</td>
|
||||
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/10 text-lg font-mono">{h.par + extra}</td>
|
||||
{activeColumns.map((col, i) => (
|
||||
<td key={i} className={`p-4 border-l border-white font-mono transition-all ${col.theme.col} ${col.theme.text}`}>
|
||||
{h.lengths?.[col.key] || '--'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* INN RAD */}
|
||||
{hasInHoles && (
|
||||
<tr className="bg-[#f1f7ed]/50 text-[#11280f] font-black border-y border-gray-200">
|
||||
<td className="p-4 text-left pl-10 uppercase tracking-widest text-[10px] text-gray-400">Inn</td>
|
||||
<td className="p-4 border-l border-gray-100">{sumPar(holesIn)}</td>
|
||||
<td colSpan={3} className="border-l border-gray-100 bg-white"></td>
|
||||
{activeColumns.map((col, i) => (
|
||||
<td key={i} className={`p-4 border-l border-white font-mono ${col.theme.col} text-gray-900`}>{sumLen(holesIn, col.key)}</td>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* TOTAL RAD */}
|
||||
<tr className="bg-[#11280f] text-white text-xl font-black">
|
||||
<td className="p-8 text-left pl-10 uppercase tracking-tighter">Totalt</td>
|
||||
<td className="p-8 border-l border-white/10">{sumPar(allHoles)}</td>
|
||||
<td colSpan={3} className="border-l border-white/10 bg-[#1a3a17]"></td>
|
||||
{activeColumns.map((col, i) => (
|
||||
<td key={i} className={`p-8 border-l border-white/10 font-mono ${col.theme.header.split(' ')[0]}`}>
|
||||
{sumLen(allHoles, col.key)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,435 @@
|
|||
"use client";
|
||||
/**
|
||||
* TEE OFF DETAIL VIEW - COMPLETE v3.22
|
||||
* ---------------------------------------------------------------------------
|
||||
* FIX: Gjenopprettet "Turneringer" i den flytende knapperaden over bildet.
|
||||
* FIX: Byttet plass på tekst og sidebar (Tekst øverst på mobil).
|
||||
* FIX: Økt padding (pb-32) i Hero-teksten på mobil for å unngå krasj med knapper.
|
||||
* FIX: Alle 4 kontaktpunkter i sidebar er klikkbare (tel:0047 fix inkludert).
|
||||
* NEW: Sosiale Medier, Footnote og Samarbeidende klubber integrert.
|
||||
* REGEL: Beholder monokrome ikoner, 22/78 layout og robust JSON-parsing.
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants";
|
||||
import Link from 'next/link';
|
||||
import CourseDisplay from './CourseDisplay';
|
||||
|
||||
const formatPhoneForUrl = (phone: string) => {
|
||||
if (!phone) return "";
|
||||
return phone.replace('+', '00').replace(/\s/g, '');
|
||||
};
|
||||
|
||||
const renderValue = (val: string) => {
|
||||
if (!val) return "Nei";
|
||||
const hasLink = val.includes('<a');
|
||||
return (
|
||||
<span
|
||||
className={hasLink ? "text-[#ff5722] font-bold hover:underline" : "text-[#11280f]"}
|
||||
dangerouslySetInnerHTML={{ __html: val }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Icon = ({ children, className = "w-5 h-5" }: { children: React.ReactNode, className?: string }) => (
|
||||
<svg
|
||||
className={`${className} flex-shrink-0 text-current`}
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ width: '20px', height: '20px' }}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ICONS = {
|
||||
web: <><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></>,
|
||||
phone: <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />,
|
||||
mail: <><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" /><polyline points="22,6 12,13 2,6" /></>,
|
||||
pin: <><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" /><circle cx="12" cy="10" r="3" /></>,
|
||||
booking: <><path d="M3 10h18M7 15h.01M11 15h.01M15 15h.01M7 19h.01M11 19h.01M15 19h.01M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2zM16 3v4M8 3v4"/></>,
|
||||
trophy: <><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22M18 2H6v7a6 6 0 0 0 12 0V2z"/></>,
|
||||
guide: <><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 4.5A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1-2.5-2.5V4.5z"/></>,
|
||||
camera: <><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></>,
|
||||
webcam: <><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></>,
|
||||
chart: <><path d="M18 20V10M12 20V4M6 20v-6"/></>,
|
||||
weather: <><path d="M12 2v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="M20 12h2"/><path d="m19.07 4.93-1.41 1.41"/><path d="M15.947 12.65a4 4 0 0 0-5.925-4.128"/><path d="M13 22H7a5 5 0 1 1 4.9-6H13a3 3 0 0 1 0 6Z"/></>
|
||||
};
|
||||
|
||||
const SOCIAL_ICONS: Record<string, React.ReactNode> = {
|
||||
facebook: <path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />,
|
||||
instagram: <><rect x="2" y="2" width="20" height="20" rx="5" ry="5" /><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" /><line x1="17.5" y1="6.5" x2="17.51" y2="6.5" /></>,
|
||||
twitter: <path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z" />,
|
||||
x: <path d="M4 4l16 16M4 20L20 4" />,
|
||||
linkedin: <><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" /><rect x="2" y="9" width="4" height="12" /><circle cx="4" cy="4" r="2" /></>,
|
||||
youtube: <><path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33 2.78 2.78 0 0 0 1.94 2c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.33 29 29 0 0 0-.46-5.33z" /><polygon points="9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02" /></>,
|
||||
tiktok: <path d="M9 12a4 4 0 1 0 4 4V2a5 5 0 0 0 5 5h-2a3 3 0 0 1-3-3V16a2 2 0 1 1-2-2v-2z" />,
|
||||
snapchat: <path d="M12 2C8.5 2 6 5 6 8.5c0 1.5.5 3 1.5 4-1 .5-2.5 1-3.5 1-.5 0-1 .5-1 1s.5 1 1.5 1h15c1 0 1.5-.5 1.5-1s-.5-1-1-1c-1 0-2.5-.5-3.5-1 1-1 1.5-2.5 1.5-4C18 5 15.5 2 12 2zm0 15c-3 0-5-1-5-1s.5 1.5 1.5 2h7C16.5 17.5 17 16 17 16s-2 1-5 1z" />
|
||||
};
|
||||
|
||||
export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
|
||||
const parseJson = (val: any, fallback: any) => {
|
||||
if (!val) return fallback;
|
||||
if (typeof val === 'object') return val;
|
||||
try { return JSON.parse(val); } catch (e) { return fallback; }
|
||||
};
|
||||
|
||||
const rawCourses = parseJson(facility.courses, []);
|
||||
const activeCourses = Array.isArray(rawCourses) ? rawCourses.filter((c: any) => c.holes && (typeof c.holes === 'string' || c.holes.length > 0)) : [];
|
||||
const amenities = parseJson(facility.amenities, {});
|
||||
const galleryRaw = parseJson(facility.gallery, []);
|
||||
const gallery = galleryRaw.length > 0 ? galleryRaw : [facility.image_url || FALLBACK_IMAGE];
|
||||
const greenfeeRaw = parseJson(facility.greenfee, []);
|
||||
const shotzoom = parseJson(facility.shotzoom, []);
|
||||
|
||||
const golfamoreData = parseJson(facility.golfamore_data, {});
|
||||
const nsgData = parseJson(facility.nsg_data, {});
|
||||
const socialLinksRaw = parseJson(facility.social_links, []);
|
||||
const socialLinks = Array.isArray(socialLinksRaw) ? socialLinksRaw : [];
|
||||
|
||||
const coopClubsRaw = parseJson(facility.cooperating_clubs, []);
|
||||
const cooperatingClubs = Array.isArray(coopClubsRaw) ? coopClubsRaw : [];
|
||||
|
||||
const hasGolfamore = facility.golfamore === true;
|
||||
const hasNSG = facility.nsg_url || (nsgData && Object.keys(nsgData).length > 0);
|
||||
|
||||
const groupedGreenfee: Record<string, any[]> = greenfeeRaw.reduce((acc: any, curr: any) => {
|
||||
const bane = curr.banenavn || "Gjestespill";
|
||||
if (!acc[bane]) acc[bane] = [];
|
||||
acc[bane].push(curr);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const sidebarLinkClass = "flex items-center gap-4 text-[#11280f] hover:text-[#ff5722] transition-colors group";
|
||||
const resourceBtnClass = "flex justify-between items-center p-5 bg-gray-50 rounded-2xl text-[11px] font-black uppercase text-[#11280f] hover:bg-[#ff5722] hover:text-white transition-all group";
|
||||
|
||||
useEffect(() => {
|
||||
if (gallery.length <= 1) return;
|
||||
const timer = setInterval(() => setCurrentSlide((p) => (p + 1) % gallery.length), 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [gallery.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setShowBackToTop(window.scrollY > 500);
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const scrollTo = (id: string) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) window.scrollTo({ top: el.getBoundingClientRect().top + window.pageYOffset - 80, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('nb-NO', { day: 'numeric', month: 'long', year: 'numeric' }) : null;
|
||||
const weatherImg = facility.weather_url?.replace("/graf/dag/", "/innhold/").replace(/\/$/, "") + "/meteogram.svg";
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#f1f7ed] pb-20 relative font-sans text-[#11280f]">
|
||||
|
||||
{/* 1. HERO SLIDER */}
|
||||
<div className="h-[55vh] min-h-[450px] relative overflow-hidden bg-[#11280f]">
|
||||
{gallery.map((img: string, i: number) => (
|
||||
<img key={i} src={img} className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-1000 ${i === currentSlide ? 'opacity-100 z-10' : 'opacity-0 z-0'}`} alt="" />
|
||||
))}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#11280f]/90 via-transparent to-black/10 z-20" />
|
||||
|
||||
{/* BANESTATUS BADGES */}
|
||||
<div className="absolute top-8 right-8 z-40 flex flex-col items-end gap-2">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{activeCourses.map((c: any) => (
|
||||
<span key={c.id} className="px-3 py-1.5 rounded-lg text-[10px] font-black uppercase bg-[#7ca982] text-white shadow-xl">
|
||||
{c.name.toUpperCase()}: {STATUS_MAP[c.status] || c.status}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{facility.status_updated_at && (
|
||||
<span className="text-white/60 text-[10px] uppercase font-black tracking-widest bg-black/20 px-2 py-1 rounded">
|
||||
Sist oppdatert: {formatDate(facility.status_updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* FLYTENDE HURTIGKNAPPER */}
|
||||
<div className="absolute bottom-8 right-8 z-40 flex gap-2.5 bg-black/30 backdrop-blur-md p-2 rounded-2xl border border-white/10 shadow-2xl text-[#11280f]">
|
||||
{facility.website_url && <a href={facility.website_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.web} /></a>}
|
||||
{facility.golfbox_booking_url && <a href={facility.golfbox_booking_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.booking} /></a>}
|
||||
{facility.golfbox_tournament_url && <a href={facility.golfbox_tournament_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.trophy} /></a>}
|
||||
<a href={`https://www.google.com/maps/search/?api=1&query=${facility.lat},${facility.lng}`} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.pin} /></a>
|
||||
{facility.weather_url && <a href={facility.weather_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center hover:bg-[#ff5722] hover:text-white transition-all"><Icon children={ICONS.weather} /></a>}
|
||||
</div>
|
||||
|
||||
{/* HERO TEXT */}
|
||||
<div className="relative z-30 max-w-[1200px] mx-auto px-6 w-full h-full flex flex-col justify-end pb-32 md:pb-12">
|
||||
{facility.logo_url && (
|
||||
<div className="hidden md:block mb-8 w-24 h-24 bg-white p-2 rounded-2xl shadow-2xl border-4 border-white/20 overflow-hidden"><img src={facility.logo_url} className="w-full h-full object-contain" alt="Logo" /></div>
|
||||
)}
|
||||
<h1 className="text-5xl md:text-8xl font-black text-white mb-3 tracking-tighter drop-shadow-2xl">{facility.name}</h1>
|
||||
<p className="text-[#7ca982] uppercase tracking-[0.4em] font-black text-xs md:text-sm pl-1">{facility.county} • {facility.city}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. STICKY NAV */}
|
||||
<nav className="sticky top-0 z-50 bg-white/95 backdrop-blur-md border-b border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="max-w-[1200px] mx-auto px-6 flex justify-between md:justify-start gap-4 md:gap-10 h-16 items-center text-[10px] font-black uppercase tracking-widest text-gray-400">
|
||||
<button onClick={() => scrollTo('intro')}>Info</button>
|
||||
<button onClick={() => scrollTo('weather')}>Vær</button>
|
||||
<button onClick={() => scrollTo('details')}>Detaljer</button>
|
||||
<button onClick={() => scrollTo('map')}>Kart</button>
|
||||
{facility.video_url && <button onClick={() => scrollTo('video')}>Video</button>}
|
||||
<button onClick={() => scrollTo('prices')}>Priser</button>
|
||||
<button onClick={() => scrollTo('scorecards')}>Scorekort</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="max-w-[1200px] mx-auto px-0 md:px-6 space-y-4 md:space-y-12 mt-0 md:mt-12">
|
||||
|
||||
{/* 3. INTRO & SIDEBAR */}
|
||||
<section id="intro" className="flex flex-col lg:flex-row gap-0 md:gap-8 items-stretch">
|
||||
{/* HOVEDINNHOLD (78%) */}
|
||||
<div className="lg:w-[78%] bg-white p-10 md:p-16 md:rounded-[3rem] shadow-sm border-b md:border-none">
|
||||
{facility.footnote && (
|
||||
<div className="mb-8 pb-8 border-b border-gray-50 italic text-[#ff5722] text-lg font-serif">
|
||||
{facility.footnote}
|
||||
</div>
|
||||
)}
|
||||
<div className="leading-relaxed text-lg md:text-xl text-gray-600" dangerouslySetInnerHTML={{ __html: facility.description || 'Ingen beskrivelse tilgjengelig.' }} />
|
||||
</div>
|
||||
|
||||
{/* SIDEBAR (22%) */}
|
||||
<div className="lg:w-[22%] bg-white p-10 md:rounded-[3rem] shadow-sm flex flex-col order-last lg:order-none">
|
||||
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-10">Kontakt & Adresse</h3>
|
||||
<div className="flex-grow space-y-7 text-sm font-bold">
|
||||
<a href={facility.website_url} target="_blank" className={sidebarLinkClass}><Icon children={ICONS.web} /> Besøk nettsiden</a>
|
||||
<a href={`tel:${formatPhoneForUrl(facility.phone)}`} className={sidebarLinkClass}>
|
||||
<Icon children={ICONS.phone} /> {facility.phone || 'Ikke oppgitt'}
|
||||
</a>
|
||||
<a href={`mailto:${facility.email}`} className={sidebarLinkClass}>
|
||||
<Icon children={ICONS.mail} /> <span className="truncate">{facility.email || 'Ikke oppgitt'}</span>
|
||||
</a>
|
||||
<div className="pt-2 border-t border-gray-50 mt-4">
|
||||
<a href={`https://www.google.com/maps/search/?api=1&query=${facility.lat},${facility.lng}`} target="_blank" className={sidebarLinkClass + " pt-4 leading-tight items-start"}>
|
||||
<Icon children={ICONS.pin} /> <span className="text-gray-400 group-hover:text-[#ff5722] transition-colors">{facility.address}<br/>{facility.city}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SOSIALE MEDIER IKONER */}
|
||||
{socialLinks.length > 0 && (
|
||||
<div className="pt-6 border-t border-gray-50 mt-6 flex flex-wrap gap-3">
|
||||
{socialLinks.map((social: any, idx: number) => {
|
||||
const platform = (social.platform || '').toLowerCase().trim();
|
||||
// Finn riktig ikon, fall tilbake til en generell link-pil
|
||||
const iconData = SOCIAL_ICONS[platform] || <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3" />;
|
||||
|
||||
return (
|
||||
<a key={idx} href={social.url} target="_blank" rel="noreferrer" title={social.platform} className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center text-[#11280f] hover:bg-[#ff5722] hover:text-white transition-all shadow-sm">
|
||||
<Icon children={iconData} className="w-4 h-4 text-current" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-10 pt-6 border-t border-gray-50">
|
||||
<Link href={`/`} className="text-[10px] font-black uppercase tracking-widest text-[#7ca982] hover:text-[#11280f] transition-all flex items-center gap-1">
|
||||
Se alle baner i {facility.county} →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 4. 3-KOLONNE INFO */}
|
||||
<section id="details" className="grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-8">
|
||||
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm">
|
||||
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter">Andre Ressurser</h3>
|
||||
<div className="space-y-2.5">
|
||||
{facility.golfbox_booking_url && (
|
||||
<a href={facility.golfbox_booking_url} target="_blank" className={resourceBtnClass}>
|
||||
<span className="flex items-center gap-3"><Icon children={ICONS.booking} className="group-hover:text-white" /> Book Starttid</span><span>→</span>
|
||||
</a>
|
||||
)}
|
||||
{facility.golfbox_tournament_url && (
|
||||
<a href={facility.golfbox_tournament_url} target="_blank" className={resourceBtnClass}>
|
||||
<span className="flex items-center gap-3"><Icon children={ICONS.trophy} className="group-hover:text-white" /> Turneringer</span><span>→</span>
|
||||
</a>
|
||||
)}
|
||||
{facility.baneguide_url && (
|
||||
<a href={facility.baneguide_url} target="_blank" className={resourceBtnClass}>
|
||||
<span className="flex items-center gap-3"><Icon children={ICONS.guide} className="group-hover:text-white" /> Baneguide</span><span>→</span>
|
||||
</a>
|
||||
)}
|
||||
{facility.flyfoto_url && (
|
||||
<a href={facility.flyfoto_url} target="_blank" className={resourceBtnClass}>
|
||||
<span className="flex items-center gap-3"><Icon children={ICONS.camera} className="group-hover:text-white" /> Flyfoto</span><span>→</span>
|
||||
</a>
|
||||
)}
|
||||
{facility.webcam_url && (
|
||||
<a href={facility.webcam_url} target="_blank" className={resourceBtnClass}>
|
||||
<span className="flex items-center gap-3"><Icon children={ICONS.webcam} className="group-hover:text-white" /> Webkamera</span><span>→</span>
|
||||
</a>
|
||||
)}
|
||||
{shotzoom.map((sz: any, i: number) => (
|
||||
<a key={i} href={sz.shotzoom_url} target="_blank" className={resourceBtnClass}>
|
||||
<span className="flex items-center gap-3"><Icon children={ICONS.chart} className="group-hover:text-white" /> Statistikk: {sz.shotzoom_beskrivelse?.replace(/ ?/g, ' ').trim().toUpperCase()}</span><span>→</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm text-sm font-bold text-gray-700 flex flex-col">
|
||||
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter text-[#11280f]">Banen</h3>
|
||||
<div className="space-y-5 flex-grow">
|
||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Hull:</span><span>{amenities.antall_hull || '--'}</span></div>
|
||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Lengde:</span><span>{facility.length_meters ? `${facility.length_meters}m` : '--'}</span></div>
|
||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Sesong:</span><span>{facility.season || '--'}</span></div>
|
||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Byggeår:</span><span>{facility.established_year || '--'}</span></div>
|
||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Banetype:</span><span>{facility.banetype || 'Park/Skog'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-400">Arkitekt:</span><span className="text-right truncate ml-4">{facility.architect || '--'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm text-sm font-bold text-gray-700">
|
||||
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter text-[#11280f]">Andre Tilbud</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Drivingrange:</span><span>{amenities.drivingrange || 'Nei'}</span></div>
|
||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Nærspill:</span><span>{amenities.treningsgreen || 'Ja'}</span></div>
|
||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Proshop:</span><span className="text-right ml-4">{renderValue(amenities.proshop)}</span></div>
|
||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Kølleutleie:</span><span>{amenities.kolleutleie || 'Ja'}</span></div>
|
||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Bilutleie:</span><span>{amenities.bilutleie || 'Nei'}</span></div>
|
||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Simulator:</span><span className="text-right ml-4">{renderValue(amenities.simulator)}</span></div>
|
||||
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Kafé:</span><span className="text-right ml-4">{renderValue(amenities.kafe)}</span></div>
|
||||
|
||||
{/* Golfamore og NSG */}
|
||||
<div className="flex justify-between border-b border-gray-50 pb-2">
|
||||
<span className="text-gray-400">Golfamore:</span>
|
||||
<span className="text-right ml-4">
|
||||
{hasGolfamore ? <span className="text-[#ff5722] font-black">{golfamoreData.gyldighet || "Ja"}</span> : "Nei"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-gray-50 pb-2">
|
||||
<span className="text-gray-400">Seniorgolf (NSG):</span>
|
||||
<span className="text-right ml-4">
|
||||
{hasNSG && facility.nsg_url
|
||||
? <a href={facility.nsg_url} target="_blank" className="text-blue-600 font-black hover:underline">Ja (Vis Avtale)</a>
|
||||
: (hasNSG ? <span className="text-blue-600 font-black">Ja</span> : "Nei")
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* SAMARBEIDENDE KLUBBER */}
|
||||
{cooperatingClubs.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<span className="text-gray-400 block mb-2">Samarbeider med:</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{cooperatingClubs.map((slug: string) => (
|
||||
<Link key={slug} href={`/golfbaner/${slug}`} className="px-3 py-1 bg-gray-100 rounded-lg text-[10px] uppercase font-black tracking-widest hover:bg-[#8bc34a] hover:text-white transition-colors">
|
||||
{slug.replace('-golfklubb', '').replace(/-/g, ' ')}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 5. VÆR SEKSJON */}
|
||||
<section id="weather" className="bg-white p-0 md:p-12 md:rounded-[3rem] shadow-sm border-b md:border-none overflow-hidden text-center">
|
||||
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-[0.2em] py-8 md:py-0 md:mb-10 flex items-center justify-center gap-3"><Icon children={ICONS.weather} /> Vær for {facility.name}</h3>
|
||||
<div className="w-full flex justify-center px-4 md:px-0">
|
||||
{facility.weather_url ? ( <img src={weatherImg} className="w-full h-auto block max-w-5xl" alt="Vær" /> ) : <p className="text-center py-24 text-gray-300 italic text-sm">Værvarsel ikke tilgjengelig</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 6. KART SEKSJON */}
|
||||
<section id="map" className="space-y-6">
|
||||
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Kart <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
|
||||
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-xl h-[450px] md:h-[650px] border-y-4 md:border-[12px] border-white bg-gray-100">
|
||||
<iframe width="100%" height="100%" style={{ border: 0 }} src={`https://maps.google.com/maps?q=${facility.lat},${facility.lng}&t=k&z=15&ie=UTF8&iwloc=&output=embed`} allowFullScreen />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 7. VIDEO SEKSJON */}
|
||||
{facility.video_url && (
|
||||
<section id="video" className="space-y-6">
|
||||
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Video <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
|
||||
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-2xl aspect-video bg-black border-y-4 md:border-[12px] border-white">
|
||||
<iframe src={facility.video_url} className="w-full h-full" allowFullScreen />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 8. PRISER & GJESTESPILL */}
|
||||
<section id="prices" className="grid grid-cols-1 lg:grid-cols-2 gap-0 md:gap-8">
|
||||
<div className="bg-white p-10 md:p-14 md:rounded-[3rem] shadow-sm">
|
||||
<h3 className="text-2xl font-black mb-10 uppercase tracking-tighter">Gjestespill</h3>
|
||||
<div className="space-y-10">
|
||||
{Object.keys(groupedGreenfee).length > 0 ? (
|
||||
Object.entries(groupedGreenfee).map(([bane, priser], idx) => (
|
||||
<div key={idx} className="space-y-4">
|
||||
{!(bane === "Gjestespill" && Object.keys(groupedGreenfee).length === 1) && (
|
||||
<h4 className="text-lg font-black uppercase tracking-tighter text-[#11280f] border-b-2 border-gray-50 pb-2">{bane}</h4>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Voksne</p>
|
||||
{priser.map((g, i) => (
|
||||
<div key={i} className="flex justify-between py-2 border-b border-gray-50/50 text-sm font-bold">
|
||||
<span className="text-gray-500">{g.priskategori}</span>
|
||||
<span>kr {g.pris_voksne || '--'},-</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{priser.some(g => g.pris_junior) && (
|
||||
<div className="space-y-2 pt-4">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Junior</p>
|
||||
{priser.map((g, i) => (
|
||||
<div key={i} className="flex justify-between py-2 border-b border-gray-50/50 text-sm font-bold">
|
||||
<span className="text-gray-500">{g.priskategori}</span>
|
||||
<span>kr {g.pris_junior || '--'},-</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : <p className="text-gray-400 italic py-6">Ingen priser funnet.</p>}
|
||||
</div>
|
||||
<p className="mt-10 text-[10px] text-gray-300 font-black uppercase tracking-widest italic">Krav: {facility.guest_requirements || 'Klubbhandicap'}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-10 md:p-14 md:rounded-[3rem] shadow-sm flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 className="text-2xl font-black mb-10 uppercase tracking-tighter">Medlemskap</h3>
|
||||
<div className="bg-[#f1f7ed] p-12 md:rounded-[2.5rem] mb-8 text-center border border-[#7ca982]/10">
|
||||
<p className="text-[#7ca982] text-[11px] font-black uppercase mb-3 tracking-widest">{facility.navn_standard_medlemskap || "Standard"}</p>
|
||||
<p className="text-6xl font-black text-[#11280f]">kr {facility.standard_medlemskap || '--'},-</p>
|
||||
{facility.standard_medlemskap_kommentarer && <p className="text-[10px] text-gray-400 mt-4 uppercase font-bold italic leading-tight">{facility.standard_medlemskap_kommentarer}</p>}
|
||||
</div>
|
||||
{facility.navn_rimeligste_alternativ && (
|
||||
<div className="px-8 py-5 bg-gray-50 rounded-2xl border border-gray-100 flex justify-between items-center text-sm font-bold"><span className="text-gray-400 uppercase text-[10px] tracking-widest">{facility.navn_rimeligste_alternativ}</span><span className="text-[#11280f]">kr {facility.rimeligste_alternativ},-</span></div>
|
||||
)}
|
||||
</div>
|
||||
<a href={facility.medlemskap_url} target="_blank" className="mt-10 block w-full text-center bg-[#11280f] text-white p-7 rounded-2xl font-black uppercase text-xs tracking-widest shadow-xl hover:bg-black transition-all">Se alle alternativer</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 9. SCOREKORT SEKSJON */}
|
||||
<section id="scorecards" className="pt-10 space-y-20 overflow-hidden">
|
||||
<h3 className="text-center text-3xl md:text-5xl font-black uppercase tracking-tighter">Scorekort</h3>
|
||||
<div className="w-full flex flex-col items-center gap-20">
|
||||
{activeCourses.map((c: any) => (
|
||||
<div key={c.id} className="w-full overflow-x-auto md:overflow-visible no-scrollbar">
|
||||
<div className="min-w-[800px] md:min-w-0"><CourseDisplay course={c} /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{showBackToTop && (
|
||||
<button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className="fixed bottom-8 right-8 w-14 h-14 bg-[#11280f] text-white rounded-full shadow-2xl flex items-center justify-center text-2xl z-[100] border-4 border-white/20 hover:scale-110 transition-all">↑</button>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
// page.tsx
|
||||
import { API_URL } from "@/config/constants";
|
||||
import FacilityDetailView from "./FacilityDetailView";
|
||||
|
||||
export default async function GolfCoursePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
|
||||
const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: 'no-store' });
|
||||
const facility = await res.json();
|
||||
|
||||
if (!facility || facility.error) {
|
||||
return <div className="p-20 text-center font-bold text-2xl">Fant ikke golfbanen...</div>;
|
||||
}
|
||||
|
||||
// Vi sender dataene til den navngitte komponenten
|
||||
return <FacilityDetailView facility={facility} />;
|
||||
}
|
||||
19
kode_eksport_1/frontend_src_app_layout_tsx.txt
Normal file
19
kode_eksport_1/frontend_src_app_layout_tsx.txt
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import Header from "@/components/Header";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "TeeOff.no - Din guide til norske golfbaner",
|
||||
description: "Oppdatert banestatus, priser og informasjon om alle norske golfanlegg.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="nb">
|
||||
<body className="antialiased bg-[#f1f7ed]">
|
||||
<Header />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
40
kode_eksport_1/frontend_src_app_page_tsx.txt
Normal file
40
kode_eksport_1/frontend_src_app_page_tsx.txt
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import HeroSlider from './HeroSlider';
|
||||
import FacilitySearch from './FacilitySearch';
|
||||
import { API_URL } from '@/config/constants';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function Home() {
|
||||
let facilities = [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/facilities`, {
|
||||
next: { revalidate: 0 },
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
console.error("API Error Body:", errorData);
|
||||
throw new Error(`API returnerte status ${res.status}`);
|
||||
}
|
||||
|
||||
facilities = await res.json();
|
||||
} catch (error) {
|
||||
console.error("Kritisk feil ved henting av data:", error);
|
||||
facilities = [];
|
||||
}
|
||||
|
||||
// Sikrer at vi alltid sender en array til komponentene
|
||||
const safeData = Array.isArray(facilities) ? facilities : [];
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#f1f7ed]">
|
||||
{/* Wrapper slideren i en div som skjuler den på mobil (hidden) og viser den på PC (md:block) */}
|
||||
<div className="hidden md:block">
|
||||
<HeroSlider facilities={safeData} />
|
||||
</div>
|
||||
<FacilitySearch initialFacilities={safeData} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
45
kode_eksport_1/frontend_src_components_Header_tsx.txt
Normal file
45
kode_eksport_1/frontend_src_components_Header_tsx.txt
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Header() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-[100] bg-white/95 backdrop-blur-md border-b border-gray-100 shadow-sm">
|
||||
<div className="max-w-[1400px] mx-auto px-6 h-20 flex items-center justify-between">
|
||||
|
||||
{/* LOGO */}
|
||||
<Link href="/" className="h-10 md:h-12 transition-transform hover:scale-105 active:scale-95">
|
||||
<img src="/TeeOff-logo-Retina-1.png" alt="TeeOff.no" className="h-full w-auto object-contain" />
|
||||
</Link>
|
||||
|
||||
{/* DESKTOP NAV */}
|
||||
<nav className="hidden md:flex items-center gap-8 text-[11px] font-black uppercase tracking-widest text-gray-500">
|
||||
<Link href="/" className="hover:text-[#8bc34a]">Hjem</Link>
|
||||
<Link href="/golfbaner" className="hover:text-[#8bc34a]">Finn Bane</Link>
|
||||
<Link href="/medlemskap" className="hover:text-[#8bc34a]">Medlemskap</Link>
|
||||
<Link href="/om-oss" className="hover:text-[#8bc34a]">Om oss</Link>
|
||||
<Link href="/admin/login" className="px-5 py-2 bg-[#ff5722] text-white rounded-xl hover:bg-black transition-all font-black uppercase text-[10px] tracking-widest">Admin</Link>
|
||||
</nav>
|
||||
|
||||
{/* HAMBURGER (Mobil) */}
|
||||
<button onClick={() => setIsOpen(!isOpen)} className="md:hidden p-2 text-[#11280f]">
|
||||
<div className="w-6 h-0.5 bg-current mb-1.5 transition-all"></div>
|
||||
<div className="w-6 h-0.5 bg-current mb-1.5"></div>
|
||||
<div className="w-6 h-0.5 bg-current"></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* MOBIL MENY OVERLAY */}
|
||||
{isOpen && (
|
||||
<div className="md:hidden absolute top-20 left-0 w-full bg-white border-b border-gray-100 p-6 flex flex-col gap-6 shadow-2xl animate-in slide-in-from-top duration-300">
|
||||
<Link onClick={() => setIsOpen(false)} href="/" className="text-lg font-black uppercase text-[#11280f]">Hjem</Link>
|
||||
<Link onClick={() => setIsOpen(false)} href="/golfbaner" className="text-lg font-black uppercase text-[#11280f]">Finn Bane</Link>
|
||||
<Link onClick={() => setIsOpen(false)} href="/medlemskap" className="text-lg font-black uppercase text-[#11280f]">Medlemskap</Link>
|
||||
<Link onClick={() => setIsOpen(false)} href="/logg-inn" className="text-[#ff5722] font-black uppercase">Admin Logg inn</Link>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
// Tilpass interface til de dataene du allerede har i frontend
|
||||
interface Facility {
|
||||
id: number;
|
||||
scrape_method?: string;
|
||||
scrape_status_url?: string;
|
||||
scrape_status_selector?: string;
|
||||
}
|
||||
|
||||
export default function ScrapeMethodSelect({ facility }: { facility: Facility }) {
|
||||
// Setter standardverdi til 'css_selector' hvis den er tom i databasen
|
||||
const [method, setMethod] = useState(facility.scrape_method || 'css_selector');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [statusColor, setStatusColor] = useState('bg-transparent'); // For å gi visuell feedback
|
||||
|
||||
const handleMethodChange = async (newMethod: string) => {
|
||||
setMethod(newMethod);
|
||||
setIsLoading(true);
|
||||
setStatusColor('bg-yellow-200'); // Lyser gult mens den lagrer
|
||||
|
||||
try {
|
||||
// Husk å endre URL-en hvis API-et ditt ligger på et annet domene
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/api/admin/facilities/${facility.id}/scrape-settings`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Hvis du bruker JWT i headers i stedet for cookies, legg det til her:
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
scrape_method: newMethod,
|
||||
scrape_status_url: facility.scrape_status_url, // Beholder eksisterende
|
||||
scrape_status_selector: facility.scrape_status_selector // Beholder eksisterende
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Feil ved lagring');
|
||||
}
|
||||
|
||||
// Suksess! Lyser grønt et kort sekund
|
||||
setStatusColor('bg-green-300');
|
||||
setTimeout(() => setStatusColor('bg-transparent'), 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setStatusColor('bg-red-300'); // Lyser rødt ved feil
|
||||
alert("Kunne ikke oppdatere skrapemetode.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<select
|
||||
value={method}
|
||||
onChange={(e) => handleMethodChange(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className={`border rounded p-1 text-sm transition-colors duration-300 ${statusColor} ${isLoading ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<option value="css_selector">Standard (CSS)</option>
|
||||
<option value="llm_parse">✨ Gemini AI (LLM)</option>
|
||||
<option value="iframe_golfbox">Golfbox iframe</option>
|
||||
<option value="click_then_css">Auto-klikk + CSS</option>
|
||||
<option value="">Ingen (Avslått)</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
42
kode_eksport_1/frontend_src_config_constants_ts.txt
Normal file
42
kode_eksport_1/frontend_src_config_constants_ts.txt
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* TEE OFF CONFIG CONSTANTS v1.3
|
||||
* ---------------------------------------------------------------------------
|
||||
* REGEL 1: ALDRI trunker eller fjern data fra denne filen.
|
||||
* REGEL 2: Håndterer både intern Docker-kommunikasjon og ekstern browser-kommunikasjon.
|
||||
* REGEL 3: Inneholder alle regionale mappinger for Norge.
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const isBrowser = typeof window !== 'undefined';
|
||||
|
||||
// Intern URL for server-to-server (Docker-internt)
|
||||
const INTERNAL_API = process.env.API_URL || "http://api:8000/api";
|
||||
|
||||
// Relativ sti for browseren.
|
||||
// Ved å bruke '/api' sørger vi for at nettleseren bruker samme protokoll (https)
|
||||
// og domene (nye.teeoff.no) som resten av siden.
|
||||
const EXTERNAL_API = "/api";
|
||||
|
||||
export const API_URL = isBrowser ? EXTERNAL_API : INTERNAL_API;
|
||||
|
||||
export const FALLBACK_IMAGE = "/Toppbilde-standard.jpg";
|
||||
export const TEEOFF_LOGO = "/TeeOff-logo-Retina-1.png";
|
||||
|
||||
export const STATUS_MAP: Record<string, string> = {
|
||||
"ukjent": "Ukjent status",
|
||||
"aapen": "Åpen",
|
||||
"aapen_med_vintergreener": "Vintergreener",
|
||||
"stengt": "Stengt",
|
||||
"nedlagt": "Nedlagt",
|
||||
"under_utvikling": "Under utvikling",
|
||||
"aapner_snart": "Åpner snart",
|
||||
"stenger_snart": "Stenger snart"
|
||||
};
|
||||
|
||||
export const REGIONS: Record<string, string[]> = {
|
||||
"nord-norge": ["finnmark", "troms", "nordland"],
|
||||
"midt-norge": ["nord-trøndelag", "sør-trøndelag", "trøndelag"],
|
||||
"vestlandet": ["møre og romsdal", "sogn og fjordane", "hordaland", "rogaland", "vestland"],
|
||||
"sørlandet": ["vest-agder", "aust-agder", "agder"],
|
||||
"østlandet": ["telemark", "vestfold", "østfold", "buskerud", "hedmark", "oppland", "oslo", "akershus", "innlandet", "viken"]
|
||||
};
|
||||
36
kode_eksport_1/frontend_src_middleware_ts.txt
Normal file
36
kode_eksport_1/frontend_src_middleware_ts.txt
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* TEE OFF SECURITY MIDDLEWARE v1.1
|
||||
* ---------------------------------------------------------------------------
|
||||
* REGEL: Beskytter alle ruter under /admin (unntatt /admin/login).
|
||||
* FUNKSJON: Sjekker for admin_session cookie og omdirigerer hvis den mangler.
|
||||
* RETTING: Flyttet NextRequest til next/server for å fikse build-error.
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const session = request.cookies.get('admin_session');
|
||||
|
||||
// 1. Tillat alltid tilgang til innloggingssiden
|
||||
if (pathname.startsWith('/admin/login')) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 2. Beskytt alle andre ruter under /admin
|
||||
if (pathname.startsWith('/admin')) {
|
||||
if (!session) {
|
||||
// Ingen sesjon funnet -> Send til innlogging
|
||||
const loginUrl = new URL('/admin/login', request.url);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Definer hvilke ruter middleware skal kjøre på
|
||||
export const config = {
|
||||
matcher: ['/admin/:path*'],
|
||||
};
|
||||
44
kode_eksport_1/test_tjome_py.txt
Normal file
44
kode_eksport_1/test_tjome_py.txt
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import asyncio
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
async def main():
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
page = await browser.new_page()
|
||||
print("🔍 Går til Tjøme Golfklubb...")
|
||||
await page.goto('https://tjomegolfklubb.no/', wait_until="domcontentloaded")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
btn_count = await page.locator("a:has-text('Banestatus')").count()
|
||||
print(f"🤖 Fant {btn_count} lenker med teksten 'Banestatus'.")
|
||||
|
||||
try:
|
||||
# Tvinger roboten til å velge den knappen som faktisk er SYNLIG på skjermen
|
||||
btn = page.locator("a:has-text('Banestatus'):visible").first
|
||||
await btn.click(timeout=5000)
|
||||
print("🖱️ Klikket på den synlige Banestatus-knappen!")
|
||||
await asyncio.sleep(2)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Klarte ikke klikke: {str(e).splitlines()[0]}")
|
||||
|
||||
# Henter ut både synlig tekst og "skjult" tekst i koden
|
||||
synlig_tekst = await page.locator("body").inner_text()
|
||||
all_tekst = await page.locator("body").text_content()
|
||||
|
||||
print("\n--- RESULTAT ---")
|
||||
if "stengt" in synlig_tekst.lower():
|
||||
print("✅ Suksess! Fant ordet 'stengt' i den SYNLIGE teksten.")
|
||||
elif "stengt" in all_tekst.lower():
|
||||
print("🫣 Fant ordet 'stengt' gjemt i HTML-koden (Panelet åpnet seg ikke skikkelig for roboten).")
|
||||
idx = all_tekst.lower().find("stengt")
|
||||
# Fjerner linjeskift for penere utskrift
|
||||
utdrag = all_tekst[max(0, idx-30):idx+80].replace('\n', ' ')
|
||||
print(f" Tekstutdrag: '...{utdrag}...'")
|
||||
else:
|
||||
print("❌ Fant verken 'stengt' eller 'åpen' på hele siden.")
|
||||
print(f" (Teksten den leste startet slik: {synlig_tekst[:80].replace(chr(10), ' ')}...)")
|
||||
print("----------------\n")
|
||||
|
||||
await browser.close()
|
||||
|
||||
asyncio.run(main())
|
||||
Loading…
Reference in a new issue