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.name} - {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.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 - -
-
Utslag - -
-
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 */} -
- - - - - - - - - {activeColumns.map((col, i) => ( - - ))} - - - - {/* UT-RUNDE */} - {holesOut.map((h: any) => { - const extra = getExtraStrokes(h.hcp_index); - return ( - - - - - - - {activeColumns.map((col, i) => ( - - ))} - - ); - })} - - {/* UT RAD */} - - - - - {activeColumns.map((col, i) => ( - - ))} - - - {/* INN-RUNDE */} - {hasInHoles && holesIn.map((h: any) => { - const extra = getExtraStrokes(h.hcp_index); - return ( - - - - - - - {activeColumns.map((col, i) => ( - - ))} - - ); - })} - - {/* INN RAD */} - {hasInHoles && ( - - - - - {activeColumns.map((col, i) => ( - - ))} - - )} - - {/* TOTAL RAD */} - - - - - {activeColumns.map((col, i) => ( - - ))} - - -
HullParHCPMottattDin Par{col.label}
{h.hole_number}{h.par}{h.hcp_index}{extra > 0 ? `+${extra}` : '-'}{h.par + extra} - {h.lengths?.[col.key] || '--'} -
Ut{sumPar(holesOut)}{sumLen(holesOut, col.key)}
{h.hole_number}{h.par}{h.hcp_index}{extra > 0 ? `+${extra}` : '-'}{h.par + extra} - {h.lengths?.[col.key] || '--'} -
Inn{sumPar(holesIn)}{sumLen(holesIn, col.key)}
Totalt{sumPar(allHoles)} - {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 - -
-

Kontakt & Adresse

- 🌐 Besøk nettsiden - 📞 {facility.phone} - ✉️ {facility.email} -
- 🏠 {facility.address || facility.city} - Se alle baner i {facility.county} → -
-
- - {/* VÆR-WIDGET */} - {facility.weather_url && ( -
-

Værvarsel (48t)

- Vær -
- )} - - {/* ANDRE RESSURSER */} -
-

Andre ressurser

-
- {facility.flyfoto_url && 🚁 Flyfoto} - {facility.webcam_url && 📹 Webkamera} - {facility.baneguide_url && 📖 Baneguide} -
-
-
- - {/* 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 */} -
-
- -
- )} -
- - {/* SCOREKORT */} -
- {activeCourses.map((c: any) => )} -
- - {/* SLOPING TABELLER */} -
-
-

Slopetabeller

- Gyldighet: {facility.gyldig_til_og_med || 'Ukjent'} -
-
- {['herrer', 'damer'].map(gender => ( -
-

{gender}

- - - - - - {(activeCourses[0]?.tee_boxes?.[gender] || []).map((tee: any, i: number) => ( - - - - - - ))} - -
UtslagCRSlope
{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) => ( - -
- {f.name} -
- {(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 ( + +
+ {f.name} + + {/* 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.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) => ( +
+
); } \ 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 && ( +
Logo
+ )}

{facility.name}

{facility.county} • {facility.city}

+ {/* 2. STICKY NAV */}