Før ny dag

This commit is contained in:
Erol 2026-03-06 09:39:32 +01:00
parent 5e94ce0755
commit 1d8ce4c52d
29 changed files with 89 additions and 3133 deletions

View file

@ -1,9 +1,6 @@
📁 teeoff/
📄 test_tjome.py
📄 fil-tre.txt
📄 struktur2_dump.txt
📄 seed.sql
📄 struktur3_dump.txt
📄 eksport_script.py
📄 update_golfbox.sql
📄 docker-compose.yml
@ -663,6 +660,7 @@
📄 struktur_dump.txt
📄 middleware.ts
📁 components/
📄 ScrapeMethodSelect.tsx
📄 Header.tsx
📁 config/
📄 constants.ts
@ -684,34 +682,29 @@
📄 page.tsx
📁 kode_eksport_1/
📄 frontend_src_components_Header_tsx.txt
📄 backend_scrape_nsg_3_py.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
📄 frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt
📄 frontend_next_config_ts.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_page_tsx.txt
📄 frontend_src_app_HeroSlider_tsx.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
📄 .env
📄 test_login.py
📄 sync_greenfee.py
📄 scrape_status.py
📄 scrape_golfamore1.3.py

View file

@ -1,13 +1,13 @@
"use client";
/**
* TEE OFF ADMIN DASHBOARD v1.7 - RESPONSIVT MED AI-HVISKER & KILL SWITCH
* TEE OFF ADMIN DASHBOARD v1.8 - RESPONSIVT MED AI-HVISKER, KILL SWITCH & FILTER
* ---------------------------------------------------------------------------
* PLASSERING: frontend/src/app/admin/page.tsx
* FUNKSJON: Live-oppdatering, massevalg, redigering og SAMMENTREKKBAR meny.
* FUNKSJON: Live-oppdatering, massevalg, redigering, meny og smart-filtrering.
* ---------------------------------------------------------------------------
*/
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { API_URL } from "@/config/constants";
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
@ -16,13 +16,12 @@ export default function AdminDashboard() {
const [loading, setLoading] = useState(true);
const [selectedFacilities, setSelectedFacilities] = useState<number[]>([]);
const [isScraping, setIsScraping] = useState(false);
// NY: State for å sjekke om sidebaren er klappet sammen
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [editingFacility, setEditingFacility] = useState<any | null>(null);
// OPPDATERT: Lagt til ai_instruction og courses for manuell overstyring
// 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: '',
@ -57,9 +56,44 @@ export default function AdminDashboard() {
return () => clearInterval(interval);
}, [isScraping]);
// NYTT: 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);
// 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
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedFacilities(facilities.map(f => f.id));
setSelectedFacilities(filteredFacilities.map(f => f.id));
} else {
setSelectedFacilities([]);
}
@ -73,8 +107,7 @@ export default function AdminDashboard() {
}
};
const handleRunScrapers = async () => {
// Hvis den allerede skraper, lar vi brukeren trykke på knappen for å avbryte UI-oppdateringen
const handleRunScrapers = async () => {
if (isScraping) {
setIsScraping(false);
return;
@ -87,11 +120,10 @@ const handleRunScrapers = async () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ facility_ids: selectedFacilities })
});
if (!response.ok) throw new Error("Kunne ikke starte skraping");
// Beregn dynamisk timeout: 40 sekunder per valgte anlegg (Minimum 1 minutt)
const timeoutMs = Math.max(selectedFacilities.length * 40 * 1000, 60000);
setSelectedFacilities([]);
setTimeout(() => setIsScraping(false), timeoutMs);
} catch (error) {
@ -101,7 +133,6 @@ const handleRunScrapers = async () => {
}
};
// OPPDATERT: Laster inn eksisterende banestatuser og ai_instruction
const openEditModal = (facility: any) => {
setEditingFacility(facility);
setEditForm({
@ -121,6 +152,7 @@ const handleRunScrapers = async () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm)
});
if (!response.ok) throw new Error("Feil ved lagring");
setEditingFacility(null);
@ -143,7 +175,7 @@ const handleRunScrapers = async () => {
<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">Rediger Konfigurasjon</h3>
<p className="text-sm text-[#7ca982]">{editingFacility.name}</p>
</div>
@ -172,9 +204,8 @@ const handleRunScrapers = async () => {
<option value="click_then_css">Auto-klikk + CSS</option>
<option value="manual">🚨 Manuell (Ikke skrap)</option>
</select>
</div>
</div>
{/* Boks som vises hvis Gemini AI er valgt */}
{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>
@ -189,7 +220,6 @@ const handleRunScrapers = async () => {
</div>
)}
{/* Boks som vises hvis MANUELL er valgt */}
{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>
@ -219,9 +249,8 @@ const handleRunScrapers = async () => {
))}
</div>
</div>
)}
)}
{/* Boks for gammel CSS-skraping */}
{(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>
@ -247,7 +276,7 @@ const handleRunScrapers = async () => {
</div>
)}
{/* NY SIDEBAR: Responsiv og sammentrekkbar */}
{/* 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>}
@ -275,21 +304,19 @@ const handleRunScrapers = async () => {
</div>
</aside>
{/* HOVEDINNHOLD: flex-1 og min-w-0 løser responsivitetsproblemet */}
{/* HOVEDINNHOLD */}
<main className="flex-1 min-w-0 p-4 md:p-8 lg:p-10 h-screen overflow-y-auto">
{/* Mobil-meny (Vises bare på veldig små skjermer) */}
<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>
{/* Mindre padding på mobil/tablet (p-6) for å spare plass */}
<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-10">
<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 {facilities.length} anlegg</p>
<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 {filteredFacilities.length} anlegg (av {facilities.length})</p>
</div>
<button
@ -305,16 +332,32 @@ const handleRunScrapers = async () => {
</button>
</header>
{/* Her er den viktige overflow-klassen for selve tabellen */}
{/* NYTT: Filter-meny UI */}
<div className="flex flex-wrap items-center gap-4 bg-gray-50 p-4 rounded-2xl border border-gray-100 mb-8">
<label htmlFor="statusFilter" className="text-xs font-bold text-gray-500 uppercase tracking-widest">Filtrer status:</label>
<select
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>
<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 === facilities.length && facilities.length > 0}
checked={selectedFacilities.length === filteredFacilities.length && filteredFacilities.length > 0}
onChange={handleSelectAll}
/>
</th>
@ -327,8 +370,8 @@ const handleRunScrapers = async () => {
</tr>
</thead>
<tbody className="text-sm font-bold text-[#11280f]">
{facilities.map((f: any) => (
<tr key={f.id} className="border-b border-gray-50 group hover:bg-gray-50/50 transition-colors">
{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"
@ -341,14 +384,14 @@ const handleRunScrapers = async () => {
<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">
<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} />
<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'}
@ -362,30 +405,30 @@ const handleRunScrapers = async () => {
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">
<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>
<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">
<button
onClick={() => openEditModal(f)}
onClick={() => openEditModal(f)}
className="bg-gray-100 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-[#11280f] hover:text-white transition-all whitespace-nowrap"
>
Rediger
Rediger
</button>
</td>
</tr>
))}
</tbody>
</table>
</table>
</div>
</div>
</main>

View file

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

View file

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

View file

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

View file

@ -1,253 +0,0 @@
"""
TEE OFF BACKEND API v3.6.8 - THE RESTORED MASTER VERSION
---------------------------------------------------------------------------
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
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
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"
# VIKTIG: Vi bruker PBKDF2-SHA256 for å unngå Bcrypt-begrensninger
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
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)
# 1. Håndter dato- og tidsformater for JSON-serialisering
for key in ['status_updated_at', 'created_at']:
if isinstance(d.get(key), (date, datetime)):
d[key] = d[key].isoformat()
# 2. Definer alle felter som inneholder JSON-data
json_list_fields = [
'course_statuses', 'courses', 'gallery', 'greenfee',
'faqs', 'shotzoom', 'social_links', 'holes'
]
json_dict_fields = [
'amenities', 'vtg', 'nsg_data', 'golfamore_data'
]
# Vask list-felter
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] = []
# Vask objekt-felter
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
@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.6.8", 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]}...)")
# FIKS: Vi pakker KUN selve verify-sjekken inn i try/except
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")
# FIKS: 401 kastes nå UTENFOR try-blokken, slik at vi unngår 500-krasj
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 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)
@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)}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View file

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

View file

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

View file

@ -1,174 +0,0 @@
import asyncio
import os
import asyncpg
import smtplib
import re
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 dotenv import load_dotenv
load_dotenv()
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
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}")
async def run_daily_scraping():
print(f"🚀 Starter sjekk {datetime.now().strftime('%H:%M:%S')}...")
conn = await asyncpg.connect(DB_URL)
facilities = await conn.fetch("SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method FROM facilities WHERE scrape_status_url IS NOT NULL")
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:
page = await context.new_page()
try: await apply_stealth(page)
except: pass
try:
print(f"🔍 Besøker {f['name']}...")
# Endret fra networkidle til domcontentloaded for å unngå Arendal-timeout
await page.goto(f['scrape_status_url'], timeout=60000, wait_until="domcontentloaded")
await asyncio.sleep(3) # Gir Javascript 3 sekunder på å bygge siden
full_text = ""
method = f.get('scrape_method') or 'css_selector'
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':
# Vi forventer formatet: "knappe_selector||tekst_selector"
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
# 1. Finn og klikk på knappen
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()
# 2. Vent 2 sekunder så animasjonen (sidepanelet) rekker å bli ferdig
await asyncio.sleep(2)
# 3. Les av teksten
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()
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'])
for c in courses:
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 på siden.")
continue
old_status = c['status'] or "ukjent"
if new_status != old_status and new_status != "ukjent":
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"✅ Oppdatert status for {f['name']} - {c['name']}")
else:
successes.append(f"✅ {f['name']} ({c['name']}): {new_status.upper()}")
print(f" - {c['name']}: Ingen endring ({new_status.upper()})")
except Exception as e:
# Trekker ut kun første linje av feilmeldingen for å unngå massiv og stygg tekst i e-posten
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__":
asyncio.run(run_daily_scraping())

View file

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

View file

@ -1,52 +0,0 @@
"""
TEE OFF ADMIN PASSWORD UPDATER
---------------------------------------------------------------------------
FUNKSJON: Genererer SQL-kommando for å KUN oppdatere passord for en eksisterende admin.
STATUS: Påvirker IKKE tofaktor (2FA), e-post eller andre brukere.
---------------------------------------------------------------------------
"""
from passlib.hash import pbkdf2_sha256
import getpass
import sys
def update_admin_password():
print("\n" + "="*50)
print(" TEE OFF ADMIN PASSORD-OPPDATERER")
print("="*50)
username = input("Brukernavn på admin som skal oppdateres: ").strip()
safe_username = username.replace("'", "''")
# 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("\n✅ GENERERING VELLYKKET!")
print("-" * 50)
print("SLIK OPPDATERER DU PASSORDET 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 denne linjen nøyaktig slik den står:")
print(f"UPDATE admins SET password_hash = '{password_hash}' WHERE username = '{safe_username}';")
print("\n3. Skriv 'exit' for å gå ut.")
print("-" * 50 + "\n")
if __name__ == "__main__":
try:
update_admin_password()
except KeyboardInterrupt:
print("\nAvbrutt.")
sys.exit(0)

View file

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

View file

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

View file

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

View file

@ -1,151 +0,0 @@
"use client";
/**
* TEE OFF SYSTEM INSTRUCTIONS - FACILITY CARDS v3.4 (STABLE)
* ---------------------------------------------------------------------------
* REGEL 1: Status-badge SKAL vises øverst til venstre. Bruk STATUS_MAP for tekst.
* REGEL 2: DATA-PARSING: Bruk parseJson() for 'course_statuses', 'amenities' og 'nsg_data'
* fordi API-et ofte returnerer disse som strenger.
* 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: LOSBY-LOGIKK: Sjekk alle baner i arrayen. Hvis én er åpen, vis 'aapen'.
* ---------------------------------------------------------------------------
*/
import { STATUS_MAP } 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 [];
return initialFacilities.map(f => {
// --- ROBUST DATA-PARSING (Håndterer tekst vs objekt fra API) ---
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 statuses = parseJson(f.course_statuses, []);
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;
const words = searchQuery.toLowerCase().trim().split(/\s+/).filter(w => w.length > 0);
const blob = `${f.name} ${f.city} ${f.county}`.toLowerCase();
const matches = words.every(w => blob.includes(w));
return { ...f, statuses, 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">
{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...' 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) => {
// --- STATUS LOGIKK ---
const sArr = Array.isArray(f.statuses) ? f.statuses : [];
const activeStatus = sArr.find((s:any) => s.status === 'aapen') || sArr[0] || { status: 'ukjent' };
const rawStatus = (activeStatus.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 (
<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 Badge */}
<div className={`absolute top-5 left-5 ${statusColor} text-white px-4 py-1.5 rounded-xl text-[10px] font-black uppercase shadow-xl z-20`}>
{STATUS_MAP[rawStatus] || rawStatus}
</div>
{/* Avstandspille (Mørk oliven #2d3319) */}
{f.dist !== Infinity && (
<div className="absolute bottom-5 right-5 bg-[#2d3319] text-white px-4 py-2 rounded-2xl text-[10px] font-black shadow-lg z-20">
{Math.round(f.dist)} km unna
</div>
)}
</div>
<div className="p-8 flex flex-col flex-grow">
<h3 className="font-black text-3xl text-[#11280f] mb-1 group-hover:text-[#8bc34a] transition-colors leading-tight">{f.name}</h3>
<p className="text-gray-400 text-[11px] font-bold uppercase tracking-widest mb-8">{f.city} • {f.county}</p>
<div className="mt-auto flex items-center justify-between">
<div className="flex items-center gap-2">
{/* Hull-pille */}
<span className="bg-[#f1f7ed] text-[#8bc34a] px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest">
{f.amenities?.antall_hull || '--'} HULL
</span>
{/* Banetype-pille */}
<span className="bg-gray-50 text-gray-400 px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-gray-100">
{f.banetype || 'SKOGSBANE'}
</span>
</div>
{/* Sirkel-ikoner (NSG / Golfamore) */}
<div className="flex gap-2">
{f.hasNSG && (
<div className="w-9 h-9 bg-blue-600 text-white rounded-full flex items-center justify-center font-black text-sm shadow-lg border-2 border-white translate-y-1">N</div>
)}
{f.hasGolfamore && (
<div className="w-9 h-9 bg-[#ff5722] text-white rounded-full flex items-center justify-center font-black text-sm shadow-lg border-2 border-white translate-y-1">G</div>
)}
</div>
</div>
</div>
</Link>
);
})}
</div>
</div>
);
}

View file

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

View file

@ -1,103 +0,0 @@
"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({...formData, 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({...formData, password: e.target.value})} required />
</>
) : (
<div className="space-y-4">
<p className="text-[10px] text-gray-400 font-black uppercase text-center tracking-widest">Tast inn 6 siffer fra appen din</p>
<input type="text" placeholder="000 000" className="w-full p-6 text-center text-4xl tracking-[0.3em] font-black bg-gray-50 rounded-3xl border-none ring-2 ring-[#ff5722]/20 outline-none focus:ring-[#ff5722] transition-all text-[#ff5722]" onChange={e => setFormData({...formData, code: e.target.value})} autoFocus required />
</div>
)}
{error && <div className="bg-red-50 p-4 rounded-xl text-red-600 text-[10px] font-black uppercase tracking-widest text-center border border-red-100">⚠️ {error}</div>}
<button type="submit" disabled={isLoading} className={`w-full p-6 rounded-2xl font-black uppercase text-xs tracking-widest text-white transition-all shadow-xl ${step === 1 ? 'bg-[#11280f]' : 'bg-[#ff5722]'}`}>
{isLoading ? "Venter..." : (step === 1 ? "Fortsett" : "Logg inn")}
</button>
</form>
</div>
</div>
);
}

View file

@ -1,91 +0,0 @@
"use client";
/**
* TEE OFF ADMIN DASHBOARD v1.1
* ---------------------------------------------------------------------------
* PLASSERING: frontend/src/app/admin/page.tsx
* FUNKSJON: Monitorering av banestatus og administrasjon.
* ---------------------------------------------------------------------------
*/
import { useState, useEffect } from 'react';
import { API_URL } from "@/config/constants";
export default function AdminDashboard() {
const [facilities, setFacilities] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_URL}/facilities`)
.then(res => res.json())
.then(data => {
setFacilities(Array.isArray(data) ? data : []);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
if (loading) return <div className="p-20 text-center font-black animate-pulse">LASTER DASHBORD...</div>;
return (
<div className="flex flex-col lg:flex-row min-h-screen bg-[#f1f7ed] font-sans">
{/* SIDEBAR (22%) */}
<aside className="lg:w-[22%] bg-[#11280f] text-white p-10 flex flex-col">
<h1 className="text-2xl font-black uppercase tracking-tighter mb-10">TeeOff Admin</h1>
<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] pl-4 py-1">Scraping Monitor</div>
<div className="hover:text-white cursor-pointer pl-4 py-1 transition-colors">Medlemskap</div>
<div className="hover:text-white cursor-pointer pl-4 py-1 transition-colors">Bildegalleri</div>
</nav>
<div className="mt-auto pt-10 border-t border-white/10">
<button onClick={() => window.location.href='/'} className="text-[10px] font-black uppercase tracking-widest text-red-400 hover:text-red-300">Logg ut</button>
</div>
</aside>
{/* HOVEDINNHOLD (78%) */}
<main className="lg:w-[78%] p-6 md:p-12">
<div className="bg-white rounded-[3rem] shadow-2xl p-10 md:p-16 border border-white">
<header className="flex justify-between items-center mb-12">
<div>
<h2 className="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å {facilities.length} anlegg</p>
</div>
<button className="bg-[#8bc34a] text-white px-8 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl hover:scale-105 transition-all">Kjør alle skrapere</button>
</header>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-300 border-b border-gray-50">
<th className="pb-6">Anlegg</th>
<th className="pb-6">Konfigurasjon</th>
<th className="pb-6">Siste Sjekk</th>
<th className="pb-6 text-right">Status</th>
</tr>
</thead>
<tbody className="text-sm font-bold text-[#11280f]">
{facilities.map((f: any) => (
<tr key={f.id} className="border-b border-gray-50 group hover:bg-gray-50/50 transition-colors">
<td className="py-8">
<div className="font-black text-lg">{f.name}</div>
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
</td>
<td className="py-8">
<div className="text-[11px] text-blue-600 truncate max-w-[200px] mb-1">{f.scrape_status_url}</div>
<div className="text-[10px] font-mono text-gray-300">{f.scrape_status_selector}</div>
</td>
<td className="py-8 text-gray-400 font-mono text-xs">
{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
</td>
<td className="py-8 text-right">
<button className="bg-gray-100 px-5 py-2.5 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-[#11280f] hover:text-white transition-all">Rediger</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</main>
</div>
);
}

View file

@ -1,196 +0,0 @@
"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);
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">Par {course.par} • {course.length_meters || '--'} meter</p>
</div>
<div className="flex items-center gap-6 bg-gray-50 p-6 rounded-[2.5rem] border border-gray-100">
<div className="flex flex-col"><span className="text-[9px] font-black text-[#7ca982] uppercase ml-1">Kjønn</span>
<select value={gender} onChange={e => { setGender(e.target.value as any); setSelectedTeeIndex(0); }} className="bg-transparent text-[#11280f] font-black outline-none border-b-2 border-[#7ca982]/30 pb-1 cursor-pointer">
<option value="herrer">HERRER</option><option value="damer">DAMER</option>
</select>
</div>
<div className="flex flex-col"><span className="text-[9px] font-black text-[#7ca982] uppercase ml-1">Utslag</span>
<select value={selectedTeeIndex} onChange={e => setSelectedTeeIndex(Number(e.target.value))} className="bg-transparent text-[#11280f] font-black outline-none border-b-2 border-[#7ca982]/30 pb-1 cursor-pointer">
{availableTees.map((t: any, i: number) => (<option key={i} value={i}>{t.navn_utslag || t.navn_utslag_damer}</option>))}
</select>
</div>
<div className="flex flex-col"><span className="text-[9px] font-black text-[#7ca982] uppercase ml-1">Ditt HCP</span>
<input type="text" value={hcp} onChange={e => setHcp(e.target.value)} className="w-12 bg-transparent text-[#11280f] font-black text-center border-b-2 border-[#7ca982]/30" />
</div>
<div className="pl-6 border-l border-gray-200 text-center">
<p className="text-[9px] uppercase font-black text-[#7ca982] mb-1">SpH</p>
<p className="text-4xl font-black text-[#11280f] leading-none">{playingHandicap || 0}</p>
</div>
</div>
</div>
{/* SCOREKORT TABELL */}
<div className="overflow-x-auto">
<table className="w-full text-center border-collapse table-fixed min-w-[850px]">
<thead>
<tr className="bg-white text-[10px] text-gray-400 font-black uppercase">
<th className="w-20 p-5 text-left pl-10 border-b border-gray-100">Hull</th>
<th className="w-16 p-5 border-l border-gray-100 border-b border-gray-100">Par</th>
<th className="w-16 p-5 border-l border-gray-100 border-b border-gray-100">HCP</th>
<th className="w-24 p-5 border-l border-gray-100 border-b border-gray-100 bg-[#7ca982]/10 text-[#7ca982]">Mottatt</th>
<th className="w-24 p-5 border-l border-gray-100 border-b border-gray-100 bg-[#7ca982]/20 text-[#11280f]">Din Par</th>
{activeColumns.map((col, i) => (
<th key={i} className={`p-5 border-l border-white font-black ${col.theme.header}`}>{col.label}</th>
))}
</tr>
</thead>
<tbody className="font-bold text-[#11280f]">
{/* UT-RUNDE */}
{holesOut.map((h: any) => {
const extra = getExtraStrokes(h.hcp_index);
return (
<tr key={h.id} className="border-t border-gray-100 group hover:bg-white transition-colors">
<td className="p-4 text-left pl-10 font-black text-lg text-gray-800">{h.hole_number}</td>
<td className="p-4 border-l border-gray-100 bg-white">{h.par}</td>
<td className="p-4 border-l border-gray-100 text-gray-300 text-xs font-mono">{h.hcp_index}</td>
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/5 text-[#7ca982] font-mono">{extra > 0 ? `+${extra}` : '-'}</td>
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/10 text-lg font-mono">{h.par + extra}</td>
{activeColumns.map((col, i) => (
<td key={i} className={`p-4 border-l border-white font-mono transition-all ${col.theme.col} ${col.theme.text}`}>
{h.lengths?.[col.key] || '--'}
</td>
))}
</tr>
);
})}
{/* UT RAD */}
<tr className="bg-[#f1f7ed]/50 text-[#11280f] font-black border-y border-gray-200">
<td className="p-4 text-left pl-10 uppercase tracking-widest text-[10px] text-gray-400">Ut</td>
<td className="p-4 border-l border-gray-100">{sumPar(holesOut)}</td>
<td colSpan={3} className="border-l border-gray-100 bg-white"></td>
{activeColumns.map((col, i) => (
<td key={i} className={`p-4 border-l border-white font-mono ${col.theme.col} text-gray-900`}>{sumLen(holesOut, col.key)}</td>
))}
</tr>
{/* INN-RUNDE */}
{hasInHoles && holesIn.map((h: any) => {
const extra = getExtraStrokes(h.hcp_index);
return (
<tr key={h.id} className="border-t border-gray-100 group hover:bg-white transition-colors">
<td className="p-4 text-left pl-10 font-black text-lg text-gray-800">{h.hole_number}</td>
<td className="p-4 border-l border-gray-100 bg-white">{h.par}</td>
<td className="p-4 border-l border-gray-100 text-gray-300 text-xs font-mono">{h.hcp_index}</td>
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/5 text-[#7ca982] font-mono">{extra > 0 ? `+${extra}` : '-'}</td>
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/10 text-lg font-mono">{h.par + extra}</td>
{activeColumns.map((col, i) => (
<td key={i} className={`p-4 border-l border-white font-mono transition-all ${col.theme.col} ${col.theme.text}`}>
{h.lengths?.[col.key] || '--'}
</td>
))}
</tr>
);
})}
{/* INN RAD */}
{hasInHoles && (
<tr className="bg-[#f1f7ed]/50 text-[#11280f] font-black border-y border-gray-200">
<td className="p-4 text-left pl-10 uppercase tracking-widest text-[10px] text-gray-400">Inn</td>
<td className="p-4 border-l border-gray-100">{sumPar(holesIn)}</td>
<td colSpan={3} className="border-l border-gray-100 bg-white"></td>
{activeColumns.map((col, i) => (
<td key={i} className={`p-4 border-l border-white font-mono ${col.theme.col} text-gray-900`}>{sumLen(holesIn, col.key)}</td>
))}
</tr>
)}
{/* TOTAL RAD */}
<tr className="bg-[#11280f] text-white text-xl font-black">
<td className="p-8 text-left pl-10 uppercase tracking-tighter">Totalt</td>
<td className="p-8 border-l border-white/10">{sumPar(allHoles)}</td>
<td colSpan={3} className="border-l border-white/10 bg-[#1a3a17]"></td>
{activeColumns.map((col, i) => (
<td key={i} className={`p-8 border-l border-white/10 font-mono ${col.theme.header.split(' ')[0]}`}>
{sumLen(allHoles, col.key)}
</td>
))}
</tr>
</tbody>
</table>
</div>
</div>
);
}

View file

@ -1,353 +0,0 @@
"use client";
/**
* TEE OFF DETAIL VIEW - COMPLETE v3.20
* ---------------------------------------------------------------------------
* 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).
* 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-orange-600 font-bold" : "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-[#11280f]`}
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"/></>,
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"/></>
};
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 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 hover:text-orange-600 transition-colors group";
const resourceBtnClass = "flex justify-between items-center p-5 bg-gray-50 rounded-2xl text-[11px] font-black uppercase hover:bg-orange-600 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 (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">
{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-orange-600 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-orange-600 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-orange-600 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-orange-600 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-orange-600 hover:text-white transition-all"><Icon children={ICONS.weather} /></a>}
</div>
{/* HERO TEXT - pb-32 på mobil hindrer overlap med knapper */}
<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 (PRESENTASJON FØRST FOR MOBIL) */}
<section id="intro" className="flex flex-col lg:flex-row gap-0 md:gap-8 items-stretch">
{/* HOVEDINNHOLD (78%) - Kommer først på mobil pga koderrekkefølge */}
<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>}
<div className="leading-relaxed text-lg md:text-xl text-gray-600" dangerouslySetInnerHTML={{ __html: facility.description || 'Ingen beskrivelse tilgjengelig.' }} />
</div>
{/* SIDEBAR (22%) - Kommer etter tekst på mobil */}
<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-orange-600 transition-colors">{facility.address}<br/>{facility.city}</span>
</a>
</div>
</div>
<div className="mt-auto pt-10 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>
)}
{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(/&nbsp;?/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">
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter text-[#11280f]">Banen</h3>
<div className="space-y-5">
<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-5">
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Drivingrange:</span><span>{amenities.drivingrange || 'Nei'}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-3"><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-3"><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-3"><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-3"><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-3"><span className="text-gray-400">Head Pro:</span><span className="text-right ml-4">{renderValue(amenities.pro)}</span></div>
<div className="flex justify-between"><span className="text-gray-400">Kafé:</span><span className="text-right ml-4">{renderValue(amenities.kafe)}</span></div>
</div>
</div>
</section>
{/* 5. VÆR SEKSJON */}
<section id="weather" className="bg-white p-0 md:p-12 md:rounded-[3rem] shadow-sm border-b md:border-none overflow-hidden text-center">
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-[0.2em] py-8 md:py-0 md:mb-10 flex items-center justify-center gap-3"><Icon children={ICONS.weather} /> Vær for {facility.name}</h3>
<div className="w-full flex justify-center px-4 md:px-0">
{facility.weather_url ? ( <img src={weatherImg} className="w-full h-auto block max-w-5xl" alt="Vær" /> ) : <p className="text-center py-24 text-gray-300 italic text-sm">Værvarsel ikke tilgjengelig</p>}
</div>
</section>
{/* 6. KART SEKSJON */}
<section id="map" className="space-y-6">
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Kart <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-xl h-[450px] md:h-[650px] border-y-4 md:border-[12px] border-white bg-gray-100">
<iframe width="100%" height="100%" style={{ border: 0 }} src={`https://maps.google.com/maps?q=${facility.lat},${facility.lng}&t=k&z=15&ie=UTF8&iwloc=&output=embed`} allowFullScreen />
</div>
</section>
{/* 7. VIDEO SEKSJON */}
{facility.video_url && (
<section id="video" className="space-y-6">
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Video <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-2xl aspect-video bg-black border-y-4 md:border-[12px] border-white">
<iframe src={facility.video_url} className="w-full h-full" allowFullScreen />
</div>
</section>
)}
{/* 8. PRISER & GJESTESPILL */}
<section id="prices" className="grid grid-cols-1 lg:grid-cols-2 gap-0 md:gap-8">
<div className="bg-white p-10 md:p-14 md:rounded-[3rem] shadow-sm">
<h3 className="text-2xl font-black mb-10 uppercase tracking-tighter">Gjestespill</h3>
<div className="space-y-10">
{Object.keys(groupedGreenfee).length > 0 ? (
Object.entries(groupedGreenfee).map(([bane, priser], idx) => (
<div key={idx} className="space-y-4">
{!(bane === "Gjestespill" && Object.keys(groupedGreenfee).length === 1) && (
<h4 className="text-lg font-black uppercase tracking-tighter text-[#11280f] border-b-2 border-gray-50 pb-2">{bane}</h4>
)}
<div className="space-y-2">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Voksne</p>
{priser.map((g, i) => (
<div key={i} className="flex justify-between py-2 border-b border-gray-50/50 text-sm font-bold">
<span className="text-gray-500">{g.priskategori}</span>
<span>kr {g.pris_voksne || '--'},-</span>
</div>
))}
</div>
{priser.some(g => g.pris_junior) && (
<div className="space-y-2 pt-4">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Junior</p>
{priser.map((g, i) => (
<div key={i} className="flex justify-between py-2 border-b border-gray-50/50 text-sm font-bold">
<span className="text-gray-500">{g.priskategori}</span>
<span>kr {g.pris_junior || '--'},-</span>
</div>
))}
</div>
)}
</div>
))
) : <p className="text-gray-400 italic py-6">Ingen priser funnet.</p>}
</div>
<p className="mt-10 text-[10px] text-gray-300 font-black uppercase tracking-widest italic">Krav: {facility.guest_requirements || 'Klubbhandicap'}</p>
</div>
<div className="bg-white p-10 md:p-14 md:rounded-[3rem] shadow-sm flex flex-col justify-between">
<div>
<h3 className="text-2xl font-black mb-10 uppercase tracking-tighter">Medlemskap</h3>
<div className="bg-[#f1f7ed] p-12 md:rounded-[2.5rem] mb-8 text-center border border-[#7ca982]/10">
<p className="text-[#7ca982] text-[11px] font-black uppercase mb-3 tracking-widest">{facility.navn_standard_medlemskap || "Standard"}</p>
<p className="text-6xl font-black text-[#11280f]">kr {facility.standard_medlemskap || '--'},-</p>
{facility.standard_medlemskap_kommentarer && <p className="text-[10px] text-gray-400 mt-4 uppercase font-bold italic leading-tight">{facility.standard_medlemskap_kommentarer}</p>}
</div>
{facility.navn_rimeligste_alternativ && (
<div className="px-8 py-5 bg-gray-50 rounded-2xl border border-gray-100 flex justify-between items-center text-sm font-bold"><span className="text-gray-400 uppercase text-[10px] tracking-widest">{facility.navn_rimeligste_alternativ}</span><span className="text-[#11280f]">kr {facility.rimeligste_alternativ},-</span></div>
)}
</div>
<a href={facility.medlemskap_url} target="_blank" className="mt-10 block w-full text-center bg-[#11280f] text-white p-7 rounded-2xl font-black uppercase text-xs tracking-widest shadow-xl hover:bg-black transition-all">Se alle alternativer</a>
</div>
</section>
{/* 9. SCOREKORT SEKSJON */}
<section id="scorecards" className="pt-10 space-y-20 overflow-hidden">
<h3 className="text-center text-3xl md:text-5xl font-black uppercase tracking-tighter">Scorekort</h3>
<div className="w-full flex flex-col items-center gap-20">
{activeCourses.map((c: any) => (
<div key={c.id} className="w-full overflow-x-auto md:overflow-visible no-scrollbar">
<div className="min-w-[800px] md:min-w-0"><CourseDisplay course={c} /></div>
</div>
))}
</div>
</section>
</div>
{showBackToTop && (
<button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className="fixed bottom-8 right-8 w-14 h-14 bg-[#11280f] text-white rounded-full shadow-2xl flex items-center justify-center text-2xl z-[100] border-4 border-white/20 hover:scale-110 transition-all">↑</button>
)}
</main>
);
}

View file

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

View file

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

View file

@ -1,37 +0,0 @@
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]">
<HeroSlider facilities={safeData} />
<FacilitySearch initialFacilities={safeData} />
</main>
);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,622 +0,0 @@
--
-- PostgreSQL database dump
--
-- Dumped from database version 15.8 (Debian 15.8-1.pgdg110+1)
-- Dumped by pg_dump version 15.8 (Debian 15.8-1.pgdg110+1)
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- Name: tiger; Type: SCHEMA; Schema: -; Owner: teeoff_admin
--
CREATE SCHEMA tiger;
ALTER SCHEMA tiger OWNER TO teeoff_admin;
--
-- Name: tiger_data; Type: SCHEMA; Schema: -; Owner: teeoff_admin
--
CREATE SCHEMA tiger_data;
ALTER SCHEMA tiger_data OWNER TO teeoff_admin;
--
-- Name: topology; Type: SCHEMA; Schema: -; Owner: teeoff_admin
--
CREATE SCHEMA topology;
ALTER SCHEMA topology OWNER TO teeoff_admin;
--
-- Name: SCHEMA topology; Type: COMMENT; Schema: -; Owner: teeoff_admin
--
COMMENT ON SCHEMA topology IS 'PostGIS Topology schema';
--
-- Name: fuzzystrmatch; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS fuzzystrmatch WITH SCHEMA public;
--
-- Name: EXTENSION fuzzystrmatch; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION fuzzystrmatch IS 'determine similarities and distance between strings';
--
-- Name: postgis; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS postgis WITH SCHEMA public;
--
-- Name: EXTENSION postgis; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION postgis IS 'PostGIS geometry and geography spatial types and functions';
--
-- Name: postgis_tiger_geocoder; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder WITH SCHEMA tiger;
--
-- Name: EXTENSION postgis_tiger_geocoder; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION postgis_tiger_geocoder IS 'PostGIS tiger geocoder and reverse geocoder';
--
-- Name: postgis_topology; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS postgis_topology WITH SCHEMA topology;
--
-- Name: EXTENSION postgis_topology; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION postgis_topology IS 'PostGIS topology spatial types and functions';
SET default_tablespace = '';
SET default_table_access_method = heap;
--
-- Name: admins; Type: TABLE; Schema: public; Owner: teeoff_admin
--
CREATE TABLE public.admins (
id integer NOT NULL,
username character varying(50) NOT NULL,
password_hash text NOT NULL,
otp_secret character varying(32),
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
email character varying(255)
);
ALTER TABLE public.admins OWNER TO teeoff_admin;
--
-- Name: admins_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
--
CREATE SEQUENCE public.admins_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER TABLE public.admins_id_seq OWNER TO teeoff_admin;
--
-- Name: admins_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
--
ALTER SEQUENCE public.admins_id_seq OWNED BY public.admins.id;
--
-- Name: courses; Type: TABLE; Schema: public; Owner: teeoff_admin
--
CREATE TABLE public.courses (
id integer NOT NULL,
facility_id integer,
name character varying(255) NOT NULL,
holes integer,
par integer,
length_meters integer,
course_type character varying(255),
architect character varying(255),
status character varying(255),
is_main_course boolean DEFAULT true,
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
tee_boxes jsonb,
scrape_keyword text
);
ALTER TABLE public.courses OWNER TO teeoff_admin;
--
-- Name: courses_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
--
CREATE SEQUENCE public.courses_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER TABLE public.courses_id_seq OWNER TO teeoff_admin;
--
-- Name: courses_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
--
ALTER SEQUENCE public.courses_id_seq OWNED BY public.courses.id;
--
-- Name: facilities; Type: TABLE; Schema: public; Owner: teeoff_admin
--
CREATE TABLE public.facilities (
id integer NOT NULL,
name character varying(255) NOT NULL,
slug character varying(255) NOT NULL,
description text,
established_year integer,
season character varying(255),
address character varying(255),
zipcode character varying(50),
city character varying(255),
county character varying(255),
lat double precision,
lng double precision,
email character varying(255),
phone character varying(255),
website_url character varying(255),
golfbox_booking_url character varying(255),
golfbox_tournament_url character varying(255),
facebook_url character varying(255),
instagram_url character varying(255),
weather_url character varying(255),
webcam_url character varying(255),
golfamore boolean DEFAULT false,
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
image_url character varying(500),
amenities jsonb,
greenfee jsonb,
architect text,
membership jsonb,
vtg jsonb,
video_url text,
baneguide_url text,
logo_url text,
flyfoto_url text,
guest_requirements text,
status_updated_at date,
gallery jsonb,
faqs jsonb DEFAULT '[]'::jsonb,
shotzoom jsonb DEFAULT '[]'::jsonb,
front_image_url text,
nsg_url text,
nsg_description text,
nsg_data jsonb DEFAULT '{}'::jsonb,
golfamore_data jsonb DEFAULT '{}'::jsonb,
ngf_number integer,
golfbox_club_id integer,
golfbox_booking_id text,
facebook_id text,
instagram_place_id text,
tournament_url text,
footnote text,
social_links jsonb DEFAULT '[]'::jsonb,
webcam_html text,
length_meters integer,
navn_standard_medlemskap text,
standard_medlemskap integer,
standard_medlemskap_kommentarer text,
navn_rimeligste_alternativ text,
rimeligste_alternativ integer,
rimeligste_alternativ_kommentarer text,
medlemskap_url text,
banetype text,
scrape_status_url text,
scrape_status_selector text,
scrape_method character varying(50) DEFAULT 'css_selector'::character varying
);
ALTER TABLE public.facilities OWNER TO teeoff_admin;
--
-- Name: facilities_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
--
CREATE SEQUENCE public.facilities_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER TABLE public.facilities_id_seq OWNER TO teeoff_admin;
--
-- Name: facilities_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
--
ALTER SEQUENCE public.facilities_id_seq OWNED BY public.facilities.id;
--
-- Name: facility_images; Type: TABLE; Schema: public; Owner: teeoff_admin
--
CREATE TABLE public.facility_images (
id integer NOT NULL,
facility_id integer,
image_url character varying(255) NOT NULL,
display_order integer DEFAULT 0,
sort_order integer DEFAULT 0
);
ALTER TABLE public.facility_images OWNER TO teeoff_admin;
--
-- Name: facility_images_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
--
CREATE SEQUENCE public.facility_images_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER TABLE public.facility_images_id_seq OWNER TO teeoff_admin;
--
-- Name: facility_images_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
--
ALTER SEQUENCE public.facility_images_id_seq OWNED BY public.facility_images.id;
--
-- Name: hole_lengths; Type: TABLE; Schema: public; Owner: teeoff_admin
--
CREATE TABLE public.hole_lengths (
id integer NOT NULL,
hole_id integer,
tee_id integer,
length_meters integer
);
ALTER TABLE public.hole_lengths OWNER TO teeoff_admin;
--
-- Name: hole_lengths_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
--
CREATE SEQUENCE public.hole_lengths_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER TABLE public.hole_lengths_id_seq OWNER TO teeoff_admin;
--
-- Name: hole_lengths_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
--
ALTER SEQUENCE public.hole_lengths_id_seq OWNED BY public.hole_lengths.id;
--
-- Name: holes; Type: TABLE; Schema: public; Owner: teeoff_admin
--
CREATE TABLE public.holes (
id integer NOT NULL,
course_id integer,
hole_number integer NOT NULL,
par integer,
hcp_index integer,
lengths jsonb
);
ALTER TABLE public.holes OWNER TO teeoff_admin;
--
-- Name: holes_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
--
CREATE SEQUENCE public.holes_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER TABLE public.holes_id_seq OWNER TO teeoff_admin;
--
-- Name: holes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
--
ALTER SEQUENCE public.holes_id_seq OWNED BY public.holes.id;
--
-- Name: tees; Type: TABLE; Schema: public; Owner: teeoff_admin
--
CREATE TABLE public.tees (
id integer NOT NULL,
course_id integer,
name character varying(50) NOT NULL,
cr_men numeric(4,1),
slope_men integer,
cr_women numeric(4,1),
slope_women integer
);
ALTER TABLE public.tees OWNER TO teeoff_admin;
--
-- Name: tees_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
--
CREATE SEQUENCE public.tees_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER TABLE public.tees_id_seq OWNER TO teeoff_admin;
--
-- Name: tees_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
--
ALTER SEQUENCE public.tees_id_seq OWNED BY public.tees.id;
--
-- Name: admins id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.admins ALTER COLUMN id SET DEFAULT nextval('public.admins_id_seq'::regclass);
--
-- Name: courses id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.courses ALTER COLUMN id SET DEFAULT nextval('public.courses_id_seq'::regclass);
--
-- Name: facilities id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.facilities ALTER COLUMN id SET DEFAULT nextval('public.facilities_id_seq'::regclass);
--
-- Name: facility_images id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.facility_images ALTER COLUMN id SET DEFAULT nextval('public.facility_images_id_seq'::regclass);
--
-- Name: hole_lengths id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.hole_lengths ALTER COLUMN id SET DEFAULT nextval('public.hole_lengths_id_seq'::regclass);
--
-- Name: holes id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.holes ALTER COLUMN id SET DEFAULT nextval('public.holes_id_seq'::regclass);
--
-- Name: tees id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.tees ALTER COLUMN id SET DEFAULT nextval('public.tees_id_seq'::regclass);
--
-- Name: admins admins_email_key; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.admins
ADD CONSTRAINT admins_email_key UNIQUE (email);
--
-- Name: admins admins_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.admins
ADD CONSTRAINT admins_pkey PRIMARY KEY (id);
--
-- Name: admins admins_username_key; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.admins
ADD CONSTRAINT admins_username_key UNIQUE (username);
--
-- Name: courses courses_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.courses
ADD CONSTRAINT courses_pkey PRIMARY KEY (id);
--
-- Name: facilities facilities_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.facilities
ADD CONSTRAINT facilities_pkey PRIMARY KEY (id);
--
-- Name: facilities facilities_slug_key; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.facilities
ADD CONSTRAINT facilities_slug_key UNIQUE (slug);
--
-- Name: facility_images facility_images_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.facility_images
ADD CONSTRAINT facility_images_pkey PRIMARY KEY (id);
--
-- Name: hole_lengths hole_lengths_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.hole_lengths
ADD CONSTRAINT hole_lengths_pkey PRIMARY KEY (id);
--
-- Name: holes holes_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.holes
ADD CONSTRAINT holes_pkey PRIMARY KEY (id);
--
-- Name: tees tees_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.tees
ADD CONSTRAINT tees_pkey PRIMARY KEY (id);
--
-- Name: facilities unique_slug; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.facilities
ADD CONSTRAINT unique_slug UNIQUE (slug);
--
-- Name: courses courses_facility_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.courses
ADD CONSTRAINT courses_facility_id_fkey FOREIGN KEY (facility_id) REFERENCES public.facilities(id) ON DELETE CASCADE;
--
-- Name: hole_lengths hole_lengths_hole_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.hole_lengths
ADD CONSTRAINT hole_lengths_hole_id_fkey FOREIGN KEY (hole_id) REFERENCES public.holes(id) ON DELETE CASCADE;
--
-- Name: hole_lengths hole_lengths_tee_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.hole_lengths
ADD CONSTRAINT hole_lengths_tee_id_fkey FOREIGN KEY (tee_id) REFERENCES public.tees(id) ON DELETE CASCADE;
--
-- Name: holes holes_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.holes
ADD CONSTRAINT holes_course_id_fkey FOREIGN KEY (course_id) REFERENCES public.courses(id) ON DELETE CASCADE;
--
-- Name: tees tees_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.tees
ADD CONSTRAINT tees_course_id_fkey FOREIGN KEY (course_id) REFERENCES public.courses(id) ON DELETE CASCADE;
--
-- PostgreSQL database dump complete
--

View file

@ -261,7 +261,8 @@ CREATE TABLE public.facilities (
banetype text,
scrape_status_url text,
scrape_status_selector text,
scrape_method character varying(50) DEFAULT 'css_selector'::character varying
scrape_method character varying(50) DEFAULT 'css_selector'::character varying,
ai_instruction text
);