diff --git a/backend/.env b/backend/.env
new file mode 100644
index 0000000..de45315
--- /dev/null
+++ b/backend/.env
@@ -0,0 +1,5 @@
+SMTP_SERVER=send.one.com
+SMTP_PORT=465
+SMTP_USER=teeoff@teeoff.no
+SMTP_PASS=Shallot Distress43, Serving Smog Hangnail Shower
+EMAIL_TO=erol.haagenrud@teeoff.no
\ No newline at end of file
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 5aa4ac9..7e0a01b 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -4,12 +4,22 @@ FROM python:3.11-slim
# Sett arbeidsmappen inne i containeren
WORKDIR /app
+# Installer system-avhengigheter som trengs for å installere nettlesere
+RUN apt-get update && apt-get install -y \
+ wget \
+ gnupg \
+ && rm -rf /var/lib/apt/lists/*
+
# Kopier filen med avhengigheter og installer dem
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
+# Installer Chromium-browseren og alle dens Linux-systemavhengigheter
+# Dette steget er kritisk for at scraping skal fungere
+RUN playwright install --with-deps chromium
+
# Kopier selve koden vår
COPY . .
# Kommandoen som starter serveren når containeren starter
-CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
\ No newline at end of file
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 8198c73..53d979b 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -4,3 +4,7 @@ asyncpg
httpx
beautifulsoup4
requests
+playwright
+playwright-stealth
+apscheduler
+python-dotenv
diff --git a/backend/scrape_status.py b/backend/scrape_status.py
new file mode 100644
index 0000000..b3f2397
--- /dev/null
+++ b/backend/scrape_status.py
@@ -0,0 +1,130 @@
+import asyncio
+import os
+import asyncpg
+import smtplib
+import re # Ny import for tekst-vasking
+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 apscheduler.schedulers.asyncio import AsyncIOScheduler
+from dotenv import load_dotenv
+
+load_dotenv()
+
+DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
+
+def clean_text(text):
+ """Fjerner spesialtegn og normaliserer tekst for sammenligning"""
+ return re.sub(r'[^a-zA-Z0-9æøåÆØÅ]', '', text).lower()
+
+def interpret_status(text, keyword=None):
+ t_raw = text.lower()
+
+ if keyword:
+ # Fuzzy match: Vi renser både tekst og søkeord for bindestreker/mellomrom
+ k_clean = clean_text(keyword)
+
+ # Hvis vi ikke finner søkeordet engang i renset form, gi opp
+ if k_clean not in clean_text(t_raw):
+ return "NOT_FOUND"
+
+ # Hvis vi finner det, prøv å isolere teksten rundt det originale ordet
+ # Vi leter etter det originale keywordet først
+ parts = re.split(re.escape(keyword), t_raw, flags=re.IGNORECASE)
+ if len(parts) > 1:
+ t_raw = parts[1][:150]
+ else:
+ # Fallback hvis keywordet er delt av HTML-tagger (f.eks 18 hull )
+ t_raw = t_raw[-200:] # Bruk slutten av teksten hvis ordet er vanskelig å isolere
+
+ 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):
+ if not changes and not warnings: 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"
+
+ 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 FROM facilities WHERE scrape_status_url IS NOT NULL")
+
+ changes, warnings = [], []
+
+ 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']}...")
+ await page.goto(f['scrape_status_url'], timeout=60000, wait_until="networkidle")
+
+ # Vent på at innholdet skal lande
+ await asyncio.sleep(3)
+
+ element = await page.query_selector(f['scrape_status_selector'])
+ if not element:
+ warnings.append(f"❌ {f['name']}: Fant ikke elementet '{f['scrape_status_selector']}'")
+ continue
+
+ full_text = await element.inner_text()
+ 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']}' 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:
+ print(f" - {c['name']}: Ingen endring ({new_status.upper()})")
+
+ except Exception as e:
+ warnings.append(f"🔥 {f['name']}: Feil: {str(e)[:100]}")
+ finally:
+ await page.close()
+ await browser.close()
+
+ await conn.close()
+ send_report(changes, warnings)
+ print("🏁 Ferdig.")
+
+if __name__ == "__main__":
+ asyncio.run(run_daily_scraping())
\ No newline at end of file
diff --git a/fil-tre.txt b/fil-tre.txt
index 7c5984a..fe5aaba 100644
--- a/fil-tre.txt
+++ b/fil-tre.txt
@@ -1,10 +1,9 @@
📁 teeoff/
- 📄 filtre.txt
+ 📄 struktur2_dump.txt
📄 seed.sql
📄 teeoff_backup_2.sql
📄 eksport_script.py
📄 update_golfbox.sql
- 📄 struktur_dump.txt
📄 teeoff_backup_1.sql
📄 teeoff_backup.sql
📄 docker-compose.yml
@@ -694,7 +693,10 @@
📁 backend/
📄 scrape_nsg_3.py
📄 import_gallery.py
+ 📄 .env
📄 scrape_golfamore1.2.py
+ 📄 sync_greenfee.py
+ 📄 scrape_status.py
📄 scrape_golfamore1.3.py
📄 requirements.txt
📄 import_wp.py
diff --git a/filtre.txt b/filtre.txt
deleted file mode 100644
index 2ce8ebe..0000000
--- a/filtre.txt
+++ /dev/null
@@ -1,47 +0,0 @@
- 📁 teeoff/
- 📄 seed.sql
- 📄 docker-compose.yml
- 📄 schema.sql
- 📄 init.sql
-📁 frontend/
- 📄 eslint.config.mjs
- 📄 next-env.d.ts
- 📄 tsconfig.json
- 📄 README.md
- 📄 next.config.ts
- 📄 postcss.config.mjs
- 📄 package-lock.json
- 📄 .gitignore
- 📄 package.json
- 📄 Dockerfile
- 📁 public/
- 📄 globe.svg
- 📄 vercel.svg
- 📄 Toppbilde-standard.jpg
- 📄 TeeOff-logo-Retina-1.png
- 📄 window.svg
- 📄 next.svg
- 📄 file.svg
- 📁 src/
- 📁 config/
- 📄 constants.ts
- 📁 app/
- 📄 FacilitySearch.tsx
- 📄 HeroSlider.tsx
- 📄 favicon.ico
- 📄 globals.css
- 📄 page.tsx
- 📄 layout.tsx
- 📁 golfbaner/
- 📁 [slug]/
- 📄 CourseDisplay.tsx
- 📄 page.tsx
- 📄 FacilityDetailView.tsx
-📁 backend/
- 📄 scrape_nsg_3.py
- 📄 import_gallery.py
- 📄 scrape_golfamore1.3.py
- 📄 requirements.txt
- 📄 import_wp.py
- 📄 main.py
- 📄 Dockerfile
diff --git a/kode_eksport/backend_import_gallery_py.txt b/kode_eksport/backend_import_gallery_py.txt
deleted file mode 100644
index 116aa85..0000000
--- a/kode_eksport/backend_import_gallery_py.txt
+++ /dev/null
@@ -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())
diff --git a/kode_eksport/backend_import_wp_py.txt b/kode_eksport/backend_import_wp_py.txt
deleted file mode 100644
index d147c22..0000000
--- a/kode_eksport/backend_import_wp_py.txt
+++ /dev/null
@@ -1,118 +0,0 @@
-import asyncio, asyncpg, urllib.request, json, re
-
-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"
-
-def decode_html(text):
- if not text: return ""
- return str(text).replace('&', '&').replace('&', '&').replace(' ', ' ').strip()
-
-def parse_int(val):
- if val is None or val == '': return None
- try:
- nums = re.findall(r'\d+', str(val))
- return int(nums[0]) if nums else None
- except: return None
-
-async def run_master_import():
- print("🚀 Starter MASTER IMPORT v6.0 (ACF Mapped)...")
- conn = await asyncpg.connect(DB_URL)
- await conn.execute("TRUNCATE facilities, courses, holes RESTART IDENTITY CASCADE;")
-
- page = 1
- while True:
- try:
- req = urllib.request.Request(f"{WP_API_URL}&page={page}", headers={'User-Agent': 'TeeOff-V6'})
- 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', {})
- name = decode_html(post.get('title', {}).get('rendered', ''))
- print(f"📦 Mapper {name}...")
-
- # 1. Medlemskap (Mappet mot field_6040...)
- membership = {
- "url": acf.get('medlemskap_url'),
- "standard": {
- "navn": decode_html(acf.get('navn_standard_medlemskap')),
- "pris": parse_int(acf.get('standard_medlemskap')),
- "kommentar": decode_html(acf.get('standard_medlemskap_kommentarer'))
- },
- "rimeligste": {
- "navn": decode_html(acf.get('navn_rimeligste_alternativ')),
- "pris": parse_int(acf.get('rimeligste_alternativ')),
- "kommentar": decode_html(acf.get('rimeligste_alternativ_kommentarer'))
- }
- }
-
- # 2. Greenfee (Repeatere)
- greenfee = {
- "voksne": acf.get('greenfee_-_voksne') or [],
- "junior": acf.get('greenfee_-_junior') or [],
- "golfpakke": decode_html(acf.get('golfpakke')),
- "rabattert": acf.get('rabattert_greenfee')
- }
-
- # 3. Amenities (Fasiliteter)
- amenities = {
- "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")),
- "pro": decode_html(acf.get("pro")),
- "antall_hull": decode_html(acf.get("antall_hull"))
- }
-
- # 4. Lagre Facility
- fac_id = await conn.fetchval('''
- INSERT INTO facilities (
- name, slug, description, established_year, season, address, city, county,
- lat, lng, email, phone, website_url, image_url, amenities, greenfee,
- membership, vtg, status_updated_at, logo_url, video_url, guest_requirements,
- faqs, shotzoom, translations
- ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb,
- $16::jsonb, $17::jsonb, $18::jsonb, TO_DATE(NULLIF($19, ''), 'YYYYMMDD'), $20, $21, $22, $23::jsonb, $24::jsonb, $25::jsonb)
- RETURNING id
- ''', name, post['slug'], decode_html(acf.get('beskrivelse')), parse_int(acf.get('byggear')), acf.get('sesong'),
- acf.get('gateadresse'), acf.get('postnummer_og_poststed'), acf.get('fylke'),
- float(acf.get('banekart', {}).get('lat', 0)) or None, float(acf.get('banekart', {}).get('lng', 0)) or None,
- acf.get('e-post'), acf.get('telefon'), acf.get('hjemmeside'),
- post.get('_embedded', {}).get('wp:featuredmedia', [{}])[0].get('source_url'),
- json.dumps(amenities), json.dumps(greenfee), json.dumps(membership), json.dumps(acf.get('vtg') or {}),
- acf.get('dato_for_oppdatert_status'), acf.get('logo'),
- f"https://youtube.com/embed/{acf.get('videopresentasjon_youtube')}" if acf.get('videopresentasjon_youtube') else None,
- decode_html(acf.get('krav_til_gjestespillere')), json.dumps(acf.get('faq') or []), json.dumps(acf.get('shotzoom') or []), json.dumps({}))
-
- # 5. Baner og Hull (Bruker ACF-felt for Hovedbane og Bane 2)
- for suffix in ['', '_bane_to']:
- course_name = acf.get('navn_pa_hovedbane' if suffix == '' else 'navn_pa_sekundar_bane') or ('Hovedbane' if suffix == '' else 'Bane 2')
- status = acf.get('banestatus' if suffix == '' else 'banestatus_sekundar_bane')
-
- # Sjekk om det i det hele tatt finnes data for denne banen
- 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, length_meters, is_main_course, tee_boxes)
- VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) RETURNING id
- ''', fac_id, course_name, status, parse_int(acf.get('totalt_par' if suffix == '' else 'totalt_par_bane_to')),
- parse_int(acf.get('lengde' if suffix == '' else 'lengst_totalt_bane_to')), (suffix == ''),
- json.dumps({"herrer": acf.get(f"utslag_herrer{suffix}"), "damer": acf.get(f"utslag_damer{suffix}")}))
-
- 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}'))
- lengths = {k: parse_int(acf.get(f'{k}_hull_{h_num}{suffix}')) for k in ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest']}
- 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(lengths))
- page += 1
- await conn.close()
- print("✅ Ferdig! All data er nå korrekt importert.")
-
-if __name__ == "__main__":
- asyncio.run(run_master_import())
diff --git a/kode_eksport/backend_main_py.txt b/kode_eksport/backend_main_py.txt
deleted file mode 100644
index 5a69052..0000000
--- a/kode_eksport/backend_main_py.txt
+++ /dev/null
@@ -1,69 +0,0 @@
-from fastapi import FastAPI, HTTPException
-from fastapi.middleware.cors import CORSMiddleware
-from contextlib import asynccontextmanager
-import asyncpg
-import json
-from datetime import date, datetime
-
-DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
-
-@asynccontextmanager
-async def lifespan(app: FastAPI):
- app.state.pool = await asyncpg.create_pool(DB_URL, min_size=5, max_size=20)
- yield
- await app.state.pool.close()
-
-app = FastAPI(lifespan=lifespan)
-app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
-
-def format_row(row):
- if row is None: return None
- d = dict(row)
- for key in ['status_updated_at', 'created_at']:
- if isinstance(d.get(key), (date, datetime)): d[key] = d[key].isoformat()
-
- # JSONB vask - Mapper direkte mot kolonnene i struktur_dump.txt
- json_fields = ['amenities', 'greenfee', 'membership', 'vtg', 'faqs', 'shotzoom', 'translations', 'course_statuses', 'courses', 'gallery', 'nsg_data', 'golfamore_data']
- for field in json_fields:
- if field in d:
- if isinstance(d[field], str):
- try: d[field] = json.loads(d[field])
- except: pass
- if d[field] is None: d[field] = [] if field in ['courses', 'faqs', 'gallery'] else {}
- return d
-
-@app.get("/api/facilities")
-async def get_facilities():
- 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 NOT IN ('finnes_ingen_bane_to', 'nedlagt')
- 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):
- 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', 'nedlagt', '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)
- return format_row(row)
diff --git a/kode_eksport/backend_scrape_golfamore1_2_py.txt b/kode_eksport/backend_scrape_golfamore1_2_py.txt
deleted file mode 100644
index 2b50ee6..0000000
--- a/kode_eksport/backend_scrape_golfamore1_2_py.txt
+++ /dev/null
@@ -1,110 +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(text):
- if not text: return ""
- # Fjerner alt som ikke er bokstaver for å matche navn på tvers av systemer
- return re.sub(r'[^a-z0-9]', '', text.lower().replace("golfklubb", "").replace("gk", "").replace(" og ", "").replace("&", ""))
-
-async def scrape_golfamore():
- print("\n******************************************")
- print("🚀 GOLFAMORE ULTIMATE SYNC v1.2")
- print("******************************************\n")
-
- conn = await asyncpg.connect(DB_URL)
- facilities = await conn.fetch("SELECT id, name FROM facilities")
-
- headers = {
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36',
- }
-
- async with httpx.AsyncClient(timeout=30.0, headers=headers, follow_redirects=True) as client:
- print("🕵️ Henter baneliste fra Golfamore (Norge)...")
- url = "https://www.golfamore.com/no/golfbaner/?country=NO"
- resp = await client.get(url)
-
- # 1. Finn alle klubb-lenker i kildekoden (omgår lazyload)
- # Vi leter etter mønsteret "/no/golfklubb/navn-pa-klubb/"
- all_slugs = re.findall(r'/no/golfklubb/([^/\"\s]+)/', resp.text)
- found_links = list(set([f"https://www.golfamore.com/no/golfklubb/{s}/" for s in all_slugs]))
-
- print(f"📍 Fant {len(found_links)} potensielle norske klubber i kildekoden.")
-
- if not found_links:
- print("❌ Klarte ikke å finne noen baner. Sjekk om Golfamore har endret URL-struktur.")
- await conn.close()
- return
-
- # Lag et map for rask matching {vasket_navn: url}
- link_map = {clean(l.split('/')[-2].replace('-', ' ')): l for l in found_links}
-
- matches_found = 0
- for fac in facilities:
- fac_id = fac['id']
- fac_name = fac['name']
- fac_clean = clean(fac_name)
-
- match_url = link_map.get(fac_clean)
-
- # Prøv delvis match (f.eks "Arendal" i "Arendal og Omegn")
- if not match_url:
- for key, url in link_map.items():
- if len(fac_clean) > 4 and (fac_clean in key or key in fac_clean):
- match_url = url
- break
-
- if match_url:
- try:
- print(f"✅ Match: {fac_name}")
- f_resp = await client.get(match_url)
- soup = BeautifulSoup(f_resp.text, 'html.parser')
-
- # Finn teksten om når kortet gjelder
- # Vi leter etter div-er som inneholder "Gjelder"
- validity = "Gjelder én gang pr. sesong."
-
- # Golfamore bruker ofte en liste (ul/li) eller en spesifikk div for regler
- rules_container = soup.find('div', class_=re.compile(r'rules|conditions|terms', re.I))
- if not rules_container:
- # Fallback: Let etter tekstblokken manuelt
- for div in soup.find_all('div'):
- if div.text and "Gjelder" in div.text and len(div.text) < 200:
- validity = div.text.strip()
- break
- else:
- validity = rules_container.get_text(separator=' ').strip()
-
- # Vask teksten for linjeskift
- validity = re.sub(r'\s+', ' ', validity).replace('"', '').strip()
-
- ga_data = {
- "url": match_url,
- "validity": validity
- }
-
- await conn.execute("""
- UPDATE facilities
- SET golfamore = true, golfamore_data = $1
- WHERE id = $2
- """, json.dumps(ga_data), fac_id)
-
- matches_found += 1
- await asyncio.sleep(0.2) # Vær snill
- except Exception as e:
- print(f"⚠️ Feil ved parsing av {fac_name}: {e}")
- await conn.execute("UPDATE facilities SET golfamore = true WHERE id = $1", fac_id)
- else:
- # Hvis ikke funnet, 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_found} baner er nå synkronisert med Golfamore.")
-
-if __name__ == "__main__":
- asyncio.run(scrape_golfamore())
diff --git a/kode_eksport/backend_scrape_golfamore1_3_py.txt b/kode_eksport/backend_scrape_golfamore1_3_py.txt
deleted file mode 100644
index fd418e0..0000000
--- a/kode_eksport/backend_scrape_golfamore1_3_py.txt
+++ /dev/null
@@ -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())
diff --git a/kode_eksport/backend_scrape_golfamore_py.txt b/kode_eksport/backend_scrape_golfamore_py.txt
deleted file mode 100644
index fdd18c0..0000000
--- a/kode_eksport/backend_scrape_golfamore_py.txt
+++ /dev/null
@@ -1,100 +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 ""
- # Vasker navnet for matching (fjerner alt unntatt bokstaver)
- s = text.lower().replace("golfklubb", "").replace("gk", "").replace(" & ", "").strip()
- return re.sub(r'[^a-z]', '', s)
-
-async def get_golfamore_links(client):
- """Henter ALLE norske klubblenker fra Golfamore sin sitemap"""
- print("🕵️ Henter komplett liste fra Golfamore...")
- try:
- # Golfamore har egne sitemaps for hvert land
- resp = await client.get("https://www.golfamore.com/sitemaps/courses-no.xml")
- if resp.status_code == 200:
- links = re.findall(r'(https://www.golfamore.com/no/golfklubb/.*?/) ', resp.text)
- return list(set(links))
- except Exception as e:
- print(f"❌ Kunne ikke hente sitemap: {e}")
- return []
-
-async def scrape_golfamore():
- print("\n******************************************")
- print("🚀 STARTER GOLFAMORE-SYNKRONISERING v1.0")
- print("******************************************\n")
-
- 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:
- ga_links = await get_golfamore_links(client)
- # Map vaskede navn fra URL-en til selve URL-en
- link_map = {clean_name(l.split('/')[-2].replace('-', ' ')): l for l in ga_links}
-
- matches_found = 0
- for fac in facilities:
- fac_id = fac['id']
- fac_name = fac['name']
- fac_clean = clean_name(fac_name)
-
- match_url = link_map.get(fac_clean)
-
- # Prøv delvis match hvis ikke eksakt (f.eks "Arendal" i "Arendal og Omegn")
- if not match_url:
- for slug, url in link_map.items():
- if len(fac_clean) > 4 and (fac_clean in slug or slug in fac_clean):
- match_url = url
- break
-
- if match_url:
- try:
- # Gå til klubbsiden for å finne vilkårene
- f_resp = await client.get(match_url)
- soup = BeautifulSoup(f_resp.text, 'html.parser')
-
- # Finn teksten om når kortet gjelder.
- # Golfamore bruker ofte spesifikke klasser for "rules" eller "conditions"
- rules_section = soup.find('div', {'class': 'course-rules'}) or \
- soup.find('div', {'class': 'course-info__rules'}) or \
- soup.find(text=re.compile(r'Golfamore gjelder', re.I))
-
- validity = "Gjelder alle dager" # Standard
- if rules_section:
- # Rydd opp i teksten
- validity = rules_section.get_text(separator=' ').replace('\n', ' ')
- validity = re.sub(r'\s+', ' ', validity).strip()
-
- ga_data = {
- "validity": validity,
- "source_url": match_url
- }
-
- # Oppdater databasen
- await conn.execute("""
- UPDATE facilities
- SET golfamore = true, golfamore_data = $1
- WHERE id = $2
- """, json.dumps(ga_data), fac_id)
-
- print(f"✅ MATCH: {fac_name} ({validity[:50]}...)")
- matches_found += 1
- except:
- # Hvis vi ikke klarer å lese detaljene, markerer vi den i hvert fall som aktiv
- await conn.execute("UPDATE facilities SET golfamore = true WHERE id = $1", fac_id)
- else:
- # Hvis den ikke finnes på Golfamore, 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_found} baner er nå bekreftet hos Golfamore.")
-
-if __name__ == "__main__":
- asyncio.run(scrape_golfamore())
diff --git a/kode_eksport/backend_scrape_nsg_3_py.txt b/kode_eksport/backend_scrape_nsg_3_py.txt
deleted file mode 100644
index 190b12d..0000000
--- a/kode_eksport/backend_scrape_nsg_3_py.txt
+++ /dev/null
@@ -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'(https://seniorgolf.no/lojalitetskort/.*?/) ', resp.text)
- if found: return list(set(found))
- else:
- soup = BeautifulSoup(resp.text, 'html.parser')
- links.extend([l['href'] for l in soup.select('a[href*="/lojalitetskort/"]')])
- except: continue
- return list(set(links))
-
-async def scrape_nsg():
- print("🚀 Starter NSG VASKEMASKIN v3.8...")
- conn = await asyncpg.connect(DB_URL)
- facilities = await conn.fetch("SELECT id, name FROM facilities")
-
- async with httpx.AsyncClient(timeout=20.0, headers={'User-Agent': 'Mozilla/5.0'}) as client:
- all_nsg_links = await get_nsg_links(client)
- link_map = {clean_name(l.split('/')[-2].replace('-', ' ')): l for l in all_nsg_links}
-
- matches_found = 0
- for fac in facilities:
- fac_name_clean = clean_name(fac['name'])
- match_url = link_map.get(fac_name_clean)
-
- if not match_url:
- for slug, url in link_map.items():
- if fac_name_clean in slug or slug in fac_name_clean:
- match_url = url
- break
-
- if match_url:
- try:
- f_resp = await client.get(match_url)
- f_soup = BeautifulSoup(f_resp.text, 'html.parser')
-
- # Finn hovedinnholdet i stedet for hele siden for å unngå menyer
- main_content = f_soup.find('div', {'class': 'entry-content'}) or f_soup
- text = main_content.get_text()
-
- st = re.search(r"Starttider:?\s*(.*?)(?=Greenfee|Booking|Adresse|Kontakt|$)", text, re.S | re.I)
- gf = re.search(r"Greenfee:?\s*(.*?)(?=Booking|Adresse|Kontakt|$)", text, re.S | re.I)
- bk = re.search(r"Booking:?\s*(.*?)(?=Adresse|Kontakt|$)", text, re.S | re.I)
-
- nsg_data = {
- "url": match_url,
- "starttider": clean_nsg_content(st.group(1)) if st else "Se nettside",
- "greenfee": clean_nsg_content(gf.group(1)) if gf else "Se nettside",
- "booking": clean_nsg_content(bk.group(1)) if bk else "Se nettside"
- }
-
- await conn.execute("UPDATE facilities SET nsg_data = $1 WHERE id = $2", json.dumps(nsg_data), fac['id'])
- print(f"✅ Vasket & Lagret: {fac['name']}")
- matches_found += 1
- except: pass
-
- await conn.close()
- print(f"\n🎉 Vask ferdig! {matches_found} baner er nå 100% klare.")
-
-if __name__ == "__main__":
- asyncio.run(scrape_nsg())
diff --git a/kode_eksport/eksport_script_py.txt b/kode_eksport/eksport_script_py.txt
deleted file mode 100644
index 3561194..0000000
--- a/kode_eksport/eksport_script_py.txt
+++ /dev/null
@@ -1,72 +0,0 @@
-import os
-import shutil
-from pathlib import Path
-
-# --- KONFIGURASJON ---
-KILDE_MAPPE = "/opt/teeoff/"
-EKSPORT_MAPPE = "/opt/teeoff/kode_eksport/"
-TRE_FIL = "/opt/teeoff/filtre.txt"
-
-# Filtyper vi vil kopiere
-FILTYPER = ['.py', '.ts', '.tsx']
-
-# Mapper vi IKKE vil ha med i treet eller skanne (sparer tid og rot)
-IGNORER_MAPPER = ['.git', 'node_modules', '__pycache__', 'kode_eksport', '.next']
-
-def generer_tre_og_kopier():
- kilde_sti = Path(KILDE_MAPPE)
- eksport_sti = Path(EKSPORT_MAPPE)
-
- # 1. Opprett eksportmappen hvis den ikke finnes
- eksport_sti.mkdir(parents=True, exist_ok=True)
-
- tre_linjer = []
- kopierte_filer = 0
-
- print("Skanner filer og genererer tre...")
-
- # 2. Gå gjennom alle mapper og filer
- for root, dirs, files in os.walk(kilde_sti):
- # Fjern ignorerte mapper så vi ikke går inn i dem
- dirs[:] = [d for d in dirs if d not in IGNORER_MAPPER]
-
- # Regn ut innrykk basert på hvor dypt vi er i mappestrukturen
- nivaa = root.replace(KILDE_MAPPE, '').count(os.sep)
- innrykk = ' ' * 4 * nivaa
- mappe_navn = os.path.basename(root)
-
- # Legg til mappen i treet
- if mappe_navn:
- tre_linjer.append(f"{innrykk}📁 {mappe_navn}/")
- else:
- tre_linjer.append(f"📁 {kilde_sti.name}/")
-
- sub_innrykk = ' ' * 4 * (nivaa + 1)
-
- # 3. Gå gjennom filene i mappen
- for fil in files:
- tre_linjer.append(f"{sub_innrykk}📄 {fil}")
-
- fil_sti = Path(root) / fil
-
- # 4. Sjekk om filen har riktig endelse og skal kopieres
- if fil_sti.suffix in FILTYPER:
- # Lag et unikt filnavn for å unngå overskriving
- relativ_sti = fil_sti.relative_to(kilde_sti)
- nytt_navn = str(relativ_sti).replace(os.sep, '_').replace('.', '_') + '.txt'
- ny_sti = eksport_sti / nytt_navn
-
- # Kopier filen
- shutil.copy2(fil_sti, ny_sti)
- kopierte_filer += 1
-
- # 5. Lagre filteret til tekstfilen
- with open(TRE_FIL, 'w', encoding='utf-8') as f:
- f.write('\n'.join(tre_linjer))
-
- print(f"\n✅ Ferdig!")
- print(f"📁 Filtre er lagret i: {TRE_FIL}")
- print(f"📝 Kopierte {kopierte_filer} kodefiler til: {EKSPORT_MAPPE}")
-
-if __name__ == "__main__":
- generer_tre_og_kopier()
diff --git a/kode_eksport/frontend_next-env_d_ts.txt b/kode_eksport/frontend_next-env_d_ts.txt
deleted file mode 100644
index c4b7818..0000000
--- a/kode_eksport/frontend_next-env_d_ts.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-///
-///
-import "./.next/dev/types/routes.d.ts";
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/kode_eksport/frontend_next_config_ts.txt b/kode_eksport/frontend_next_config_ts.txt
deleted file mode 100644
index e9ffa30..0000000
--- a/kode_eksport/frontend_next_config_ts.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { NextConfig } from "next";
-
-const nextConfig: NextConfig = {
- /* config options here */
-};
-
-export default nextConfig;
diff --git a/kode_eksport/frontend_src_app_FacilitySearch_tsx.txt b/kode_eksport/frontend_src_app_FacilitySearch_tsx.txt
deleted file mode 100644
index e6687fa..0000000
--- a/kode_eksport/frontend_src_app_FacilitySearch_tsx.txt
+++ /dev/null
@@ -1,94 +0,0 @@
-"use client";
-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 [mounted, setMounted] = useState(false);
-
- useEffect(() => {
- setMounted(true);
- if ("geolocation" in navigator) {
- navigator.geolocation.getCurrentPosition(p => setUserLocation({ lat: p.coords.latitude, lng: p.coords.longitude }));
- }
- }, []);
-
- const processed = useMemo(() => {
- if (!mounted || !Array.isArray(initialFacilities)) return [];
- const words = searchQuery.toLowerCase().trim().split(/\s+/).filter(w => w.length > 0);
-
- return initialFacilities.map(f => {
- // Skuddsikker status-vask
- const raw = Array.isArray(f.course_statuses) ? f.course_statuses : [];
- const statuses = raw.filter((s: any) => s && s.status && s.status !== 'finnes_ingen_bane_to' && s.name !== 'Bane 2');
-
- const dist = userLocation && f.lat && f.lng ? getDistance(userLocation.lat, userLocation.lng, f.lat, f.lng) : Infinity;
- const searchBlob = `${f.name} ${f.city} ${f.county} ${statuses.map((s:any) => s.name + s.status).join(" ")}`.toLowerCase();
- const matches = words.every(w => searchBlob.includes(w));
-
- return { ...f, dist, statuses, matches };
- })
- .filter(f => f.matches)
- .sort((a, b) => (userLocation && a.dist !== b.dist ? a.dist - b.dist : (a.name || "").localeCompare(b.name || "", 'nb')));
- }, [searchQuery, initialFacilities, userLocation, mounted]);
-
- if (!mounted) return null;
-
- return (
-
-
-
- {userLocation ? "GPS AKTIV" : "SORTERER ALFABETISK"} • {processed.length} BANER
-
-
-
-
setSearchQuery(e.target.value)} />
-
-
- {processed.map((f: any) => (
-
-
-
- {f.dist !== Infinity &&
{Math.round(f.dist)} km unna
}
-
- {f.statuses.map((s: any, idx: number) => {
- const raw = (s.status || "ukjent").toLowerCase();
- let color = "bg-gray-500 text-white";
- if (raw.includes('aapen') && !raw.includes('vinter')) color = "bg-green-600 text-white";
- else if (raw.includes('vinter')) color = "bg-emerald-400 text-gray-900";
- else if (raw.includes('snart')) color = "bg-yellow-500 text-gray-900";
- else if (raw.includes('stengt')) color = "bg-red-600 text-white";
- return
{f.statuses.length > 1 ? `${s.name}: ${STATUS_MAP[raw] || raw}` : (STATUS_MAP[raw] || raw)}
;
- })}
-
-
-
-
{f.name}
-
{f.city}{f.county ? ` • ${f.county}` : ''}
-
-
-
{f.amenities?.antall_hull || f.holes || '--'} Hull
- {f.golfamore &&
G
}
- {f.nsg_data?.url &&
S
}
-
-
Se bane →
-
-
-
- ))}
-
-
- );
-}
diff --git a/kode_eksport/frontend_src_app_HeroSlider_tsx.txt b/kode_eksport/frontend_src_app_HeroSlider_tsx.txt
deleted file mode 100644
index effc58f..0000000
--- a/kode_eksport/frontend_src_app_HeroSlider_tsx.txt
+++ /dev/null
@@ -1,52 +0,0 @@
-"use client";
-import { FALLBACK_IMAGE } from "@/config/constants";
-import { useState, useEffect } from 'react';
-import Link from 'next/link';
-
-export default function HeroSlider({ facilities }: { facilities: any[] }) {
- const [currentIndex, setCurrentIndex] = useState(0);
- const [sliderItems, setSliderItems] = useState([]);
-
- useEffect(() => {
- if (!Array.isArray(facilities)) return;
-
- const filtered = facilities.filter(f => {
- // Robust status-sjekk
- const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : [];
- const interesting = ['aapen', 'aapen_med_vintergreener', 'aapner_snart', 'stenger_snart', 'stengt'];
- const hasStatus = statuses.some((s: any) => s && s.status && interesting.includes(s.status));
-
- // Bilde-sjekk
- const img = f.front_image_url || f.image_url || "";
- const isReal = img && !img.includes('Toppbilde-standard.jpg') && String(img).startsWith('http');
-
- return hasStatus && isReal;
- });
-
- setSliderItems(filtered.sort(() => 0.5 - Math.random()).slice(0, 5));
- }, [facilities]);
-
- useEffect(() => {
- if (sliderItems.length <= 1) return;
- const t = setInterval(() => setCurrentIndex(p => (p + 1) % sliderItems.length), 6000);
- return () => clearInterval(t);
- }, [sliderItems]);
-
- if (sliderItems.length === 0) return null;
-
- return (
-
- {sliderItems.map((f, i) => (
-
-
-
-
-
{f.name}
-
{f.city} • {f.county}
-
Se golfanlegget
-
-
- ))}
-
- );
-}
diff --git a/kode_eksport/frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt b/kode_eksport/frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt
deleted file mode 100644
index 8e1423d..0000000
--- a/kode_eksport/frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt
+++ /dev/null
@@ -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 (
-
-
- {/* HEADER / KALKULATOR */}
-
-
-
{course.name}
-
Par {course.par} • {course.length_meters || '--'} meter
-
-
-
-
Kjønn
- { setGender(e.target.value as any); setSelectedTeeIndex(0); }} className="bg-transparent text-[#11280f] font-black outline-none border-b-2 border-[#7ca982]/30 pb-1 cursor-pointer">
- HERRER DAMER
-
-
-
Utslag
- setSelectedTeeIndex(Number(e.target.value))} className="bg-transparent text-[#11280f] font-black outline-none border-b-2 border-[#7ca982]/30 pb-1 cursor-pointer">
- {availableTees.map((t: any, i: number) => ({t.navn_utslag || t.navn_utslag_damer} ))}
-
-
-
Ditt HCP
- setHcp(e.target.value)} className="w-12 bg-transparent text-[#11280f] font-black text-center border-b-2 border-[#7ca982]/30" />
-
-
-
SpH
-
{playingHandicap || 0}
-
-
-
-
- {/* SCOREKORT TABELL */}
-
-
-
-
- Hull
- Par
- HCP
- Mottatt
- Din Par
- {activeColumns.map((col, i) => (
- {col.label}
- ))}
-
-
-
- {/* UT-RUNDE */}
- {holesOut.map((h: any) => {
- const extra = getExtraStrokes(h.hcp_index);
- return (
-
- {h.hole_number}
- {h.par}
- {h.hcp_index}
- {extra > 0 ? `+${extra}` : '-'}
- {h.par + extra}
- {activeColumns.map((col, i) => (
-
- {h.lengths?.[col.key] || '--'}
-
- ))}
-
- );
- })}
-
- {/* UT RAD */}
-
- Ut
- {sumPar(holesOut)}
-
- {activeColumns.map((col, i) => (
- {sumLen(holesOut, col.key)}
- ))}
-
-
- {/* INN-RUNDE */}
- {hasInHoles && holesIn.map((h: any) => {
- const extra = getExtraStrokes(h.hcp_index);
- return (
-
- {h.hole_number}
- {h.par}
- {h.hcp_index}
- {extra > 0 ? `+${extra}` : '-'}
- {h.par + extra}
- {activeColumns.map((col, i) => (
-
- {h.lengths?.[col.key] || '--'}
-
- ))}
-
- );
- })}
-
- {/* INN RAD */}
- {hasInHoles && (
-
- Inn
- {sumPar(holesIn)}
-
- {activeColumns.map((col, i) => (
- {sumLen(holesIn, col.key)}
- ))}
-
- )}
-
- {/* TOTAL RAD */}
-
- Totalt
- {sumPar(allHoles)}
-
- {activeColumns.map((col, i) => (
-
- {sumLen(allHoles, col.key)}
-
- ))}
-
-
-
-
-
- );
-}
diff --git a/kode_eksport/frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt b/kode_eksport/frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt
deleted file mode 100644
index 7bfa41d..0000000
--- a/kode_eksport/frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt
+++ /dev/null
@@ -1,217 +0,0 @@
-"use client";
-
-import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants";
-import Link from 'next/link';
-import CourseDisplay from './CourseDisplay';
-
-const formatTel = (phone: string) => {
- if (!phone) return "";
- const clean = phone.replace(/\s+/g, '').replace('+', '00');
- return `00${clean.replace(/^00/, '')}`; // Sikrer 0047 format
-};
-
-export default function FacilityDetailView({ facility }: { facility: any }) {
- const activeCourses = (facility.courses || []).filter((c: any) => c.holes && c.holes.length > 0);
- const amenities = facility.amenities || {};
- const nsg = facility.nsg_data || {};
-
- const weatherImg = facility.weather_url?.replace("/graf/dag/", "/innhold/").replace(/\/$/, "") + "/meteogram.svg";
- const googleMapsUrl = `https://www.google.com/maps/search/?api=1&query=${facility.lat},${facility.lng}`;
-
- return (
-
-
- {/* HERO SEKSJON */}
-
-
-
-
-
-
-
-
{facility.name}
-
{facility.city} • {facility.county}
-
-
- {activeCourses.map((c: any) => (
-
- {activeCourses.length === 1 ? STATUS_MAP[c.status] || c.status : `${c.name}: ${STATUS_MAP[c.status] || c.status}`}
-
- ))}
-
-
-
- {/* DE 5 IKON-KNAPPENE (Gjeninnført nøyaktig) */}
-
- {facility.website_url &&
🏠 }
- {facility.golfbox_booking_url &&
🕒 }
- {facility.golfbox_tournament_url &&
🏆 }
-
📍
- {facility.weather_url &&
☁️ }
-
-
-
-
-
-
-
- {/* SIDEBAR */}
-
-
← Tilbake til kartet
-
-
-
- {/* VÆR-WIDGET */}
- {facility.weather_url && (
-
-
Værvarsel (48t)
-
-
- )}
-
- {/* ANDRE RESSURSER */}
-
-
-
- {/* HOVEDINNHOLD */}
-
-
-
- {/* BANEDETALJER + ANDRE TILBUD (Nøyaktig som bildet) */}
-
-
-
Banen
-
-
- Hull: {amenities.antall_hull || '--'}
- Banetype: {facility.banetype || 'Park/Skog'}
- Lengde: {facility.length || '--'} meter
- Sesong: {facility.season}
- Byggeår: {facility.established_year || '--'}
- Arkitekt: {facility.architect || '--'}
-
-
-
-
-
Andre Tilbud
-
-
- Drivingrange: {amenities.drivingrange ? "Ja" : "Nei"}
- Nærspill: Ja ✓
- Head Pro:
- Proshop: Ja
- Kafé:
- Bilutleie: Ja
-
-
-
-
-
- {/* KART OG VIDEO */}
-
-
-
-
- {facility.video_url && (
-
-
-
- )}
-
-
- {/* SCOREKORT */}
-
- {activeCourses.map((c: any) => )}
-
-
- {/* SLOPING TABELLER */}
-
-
-
Slopetabeller
- Gyldighet: {facility.gyldig_til_og_med || 'Ukjent'}
-
-
- {['herrer', 'damer'].map(gender => (
-
-
{gender}
-
-
- Utslag CR Slope
-
-
- {(activeCourses[0]?.tee_boxes?.[gender] || []).map((tee: any, i: number) => (
-
- {tee.navn_utslag || tee.navn_utslag_damer}
- {tee.baneverdi || tee.baneverdi_damer}
- {tee.slopeverdi || tee.slopeverdi_damer}
-
- ))}
-
-
-
- ))}
-
-
-
- {/* GJESTESPILL & MEDLEMSKAP */}
-
-
-
⛳ Gjestespill
-
-
-
Priser
- {facility.greenfee?.voksne?.map((g: any, i: number) => (
-
- {g.priskategori}
- kr {g.pris_voksne},-
-
- ))}
- {facility.greenfee?.junior?.map((g: any, i: number) => (
-
- {g.priskategori_junior} (Junior)
- kr {g.pris_junior},-
-
- ))}
-
-
-
Krav: {facility.guest_requirements}
-
-
-
-
🤝 Medlemskap
-
-
-
{facility.membership?.standard?.navn || "Standard"}
-
kr {facility.membership?.standard?.pris || '--'},-
-
- {facility.membership?.rimeligste?.pris && (
-
-
{facility.membership.rimeligste.navn}
-
kr {facility.membership.rimeligste.pris},-
-
- )}
-
-
Se alle alternativer
-
-
-
-
-
- );
-}
diff --git a/kode_eksport/frontend_src_app_golfbaner_[slug]_page_tsx.txt b/kode_eksport/frontend_src_app_golfbaner_[slug]_page_tsx.txt
deleted file mode 100644
index fbae7a1..0000000
--- a/kode_eksport/frontend_src_app_golfbaner_[slug]_page_tsx.txt
+++ /dev/null
@@ -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 Fant ikke golfbanen...
;
- }
-
- // Vi sender dataene til den navngitte komponenten
- return ;
-}
diff --git a/kode_eksport/frontend_src_app_layout_tsx.txt b/kode_eksport/frontend_src_app_layout_tsx.txt
deleted file mode 100644
index f7fa87e..0000000
--- a/kode_eksport/frontend_src_app_layout_tsx.txt
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
-import "./globals.css";
-
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
-
-export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
-};
-
-export default function RootLayout({
- children,
-}: Readonly<{
- children: React.ReactNode;
-}>) {
- return (
-
-
- {children}
-
-
- );
-}
diff --git a/kode_eksport/frontend_src_app_page_tsx.txt b/kode_eksport/frontend_src_app_page_tsx.txt
deleted file mode 100644
index 04507a3..0000000
--- a/kode_eksport/frontend_src_app_page_tsx.txt
+++ /dev/null
@@ -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 (
-
-
-
-
- );
-}
diff --git a/kode_eksport/frontend_src_config_constants_ts.txt b/kode_eksport/frontend_src_config_constants_ts.txt
deleted file mode 100644
index de034d4..0000000
--- a/kode_eksport/frontend_src_config_constants_ts.txt
+++ /dev/null
@@ -1,23 +0,0 @@
-// Globale innstillinger for TeeOff.no
-export const API_URL = process.env.API_URL || "http://api:8000/api";
-export const FALLBACK_IMAGE = "/Toppbilde-standard.jpg";
-export const TEEOFF_LOGO = "/TeeOff-logo-Retina-1.png";
-
-export const STATUS_MAP: Record = {
- "ukjent": "Ukjent status",
- "aapen": "Åpen",
- "aapen_med_vintergreener": "Vintergreener",
- "stengt": "Stengt",
- "nedlagt": "Nedlagt",
- "under_utvikling": "Under utvikling",
- "aapner_snart": "Åpner snart",
- "stenger_snart": "Stenger snart"
-};
-
-export const REGIONS: Record = {
- "nord-norge": ["finnmark", "troms", "nordland"],
- "midt-norge": ["nord-trøndelag", "sør-trøndelag", "trøndelag"],
- "vestlandet": ["møre og romsdal", "sogn og fjordane", "hordaland", "rogaland", "vestland"],
- "sørlandet": ["vest-agder", "aust-agder", "agder"],
- "østlandet": ["telemark", "vestfold", "østfold", "buskerud", "hedmark", "oppland", "oslo", "akershus", "innlandet", "viken"]
-};
diff --git a/kode_eksport_1/backend_main_py.txt b/kode_eksport_1/backend_main_py.txt
index 5b37976..4aa0feb 100644
--- a/kode_eksport_1/backend_main_py.txt
+++ b/kode_eksport_1/backend_main_py.txt
@@ -5,21 +5,86 @@ import asyncpg
import json
from datetime import date, datetime
+# --- KONFIGURASJON ---
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
+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.
+ """
+ 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
+ # Disse må parses manuelt hvis de kommer som strenger fra Postgres
+ 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:
- app.state.pool = await asyncpg.create_pool(DB_URL, min_size=5, max_size=20)
+ 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: {e}")
+ print(f"❌ Databasefeil under oppstart: {e}")
raise e
yield
+ # Lukk pool ved avslutning
await app.state.pool.close()
-app = FastAPI(title="TeeOff API v2.4", lifespan=lifespan)
+app = FastAPI(title="TeeOff API v3.5", lifespan=lifespan)
+# CORS-oppsett slik at Next.js kan snakke med API-et
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -27,39 +92,9 @@ app.add_middleware(
allow_headers=["*"],
)
-def format_row(row):
- if row is None: return None
- d = dict(row)
-
- # 1. Dato-håndtering
- for key in ['status_updated_at', 'created_at']:
- if isinstance(d.get(key), (date, datetime)):
- d[key] = d[key].isoformat()
-
- # 2. Garanter riktige datatyper (Vaskeliste)
- list_fields = ['course_statuses', 'courses', 'gallery', 'greenfee', 'faqs', 'shotzoom', 'social_links', 'holes']
- dict_fields = ['amenities', 'vtg', 'nsg_data', 'golfamore_data']
-
- for field in list_fields:
- if field in d:
- if d[field] is None:
- d[field] = []
- elif isinstance(d[field], str):
- try: d[field] = json.loads(d[field])
- except: d[field] = []
-
- for field in dict_fields:
- if field in d:
- if d[field] is None:
- d[field] = {}
- elif isinstance(d[field], str):
- try: d[field] = json.loads(d[field])
- except: d[field] = {}
-
- return d
-
@app.get("/api/facilities")
async def get_facilities():
+ """Henter alle golfanlegg med aggregert banestatus"""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT f.*, (
@@ -69,12 +104,14 @@ async def get_facilities():
ORDER BY is_main_course DESC, id ASC
) cs
) as course_statuses
- FROM facilities f ORDER BY f.name ASC
+ 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.*, (
@@ -91,5 +128,22 @@ async def get_facility(slug: str):
) as courses
FROM facilities f WHERE f.slug = $1
""", slug)
- if not row: raise HTTPException(status_code=404)
- return format_row(row)
\ No newline at end of file
+
+ 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)
\ No newline at end of file
diff --git a/kode_eksport_1/backend_scrape_status_py.txt b/kode_eksport_1/backend_scrape_status_py.txt
new file mode 100644
index 0000000..b3f2397
--- /dev/null
+++ b/kode_eksport_1/backend_scrape_status_py.txt
@@ -0,0 +1,130 @@
+import asyncio
+import os
+import asyncpg
+import smtplib
+import re # Ny import for tekst-vasking
+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 apscheduler.schedulers.asyncio import AsyncIOScheduler
+from dotenv import load_dotenv
+
+load_dotenv()
+
+DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
+
+def clean_text(text):
+ """Fjerner spesialtegn og normaliserer tekst for sammenligning"""
+ return re.sub(r'[^a-zA-Z0-9æøåÆØÅ]', '', text).lower()
+
+def interpret_status(text, keyword=None):
+ t_raw = text.lower()
+
+ if keyword:
+ # Fuzzy match: Vi renser både tekst og søkeord for bindestreker/mellomrom
+ k_clean = clean_text(keyword)
+
+ # Hvis vi ikke finner søkeordet engang i renset form, gi opp
+ if k_clean not in clean_text(t_raw):
+ return "NOT_FOUND"
+
+ # Hvis vi finner det, prøv å isolere teksten rundt det originale ordet
+ # Vi leter etter det originale keywordet først
+ parts = re.split(re.escape(keyword), t_raw, flags=re.IGNORECASE)
+ if len(parts) > 1:
+ t_raw = parts[1][:150]
+ else:
+ # Fallback hvis keywordet er delt av HTML-tagger (f.eks 18 hull )
+ t_raw = t_raw[-200:] # Bruk slutten av teksten hvis ordet er vanskelig å isolere
+
+ 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):
+ if not changes and not warnings: 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"
+
+ 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 FROM facilities WHERE scrape_status_url IS NOT NULL")
+
+ changes, warnings = [], []
+
+ 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']}...")
+ await page.goto(f['scrape_status_url'], timeout=60000, wait_until="networkidle")
+
+ # Vent på at innholdet skal lande
+ await asyncio.sleep(3)
+
+ element = await page.query_selector(f['scrape_status_selector'])
+ if not element:
+ warnings.append(f"❌ {f['name']}: Fant ikke elementet '{f['scrape_status_selector']}'")
+ continue
+
+ full_text = await element.inner_text()
+ 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']}' 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:
+ print(f" - {c['name']}: Ingen endring ({new_status.upper()})")
+
+ except Exception as e:
+ warnings.append(f"🔥 {f['name']}: Feil: {str(e)[:100]}")
+ finally:
+ await page.close()
+ await browser.close()
+
+ await conn.close()
+ send_report(changes, warnings)
+ print("🏁 Ferdig.")
+
+if __name__ == "__main__":
+ asyncio.run(run_daily_scraping())
\ No newline at end of file
diff --git a/kode_eksport_1/backend_sync_greenfee_py.txt b/kode_eksport_1/backend_sync_greenfee_py.txt
new file mode 100644
index 0000000..745a80e
--- /dev/null
+++ b/kode_eksport_1/backend_sync_greenfee_py.txt
@@ -0,0 +1,79 @@
+import asyncio, asyncpg, urllib.request, json
+
+DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
+# Vi fjerner acf_format=standard da rå-feltnavnene er tryggere her
+WP_API_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100"
+
+def decode_html(text):
+ if not text: return ""
+ return str(text).replace('&', '&').replace('&', '&').replace(' ', ' ').strip()
+
+async def run_greenfee_sync():
+ print("🎯 Starter GREENFEE-SYNC v1.2 (Basert på rå-API mapping)...")
+ conn = await asyncpg.connect(DB_URL)
+ page = 1
+ total_updated = 0
+
+ while True:
+ try:
+ req = urllib.request.Request(f"{WP_API_URL}&page={page}", headers={'User-Agent': 'TeeOff-Sync'})
+ with urllib.request.urlopen(req) as response:
+ data = json.loads(response.read().decode())
+ except: break
+ if not data: break
+
+ for post in data:
+ slug = post['slug']
+ acf = post.get('acf', {})
+
+ # Henter banenavn for å gruppere riktig
+ bane_1_navn = acf.get('navn_pa_hovedbane') or "Hovedbanen"
+ bane_2_navn = acf.get('navn_pa_sekundar_bane') or "Bane 2"
+
+ final_greenfee = []
+
+ # --- MAPPER BANE 1 (Voksne + Junior) ---
+ voksne_1 = acf.get('greenfee_-_voksne') or []
+ junior_1 = acf.get('greenfee_-_junior') or []
+
+ for i, item in enumerate(voksne_1):
+ row = {
+ "banenavn": bane_1_navn,
+ "priskategori": item.get('priskategori'),
+ "pris_voksne": item.get('pris_voksne')
+ }
+ # Legger til juniorpris hvis den finnes på samme index
+ if i < len(junior_1):
+ row["pris_junior"] = junior_1[i].get('pris_junior')
+ final_greenfee.append(row)
+
+ # --- MAPPER BANE 2 (Voksne + Junior) ---
+ voksne_2 = acf.get('greenfee_-_voksne_bane_to') or []
+ junior_2 = acf.get('greenfee_-_junior_bane_to') or []
+
+ for i, item in enumerate(voksne_2):
+ row = {
+ "banenavn": bane_2_navn,
+ "priskategori": item.get('priskategori_bane_to'),
+ "pris_voksne": item.get('pris_voksne_bane_to')
+ }
+ if i < len(junior_2):
+ row["pris_junior"] = junior_2[i].get('pris_junior_bane_to')
+ final_greenfee.append(row)
+
+ # Henter krav (Gjeste_krav)
+ reqs = decode_html(acf.get('krav_til_gjestespillere'))
+
+ if final_greenfee:
+ await conn.execute('''
+ UPDATE facilities SET greenfee = $1::jsonb, guest_requirements = $2 WHERE slug = $3
+ ''', json.dumps(final_greenfee), reqs, slug)
+ print(f"✅ {slug}: Importerte {len(final_greenfee)} prisrader for {bane_1_navn}/{bane_2_navn}")
+ total_updated += 1
+
+ page += 1
+ await conn.close()
+ print(f"\n✨ Ferdig! Oppdaterte priser for {total_updated} anlegg.")
+
+if __name__ == "__main__":
+ asyncio.run(run_greenfee_sync())
\ No newline at end of file
diff --git a/kode_eksport_1/frontend_src_app_FacilitySearch_tsx.txt b/kode_eksport_1/frontend_src_app_FacilitySearch_tsx.txt
index feeff9e..1904530 100644
--- a/kode_eksport_1/frontend_src_app_FacilitySearch_tsx.txt
+++ b/kode_eksport_1/frontend_src_app_FacilitySearch_tsx.txt
@@ -1,4 +1,17 @@
"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';
@@ -29,17 +42,28 @@ export default function FacilitySearch({ initialFacilities }: { initialFacilitie
const processed = useMemo(() => {
if (!Array.isArray(initialFacilities)) return [];
- const words = searchQuery.toLowerCase().trim().split(/\s+/).filter(w => w.length > 0);
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 = f.nsg_data && Object.keys(f.nsg_data).length > 0;
- const hasGolfamore = f.golfamore && (f.golfamore_data?.terms || f.golfamore === true);
+ const hasNSG = nsgData && Object.keys(nsgData).length > 0;
+ const hasGolfamore = f.golfamore === true;
- const blob = `${f.name} ${f.city} ${f.county} ${hasNSG ? 'nsg seniorgolf' : ''} ${hasGolfamore ? 'golfamore' : ''}`.toLowerCase();
+ 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, dist, hasNSG, hasGolfamore, matches };
+ return { ...f, statuses, amenities, dist, hasNSG, hasGolfamore, matches };
})
.filter(f => f.matches)
.sort((a, b) => {
@@ -56,39 +80,71 @@ export default function FacilitySearch({ initialFacilities }: { initialFacilitie
- setSearchQuery(e.target.value)} />
+ setSearchQuery(e.target.value)} />
- {processed.map((f: any) => (
-
-
-
-
- {(Array.isArray(f.course_statuses) ? f.course_statuses : []).slice(0, 1).map((s: any, idx: number) => {
- const raw = (s.status || "").toLowerCase();
- let color = "bg-gray-500";
- if (raw === 'aapen') color = "bg-[#8bc34a]";
- else if (raw.includes('vinter')) color = "bg-emerald-600";
- else if (raw.includes('snart')) color = "bg-amber-500";
- else if (raw === 'stengt') color = "bg-red-600";
- return
{STATUS_MAP[s.status] || s.status}
;
- })}
+ {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 (
+
+
+
+
+ {/* Status Badge */}
+
+ {STATUS_MAP[rawStatus] || rawStatus}
+
+
+ {/* Avstandspille (Mørk oliven #2d3319) */}
+ {f.dist !== Infinity && (
+
+ {Math.round(f.dist)} km unna
+
+ )}
-
- {f.hasNSG &&
NSG
}
- {f.hasGolfamore &&
G
}
+
+
+
{f.name}
+
{f.city} • {f.county}
+
+
+
+ {/* Hull-pille */}
+
+ {f.amenities?.antall_hull || '--'} HULL
+
+ {/* Banetype-pille */}
+
+ {f.banetype || 'SKOGSBANE'}
+
+
+
+ {/* Sirkel-ikoner (NSG / Golfamore) */}
+
+ {f.hasNSG && (
+
N
+ )}
+ {f.hasGolfamore && (
+
G
+ )}
+
+
-
-
-
{f.name}
-
{f.city} • {f.county}
-
- {f.amenities?.antall_hull || '--'} Hull
- {f.banetype || 'Park/Skog'}
-
-
-
- ))}
+
+ );
+ })}
);
diff --git a/kode_eksport_1/frontend_src_app_HeroSlider_tsx.txt b/kode_eksport_1/frontend_src_app_HeroSlider_tsx.txt
index 23e2469..515ce80 100644
--- a/kode_eksport_1/frontend_src_app_HeroSlider_tsx.txt
+++ b/kode_eksport_1/frontend_src_app_HeroSlider_tsx.txt
@@ -1,59 +1,130 @@
"use client";
+/**
+ * TEE OFF SYSTEM INSTRUCTIONS - HERO SLIDER v2.4
+ * ---------------------------------------------------------------------------
+ * REGEL 1: Kun baner med status 'aapen', 'aapner_snart', 'stenger_snart'
+ * eller 'aapen_med_vintergreener' skal prioriteres.
+ * REGEL 2: Baner med status 'nedlagt' eller 'under_utvikling' skal ALDRI vises.
+ * REGEL 3: Baner med generiske bilder (inneholder 'standard') skal ALDRI vises.
+ * REGEL 4: MANUELL EKSKLUDERING: Slugs i MANUAL_EXCLUSION_LIST skal aldri vises.
+ * REGEL 5: Slideren skal vise nøyaktig 5 baner.
+ * REGEL 6: Maks høyde er låst til 624px. Ingen badges.
+ * REGEL 7: Typografi: Nedjustert fontstørrelse (4xl mobil / 7xl desktop) for eleganse.
+ * REGEL 8: Utvalget skal være stabilt i én time (Hourly Seed) før det refreshes.
+ * ---------------------------------------------------------------------------
+ */
+
import { useState, useEffect, useMemo } from 'react';
import Link from 'next/link';
+const MANUAL_EXCLUSION_LIST = [
+ 'alsten-golfklubb', 'askim-golfklubb', 'bergen-golfklubb', 'eidskog-golfklubb',
+ 'eiker-golfklubb', 'floro-golfklubb', 'garder-golfklubb', 'hafjell-golfklubb',
+ 'halden-golfklubb', 'haugesund-golfklubb', 'hinnoy-golfklubb', 'hitra-golfklubb',
+ 'hurum-golfklubb', 'imjelt-pitch-putt', 'karmoy-golfklubb', 'kristiansund-og-omegn-golfklubb',
+ 'lommedalen-golfklubb', 'laerdal-golfklubb', 'moa-golfsenter', 'modum-golfklubb',
+ 'nes-golfklubb-09', 'nittedal-golfklubb', 'selbu-golfklubb', 'stryn-golfklubb',
+ 'sunnfjord-golfklubb', 'tysnes-golfklubb', 'vanylven-golfklubb', 'vesteralen-golfklubb',
+ 'vestlia-golf'
+];
+
export default function HeroSlider({ facilities }: { facilities: any[] }) {
const [currentIndex, setCurrentSlide] = useState(0);
const sliderItems = useMemo(() => {
if (!Array.isArray(facilities) || facilities.length === 0) return [];
- return facilities.filter(f => {
+ 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 || "";
- // Filter: Unngå standardbilder og krev fungerende bilde-sti
- const isRealImage = img && !img.toLowerCase().includes('standard');
+ if (!img || img.toLowerCase().includes('standard') || img.length < 5) return false;
const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : [];
- const isActive = statuses.some((s: any) =>
- ['aapen', 'aapner_snart', 'stenger_snart', 'aapen_med_vintergreener'].includes(s.status)
+ const isForbidden = statuses.some((s: any) =>
+ forbiddenStatuses.includes((s.status || "").toLowerCase())
);
-
- return isRealImage && isActive;
- })
- .sort(() => 0.5 - Math.random()) // Tilfeldig utvalg ved last
- .slice(0, 5);
+ 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 t = setInterval(() => setCurrentSlide(p => (p + 1) % sliderItems.length), 6000);
- return () => clearInterval(t);
- }, [sliderItems]);
+ const interval = setInterval(() => setCurrentSlide((p) => (p + 1) % sliderItems.length), 8000);
+ return () => clearInterval(interval);
+ }, [sliderItems.length]);
if (sliderItems.length === 0) return null;
return (
-
+
{sliderItems.map((f, i) => (
-
+
-
-
+
+
+
+
-
-
-
- {f.name}
-
-
- {f.county} • {f.city}
-
-
+
+
+ {/* FONT NEDJUSTERT FRA text-6xl md:text-9xl TIL text-4xl md:text-7xl */}
+
+ {f.name}
+
+
+ {f.county} • {f.city}
+
+
))}
-
+
+
+ {sliderItems.map((_, i) => (
+ setCurrentSlide(i)}
+ className={`h-1 transition-all duration-500 rounded-full ${
+ i === currentIndex ? 'w-16 bg-[#8bc34a]' : 'w-4 bg-white/20'
+ }`}
+ />
+ ))}
+
+
);
}
\ No newline at end of file
diff --git a/kode_eksport_1/frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt b/kode_eksport_1/frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt
index 269c07c..880e1d4 100644
--- a/kode_eksport_1/frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt
+++ b/kode_eksport_1/frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt
@@ -1,39 +1,86 @@
"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';
-// --- MONOKROME SVG IKONER (#11280f) ---
+const formatPhoneForUrl = (phone: string) => {
+ if (!phone) return "";
+ return phone.replace('+', '00').replace(/\s/g, '');
+};
+
+const renderValue = (val: string) => {
+ if (!val) return "Nei";
+ const hasLink = val.includes('
+ );
+};
+
const Icon = ({ children, className = "w-5 h-5" }: { children: React.ReactNode, className?: string }) => (
-
{children}
+
+ {children}
+
);
const ICONS = {
- web: <>
>,
+ web: <>
>,
phone:
,
mail: <>
>,
- map: <>
>,
- booking: <>
>,
- trophy: <>
>,
- guide: <>
>,
- camera: <>
>,
- webcam: <>
>,
- weather:
,
- facebook:
,
- instagram: <>
>
+ pin: <>
>,
+ booking: <>
>,
+ trophy: <>
>,
+ guide: <>
>,
+ camera: <>
>,
+ chart: <>
>,
+ weather: <>
>
};
export default function FacilityDetailView({ facility }: { facility: any }) {
const [showBackToTop, setShowBackToTop] = useState(false);
const [currentSlide, setCurrentSlide] = useState(0);
- const activeCourses = (facility.courses || []).filter((c: any) => c.holes && c.holes.length > 0);
- const amenities = facility.amenities || {};
- const gallery = Array.isArray(facility.gallery) && facility.gallery.length > 0 ? facility.gallery : [facility.image_url || FALLBACK_IMAGE];
- const shotzoom = Array.isArray(facility.shotzoom) ? facility.shotzoom : [];
- const linkClass = "text-orange-600 hover:underline transition-colors font-bold";
+ 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
= 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;
@@ -53,49 +100,61 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
};
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('nb-NO', { day: 'numeric', month: 'long', year: 'numeric' }) : null;
-
const weatherImg = facility.weather_url?.replace("/graf/dag/", "/innhold/").replace(/\/$/, "") + "/meteogram.svg";
return (
+ {/* 1. HERO SLIDER */}
{gallery.map((img: string, i: number) => (
))}
-
+ {/* BANESTATUS BADGES */}
+
{activeCourses.map((c: any) => (
- {STATUS_MAP[c.status] || c.status}
+
+ {c.name.toUpperCase()}: {STATUS_MAP[c.status] || c.status}
+
))}
- {facility.status_updated_at &&
Sist oppdatert: {formatDate(facility.status_updated_at)} }
+ {facility.status_updated_at && (
+
+ Sist oppdatert: {formatDate(facility.status_updated_at)}
+
+ )}
+ {/* FLYTENDE HURTIGKNAPPER (Inkludert Turneringer) */}
- {facility.website_url &&
}
- {facility.golfbox_booking_url &&
}
- {facility.golfbox_tournament_url &&
}
-
- {facility.weather_url &&
}
+ {facility.website_url &&
}
+ {facility.golfbox_booking_url &&
}
+ {facility.golfbox_tournament_url &&
}
+
+ {facility.weather_url &&
}
-
- {facility.logo_url &&
}
+ {/* HERO TEXT - pb-32 på mobil hindrer overlap med knapper */}
+
+ {facility.logo_url && (
+
+ )}
{facility.name}
{facility.county} • {facility.city}
+ {/* 2. STICKY NAV */}
scrollTo('intro')}>Info
scrollTo('weather')}>Vær
scrollTo('details')}>Detaljer
scrollTo('map')}>Kart
- scrollTo('video')}>Video
+ {facility.video_url && scrollTo('video')}>Video }
scrollTo('prices')}>Priser
scrollTo('scorecards')}>Scorekort
@@ -103,26 +162,97 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
+ {/* 3. INTRO & SIDEBAR (PRESENTASJON FØRST FOR MOBIL) */}
-
-
Kontakt & Adresse
-
-
Besøk nettsiden
-
{facility.phone || 'Ikke oppgitt'}
-
{facility.email || 'Ikke oppgitt'}
-
{facility.address} {facility.city}
-
- {facility.facebook_url &&
}
- {facility.instagram_url &&
}
+ {/* HOVEDINNHOLD (78%) - Kommer først på mobil pga koderrekkefølge */}
+
+ {facility.footnote &&
{facility.footnote}
}
+
+
+
+ {/* SIDEBAR (22%) - Kommer etter tekst på mobil */}
+
-
- {facility.footnote &&
{facility.footnote}
}
-
+
+
+ Se alle baner i {facility.county} →
+
+
+ {/* 4. 3-KOLONNE INFO */}
+
+
+
+
Banen
+
+
Hull: {amenities.antall_hull || '--'}
+
Lengde: {facility.length_meters ? `${facility.length_meters}m` : '--'}
+
Sesong: {facility.season || '--'}
+
Byggeår: {facility.established_year || '--'}
+
Banetype: {facility.banetype || 'Park/Skog'}
+
Arkitekt: {facility.architect || '--'}
+
+
+
+
Andre Tilbud
+
+
Drivingrange: {amenities.drivingrange || 'Nei'}
+
Nærspill: {amenities.treningsgreen || 'Ja'}
+
Proshop: {renderValue(amenities.proshop)}
+
Kølleutleie: {amenities.kolleutleie || 'Ja'}
+
Simulator: {renderValue(amenities.simulator)}
+
Head Pro: {renderValue(amenities.pro)}
+
Kafé: {renderValue(amenities.kafe)}
+
+
+
+
+ {/* 5. VÆR SEKSJON */}
Vær for {facility.name}
@@ -130,89 +260,79 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
-
-
-
-
-
-
Banen
-
-
Hull: {amenities.antall_hull || '--'}
-
Lengde: {facility.length_meters ? `${facility.length_meters}m` : '--'}
-
Sesong: {facility.season || '--'}
-
Byggeår: {facility.established_year || '--'}
-
Banetype: {facility.banetype || 'Park/Skog'}
-
Arkitekt: {facility.architect || '--'}
-
-
-
-
Fasiliteter
-
-
Drivingrange: {amenities.drivingrange || 'Nei'}
-
Nærspill: Ja
-
Proshop: Ja `) : 'Nei' }} />
-
Kølleutleie: {amenities.kolleutleie || 'Nei'}
-
Simulator: Ja `) : 'Nei' }} />
-
Head Pro:
-
Kafé:
-
-
-
-
-
+ {/* 6. KART SEKSJON */}
+ {/* 7. VIDEO SEKSJON */}
{facility.video_url && (
)}
+ {/* 8. PRISER & GJESTESPILL */}
-
-
⛳ Gjestespill
-
- {Array.isArray(facility.greenfee) && facility.greenfee.length > 0 ? (
- facility.greenfee.map((g: any, i: number) => (
-
{g.priskategori} kr {g.pris_voksne || '--'},-
+
+
Gjestespill
+
+ {Object.keys(groupedGreenfee).length > 0 ? (
+ Object.entries(groupedGreenfee).map(([bane, priser], idx) => (
+
+ {!(bane === "Gjestespill" && Object.keys(groupedGreenfee).length === 1) && (
+
{bane}
+ )}
+
+
Voksne
+ {priser.map((g, i) => (
+
+ {g.priskategori}
+ kr {g.pris_voksne || '--'},-
+
+ ))}
+
+ {priser.some(g => g.pris_junior) && (
+
+
Junior
+ {priser.map((g, i) => (
+
+ {g.priskategori}
+ kr {g.pris_junior || '--'},-
+
+ ))}
+
+ )}
+
))
) :
Ingen priser funnet.
}
-
Krav: {facility.guest_requirements || 'Klubbhandicap'}
+
Krav: {facility.guest_requirements || 'Klubbhandicap'}
-
+
-
💛 Medlemskap
+
Medlemskap
{facility.navn_standard_medlemskap || "Standard"}
-
kr {facility.standard_medlemskap || '--'},-
- {facility.standard_medlemskap_kommentarer &&
{facility.standard_medlemskap_kommentarer}
}
+
kr {facility.standard_medlemskap || '--'},-
+ {facility.standard_medlemskap_kommentarer &&
{facility.standard_medlemskap_kommentarer}
}
{facility.navn_rimeligste_alternativ && (
-
{facility.navn_rimeligste_alternativ} kr {facility.rimeligste_alternativ},-
+
{facility.navn_rimeligste_alternativ} kr {facility.rimeligste_alternativ},-
)}
-
Se alle alternativer
+
Se alle alternativer
+ {/* 9. SCOREKORT SEKSJON */}
Scorekort
@@ -225,7 +345,9 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
- {showBackToTop && ( window.scrollTo({ top: 0, behavior: 'smooth' })} className="fixed bottom-8 right-8 w-14 h-14 bg-[#11280f] text-white rounded-full shadow-2xl flex items-center justify-center text-2xl z-[100] hover:scale-110 transition-all border-4 border-white/20">↑ )}
+ {showBackToTop && (
+ window.scrollTo({ top: 0, behavior: 'smooth' })} className="fixed bottom-8 right-8 w-14 h-14 bg-[#11280f] text-white rounded-full shadow-2xl flex items-center justify-center text-2xl z-[100] border-4 border-white/20 hover:scale-110 transition-all">↑
+ )}
);
}
\ No newline at end of file
diff --git a/struktur1_dump.txt b/struktur2_dump.txt
similarity index 98%
rename from struktur1_dump.txt
rename to struktur2_dump.txt
index 5b3b821..afed31b 100644
--- a/struktur1_dump.txt
+++ b/struktur2_dump.txt
@@ -126,7 +126,8 @@ CREATE TABLE public.courses (
status character varying(255),
is_main_course boolean DEFAULT true,
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
- tee_boxes jsonb
+ tee_boxes jsonb,
+ scrape_keyword text
);
@@ -219,7 +220,10 @@ CREATE TABLE public.facilities (
rimeligste_alternativ integer,
rimeligste_alternativ_kommentarer text,
medlemskap_url text,
- banetype text
+ banetype text,
+ scrape_status_url text,
+ scrape_status_selector text,
+ scrape_method character varying(50) DEFAULT 'css_selector'::character varying
);
diff --git a/struktur_dump.txt b/struktur_dump.txt
deleted file mode 100644
index 7dbf121..0000000
--- a/struktur_dump.txt
+++ /dev/null
@@ -1,516 +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: 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
-);
-
-
-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 text,
- gallery jsonb
-);
-
-
-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: 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: 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: 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
---
-