From 1d8ce4c52d4470911bdea87a96efeec92e5f4869 Mon Sep 17 00:00:00 2001 From: Erol Date: Fri, 6 Mar 2026 09:39:32 +0100 Subject: [PATCH] =?UTF-8?q?F=C3=B8r=20ny=20dag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fil-tre.txt | 15 +- frontend/src/app/admin/page.tsx | 123 ++-- kode_eksport_1/backend_create_admin_py.txt | 64 -- kode_eksport_1/backend_import_gallery_py.txt | 111 ---- kode_eksport_1/backend_import_wp_py.txt | 157 ----- kode_eksport_1/backend_main_py.txt | 253 ------- .../backend_scrape_golfamore1_3_py.txt | 124 ---- kode_eksport_1/backend_scrape_nsg_3_py.txt | 96 --- kode_eksport_1/backend_scrape_status_py.txt | 174 ----- kode_eksport_1/backend_sync_greenfee_py.txt | 79 --- kode_eksport_1/backend_update_admin_py.txt | 52 -- kode_eksport_1/eksport_script_py.txt | 72 -- kode_eksport_1/frontend_next-env_d_ts.txt | 6 - kode_eksport_1/frontend_next_config_ts.txt | 7 - .../frontend_src_app_FacilitySearch_tsx.txt | 151 ----- .../frontend_src_app_HeroSlider_tsx.txt | 130 ---- .../frontend_src_app_admin_login_page_tsx.txt | 103 --- .../frontend_src_app_admin_page_tsx.txt | 91 --- ...app_golfbaner_[slug]_CourseDisplay_tsx.txt | 196 ------ ...olfbaner_[slug]_FacilityDetailView_tsx.txt | 353 ---------- ...tend_src_app_golfbaner_[slug]_page_tsx.txt | 17 - .../frontend_src_app_layout_tsx.txt | 19 - kode_eksport_1/frontend_src_app_page_tsx.txt | 37 -- .../frontend_src_components_Header_tsx.txt | 45 -- .../frontend_src_config_constants_ts.txt | 42 -- kode_eksport_1/frontend_src_middleware_ts.txt | 36 - kode_eksport_1/test_tjome_py.txt | 44 -- struktur2_dump.txt | 622 ------------------ struktur3_dump.txt | 3 +- 29 files changed, 89 insertions(+), 3133 deletions(-) delete mode 100644 kode_eksport_1/backend_create_admin_py.txt delete mode 100644 kode_eksport_1/backend_import_gallery_py.txt delete mode 100644 kode_eksport_1/backend_import_wp_py.txt delete mode 100644 kode_eksport_1/backend_main_py.txt delete mode 100644 kode_eksport_1/backend_scrape_golfamore1_3_py.txt delete mode 100644 kode_eksport_1/backend_scrape_nsg_3_py.txt delete mode 100644 kode_eksport_1/backend_scrape_status_py.txt delete mode 100644 kode_eksport_1/backend_sync_greenfee_py.txt delete mode 100644 kode_eksport_1/backend_update_admin_py.txt delete mode 100644 kode_eksport_1/eksport_script_py.txt delete mode 100644 kode_eksport_1/frontend_next-env_d_ts.txt delete mode 100644 kode_eksport_1/frontend_next_config_ts.txt delete mode 100644 kode_eksport_1/frontend_src_app_FacilitySearch_tsx.txt delete mode 100644 kode_eksport_1/frontend_src_app_HeroSlider_tsx.txt delete mode 100644 kode_eksport_1/frontend_src_app_admin_login_page_tsx.txt delete mode 100644 kode_eksport_1/frontend_src_app_admin_page_tsx.txt delete mode 100644 kode_eksport_1/frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt delete mode 100644 kode_eksport_1/frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt delete mode 100644 kode_eksport_1/frontend_src_app_golfbaner_[slug]_page_tsx.txt delete mode 100644 kode_eksport_1/frontend_src_app_layout_tsx.txt delete mode 100644 kode_eksport_1/frontend_src_app_page_tsx.txt delete mode 100644 kode_eksport_1/frontend_src_components_Header_tsx.txt delete mode 100644 kode_eksport_1/frontend_src_config_constants_ts.txt delete mode 100644 kode_eksport_1/frontend_src_middleware_ts.txt delete mode 100644 kode_eksport_1/test_tjome_py.txt delete mode 100644 struktur2_dump.txt diff --git a/fil-tre.txt b/fil-tre.txt index 26d6c9c..c1e507e 100644 --- a/fil-tre.txt +++ b/fil-tre.txt @@ -1,9 +1,6 @@ 📁 teeoff/ 📄 test_tjome.py - 📄 fil-tre.txt - 📄 struktur2_dump.txt 📄 seed.sql - 📄 struktur3_dump.txt 📄 eksport_script.py 📄 update_golfbox.sql 📄 docker-compose.yml @@ -663,6 +660,7 @@ 📄 struktur_dump.txt 📄 middleware.ts 📁 components/ + 📄 ScrapeMethodSelect.tsx 📄 Header.tsx 📁 config/ 📄 constants.ts @@ -684,34 +682,29 @@ 📄 page.tsx 📁 kode_eksport_1/ 📄 frontend_src_components_Header_tsx.txt - 📄 backend_scrape_nsg_3_py.txt 📄 frontend_next-env_d_ts.txt 📄 frontend_src_app_layout_tsx.txt 📄 frontend_src_app_page_tsx.txt 📄 eksport_script_py.txt + 📄 frontend_src_components_ScrapeMethodSelect_tsx.txt 📄 frontend_src_app_golfbaner_[slug]_page_tsx.txt - 📄 backend_import_wp_py.txt 📄 frontend_src_middleware_ts.txt 📄 test_tjome_py.txt 📄 frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt 📄 frontend_next_config_ts.txt 📄 frontend_src_app_admin_login_page_tsx.txt 📄 frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt - 📄 backend_main_py.txt 📄 frontend_src_app_admin_page_tsx.txt 📄 frontend_src_app_HeroSlider_tsx.txt - 📄 backend_create_admin_py.txt - 📄 backend_sync_greenfee_py.txt - 📄 backend_scrape_status_py.txt - 📄 backend_scrape_golfamore1_3_py.txt 📄 frontend_src_app_FacilitySearch_tsx.txt 📄 frontend_src_config_constants_ts.txt - 📄 backend_import_gallery_py.txt 📁 backend/ 📄 scrape_nsg_3.py 📄 update_admin.py + 📄 test_gemini.py 📄 import_gallery.py 📄 .env + 📄 test_login.py 📄 sync_greenfee.py 📄 scrape_status.py 📄 scrape_golfamore1.3.py diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index f1ab525..cef7f59 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1,13 +1,13 @@ "use client"; /** - * TEE OFF ADMIN DASHBOARD v1.7 - RESPONSIVT MED AI-HVISKER & KILL SWITCH + * TEE OFF ADMIN DASHBOARD v1.8 - RESPONSIVT MED AI-HVISKER, KILL SWITCH & FILTER * --------------------------------------------------------------------------- * PLASSERING: frontend/src/app/admin/page.tsx - * FUNKSJON: Live-oppdatering, massevalg, redigering og SAMMENTREKKBAR meny. + * FUNKSJON: Live-oppdatering, massevalg, redigering, meny og smart-filtrering. * --------------------------------------------------------------------------- */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { API_URL } from "@/config/constants"; import ScrapeMethodSelect from "@/components/ScrapeMethodSelect"; @@ -16,13 +16,12 @@ export default function AdminDashboard() { const [loading, setLoading] = useState(true); const [selectedFacilities, setSelectedFacilities] = useState([]); const [isScraping, setIsScraping] = useState(false); - - // NY: State for å sjekke om sidebaren er klappet sammen const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); - const [editingFacility, setEditingFacility] = useState(null); - // OPPDATERT: Lagt til ai_instruction og courses for manuell overstyring + // NYTT: State for å holde styr på hvilket filter som er aktivt + const [statusFilter, setStatusFilter] = useState('alle'); + const [editForm, setEditForm] = useState({ scrape_status_url: '', scrape_status_selector: '', @@ -30,7 +29,7 @@ export default function AdminDashboard() { ai_instruction: '', courses: [] as any[] }); - + const [isSaving, setIsSaving] = useState(false); const fetchFacilities = () => { @@ -57,9 +56,44 @@ export default function AdminDashboard() { return () => clearInterval(interval); }, [isScraping]); + // NYTT: Filtreringslogikken som kjører automatisk når facilities eller filteret endres + const filteredFacilities = useMemo(() => { + if (statusFilter === 'alle') return facilities; + + return facilities.map(facility => { + if (!facility.course_statuses) return facility; + + // Filtrer banene innad i hvert anlegg + const filteredCourses = facility.course_statuses.filter((cs: any) => { + const s = cs.status || 'ukjent'; + + if (statusFilter === 'aapne') { + return s === 'aapen'; + } + if (statusFilter === 'ikke_stengt') { + return ['aapen', 'aapen_med_vintergreener', 'aapner_snart'].includes(s); + } + if (statusFilter === 'stengt') { + return s === 'stengt' || s === 'nedlagt'; + } + if (statusFilter === 'ukjent_feil') { + return s === 'ukjent' || s === 'NOT_FOUND'; + } + return true; + }); + + // Returner anlegget kun med de banene som matcher + return { ...facility, course_statuses: filteredCourses }; + + }).filter(facility => facility.course_statuses && facility.course_statuses.length > 0); + // Skjul anlegget helt hvis det ikke har noen baner som matcher filteret + + }, [facilities, statusFilter]); + + // OPPDATERT: "Velg alle" gjelder nå kun de anleggene som er synlige i filteret const handleSelectAll = (e: React.ChangeEvent) => { if (e.target.checked) { - setSelectedFacilities(facilities.map(f => f.id)); + setSelectedFacilities(filteredFacilities.map(f => f.id)); } else { setSelectedFacilities([]); } @@ -73,8 +107,7 @@ export default function AdminDashboard() { } }; -const handleRunScrapers = async () => { - // Hvis den allerede skraper, lar vi brukeren trykke på knappen for å avbryte UI-oppdateringen + const handleRunScrapers = async () => { if (isScraping) { setIsScraping(false); return; @@ -87,11 +120,10 @@ const handleRunScrapers = async () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ facility_ids: selectedFacilities }) }); + if (!response.ok) throw new Error("Kunne ikke starte skraping"); - // Beregn dynamisk timeout: 40 sekunder per valgte anlegg (Minimum 1 minutt) const timeoutMs = Math.max(selectedFacilities.length * 40 * 1000, 60000); - setSelectedFacilities([]); setTimeout(() => setIsScraping(false), timeoutMs); } catch (error) { @@ -101,7 +133,6 @@ const handleRunScrapers = async () => { } }; - // OPPDATERT: Laster inn eksisterende banestatuser og ai_instruction const openEditModal = (facility: any) => { setEditingFacility(facility); setEditForm({ @@ -121,6 +152,7 @@ const handleRunScrapers = async () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(editForm) }); + if (!response.ok) throw new Error("Feil ved lagring"); setEditingFacility(null); @@ -143,7 +175,7 @@ const handleRunScrapers = async () => {
-

Rediger Konfigurasjon

+

Rediger Konfigurasjon

{editingFacility.name}

@@ -172,9 +204,8 @@ const handleRunScrapers = async () => { -
+
- {/* Boks som vises hvis Gemini AI er valgt */} {editForm.scrape_method === 'llm_parse' && (
@@ -189,7 +220,6 @@ const handleRunScrapers = async () => {
)} - {/* Boks som vises hvis MANUELL er valgt */} {editForm.scrape_method === 'manual' && (
@@ -219,9 +249,8 @@ const handleRunScrapers = async () => { ))}
- )} + )} - {/* Boks for gammel CSS-skraping */} {(editForm.scrape_method === 'css_selector' || editForm.scrape_method === 'click_then_css' || editForm.scrape_method === 'iframe_golfbox') && (
@@ -247,7 +276,7 @@ const handleRunScrapers = async () => {
)} - {/* NY SIDEBAR: Responsiv og sammentrekkbar */} + {/* SIDEBAR */} - {/* HOVEDINNHOLD: flex-1 og min-w-0 løser responsivitetsproblemet */} + {/* HOVEDINNHOLD */}
- {/* Mobil-meny (Vises bare på veldig små skjermer) */}

TeeOff Admin

MONITOR
- {/* Mindre padding på mobil/tablet (p-6) for å spare plass */}
-
+
-

Scraping Monitor

-

Sjekker status på {facilities.length} anlegg

+

Scraping Monitor

+

Sjekker status på {filteredFacilities.length} anlegg (av {facilities.length})

+ + {/* NYTT: Filter-meny UI */} +
+ + +
- {/* Her er den viktige overflow-klassen for selve tabellen */}
- + @@ -327,8 +370,8 @@ const handleRunScrapers = async () => { - {facilities.map((f: any) => ( - + {filteredFacilities.map((f: any) => ( + - ))} -
0} + checked={selectedFacilities.length === filteredFacilities.length && filteredFacilities.length > 0} onChange={handleSelectAll} />
{
{f.name}
{f.city}
+
{f.scrape_status_url ? f.scrape_status_url : Mangler URL}
{f.scrape_status_selector}
- + {f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'} @@ -362,30 +405,30 @@ const handleRunScrapers = async () => { if (cs.status === "aapen_med_vintergreener" || cs.status === "aapner_snart") badgeColor = "bg-yellow-100 text-yellow-700"; return ( -
+
{cs.name} - + {cs.status || 'UKJENT'}
) - })} + })}
+
diff --git a/kode_eksport_1/backend_create_admin_py.txt b/kode_eksport_1/backend_create_admin_py.txt deleted file mode 100644 index c2d3dcb..0000000 --- a/kode_eksport_1/backend_create_admin_py.txt +++ /dev/null @@ -1,64 +0,0 @@ -""" -TEE OFF ADMIN GENERATOR v1.9 (DEBUG & BULLETPROOF) ---------------------------------------------------------------------------- -FUNKSJON: Genererer SQL-kommando for administrator. -STATUS: Beholder TRUNCATE for feilsøking, men sikrer SQL-innsendingen. ---------------------------------------------------------------------------- -""" -import pyotp -from passlib.hash import pbkdf2_sha256 -import getpass -import sys - -def generate_admin(): - print("\n" + "="*50) - print(" TEE OFF ADMIN GENERATOR v1.9 (DEBUG MODE)") - print("="*50) - - username = input("Brukernavn (f.eks Envide Webutvikling): ").strip() - email = input("E-post: ").strip() - - # Sikre mot SQL-feil hvis navnet/eposten inneholder apostrof - safe_username = username.replace("'", "''") - safe_email = email.replace("'", "''") - - # Passord-verifisering - while True: - password = getpass.getpass("Skriv inn passord: ") - password_confirm = getpass.getpass("Gjenta passord: ") - - if password == password_confirm: - if len(password) < 8: - print("⚠️ Advarsel: Passordet bør være minst 8 tegn.") - print(f"\n[DEBUG] Passord akseptert. Lengde: {len(password)} tegn.") - break - else: - print("❌ Passordene er ikke like. Prøv igjen.\n") - - otp_secret = pyotp.random_base32() - - print("⏳ Genererer PBKDF2-hash...") - password_hash = pbkdf2_sha256.hash(password) - print(f"[DEBUG] Hash generert. Lengde: {len(password_hash)} tegn.") - - print("\n✅ GENERERING VELLYKKET!") - print("-" * 50) - print("SLIK LEGGER DU INN BRUKEREN TRYGT:") - print("-" * 50) - print("1. Gå inn i databasen:") - print(" docker exec -it teeoff_db psql -U teeoff_admin -d teeoff") - print("\n2. Lim inn disse to linjene nøyaktig slik de står:") - print("TRUNCATE admins;") - print(f"INSERT INTO admins (username, email, password_hash, otp_secret) VALUES ('{safe_username}', '{safe_email}', '{password_hash}', '{otp_secret}');") - print("\n3. Skriv 'exit' for å gå ut.") - print("-" * 50) - print("4. KONFIGURER 2FA I GOOGLE AUTHENTICATOR:") - print(f"Bruk denne nøkkelen: {otp_secret}") - print("-" * 50 + "\n") - -if __name__ == "__main__": - try: - generate_admin() - except KeyboardInterrupt: - print("\nAvbrutt.") - sys.exit(0) \ No newline at end of file diff --git a/kode_eksport_1/backend_import_gallery_py.txt b/kode_eksport_1/backend_import_gallery_py.txt deleted file mode 100644 index 116aa85..0000000 --- a/kode_eksport_1/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_1/backend_import_wp_py.txt b/kode_eksport_1/backend_import_wp_py.txt deleted file mode 100644 index 3eac73e..0000000 --- a/kode_eksport_1/backend_import_wp_py.txt +++ /dev/null @@ -1,157 +0,0 @@ -import asyncio, asyncpg, urllib.request, json, re, os, requests - -# --- KONFIGURASJON --- -DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff" -WP_API_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100&_embed" -MEDIA_ENDPOINT = "https://teeoff.no/wp-json/wp/v2/media" -MEDIA_DIR = "./public/media" - -os.makedirs(MEDIA_DIR, exist_ok=True) -media_cache = {} - -def get_url_from_id(media_id): - if not media_id or not isinstance(media_id, int): return None - if media_id in media_cache: return media_cache[media_id] - try: - resp = requests.get(f"{MEDIA_ENDPOINT}/{media_id}", timeout=10) - if resp.status_code == 200: - url = resp.json().get('source_url') - media_cache[media_id] = url - return url - except: return None - -def download_media(url, slug, prefix): - if not isinstance(url, str) or not url: return None - clean_url = url.replace("https:///", "https://").replace("http:///", "http://") - if "teeoff.no" not in clean_url: return clean_url - try: - ext = clean_url.split('.')[-1].split('?')[0].lower() - if len(ext) > 4 or len(ext) < 3: ext = "jpg" - filename = f"{prefix}_{slug}.{ext}" - filepath = os.path.join(MEDIA_DIR, filename) - if os.path.exists(filepath): return f"/media/{filename}" - response = requests.get(clean_url, timeout=15) - if response.status_code == 200: - with open(filepath, 'wb') as f: f.write(response.content) - return f"/media/{filename}" - except: pass - return None - -def decode_html(text): - if not text: return "" - return str(text).replace('&', '&').replace('&', '&').replace(' ', ' ').strip() - -def parse_int(val): - if val is None or val == '': return None - try: - nums = re.findall(r'\d+', str(val)) - return int(nums[0]) if nums else None - except: return None - -def extract_url(val): - if isinstance(val, dict): return val.get('url') - if isinstance(val, str): return val - return None - -async def run_master_import(): - print("🚀 Starter MASTER IMPORT v9.2 (Robust datakonvertering & Banetype)...") - conn = await asyncpg.connect(DB_URL) - - # Tømmer kun courses og holes (hjelpetabeller) - await conn.execute("TRUNCATE courses, holes RESTART IDENTITY CASCADE;") - - page = 1 - while True: - try: - req = urllib.request.Request(f"{WP_API_URL}&page={page}", headers={'User-Agent': 'TeeOff-V9.2'}) - with urllib.request.urlopen(req) as response: - data = json.loads(response.read().decode()) - except: break - if not data: break - - for post in data: - acf = post.get('acf', {}) - slug = post['slug'] - name = decode_html(post.get('title', {}).get('rendered', '')) - print(f"📦 Mapper {name}...") - - # Media & Identifiers - local_main_img = download_media(post.get('_embedded', {}).get('wp:featuredmedia', [{}])[0].get('source_url'), slug, "main") - local_logo = download_media(get_url_from_id(acf.get('logo')) if isinstance(acf.get('logo'), int) else extract_url(acf.get('logo')), slug, "logo") - - # Galleri - slides = acf.get('slides') or [] - local_gallery = [download_media(get_url_from_id(s) if isinstance(s, int) else extract_url(s), f"{slug}_{i}", "slide") for i, s in enumerate(slides)] - local_gallery = [url for url in local_gallery if url] - - # Golfbox - booking_id = acf.get('golfbox_booking_id') - gb_booking_url = f"http://www.golfbox.no/site/system/redirect.asp?locale=nb_NO&rUrl=%2Fsite%2Fressources%2Fbooking%2Fgrid.asp%3FRessource_GUID%3D%{{{str(booking_id).strip().replace('{','').replace('}','')}}}" if booking_id else None - - # --- UPSERT FACILITY --- - # Merk: $16 (status_updated_at) pakkes nå inn i TO_DATE for å unngå krasj - await conn.execute(''' - INSERT INTO facilities ( - name, slug, description, address, city, county, established_year, season, - email, phone, website_url, image_url, logo_url, video_url, - amenities, status_updated_at, gallery, banetype, - ngf_number, golfbox_club_id, golfbox_booking_url, - facebook_url, instagram_url, baneguide_url, flyfoto_url, - golfbox_tournament_url, footnote, social_links, webcam_url, - weather_url, architect, - navn_standard_medlemskap, standard_medlemskap, standard_medlemskap_kommentarer, - navn_rimeligste_alternativ, rimeligste_alternativ, rimeligste_alternativ_kommentarer, - medlemskap_url - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb, - TO_DATE(NULLIF($16, ''), 'YYYYMMDD'), - $17::jsonb, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28::jsonb, - $29, $30, $31, $32, $33, $34, $35, $36, $37, $38 - ) - ON CONFLICT (slug) DO UPDATE SET - name = EXCLUDED.name, - description = EXCLUDED.description, - address = EXCLUDED.address, - city = EXCLUDED.city, - phone = EXCLUDED.phone, - email = EXCLUDED.email, - website_url = EXCLUDED.website_url, - image_url = EXCLUDED.image_url, - logo_url = EXCLUDED.logo_url, - amenities = EXCLUDED.amenities, - gallery = EXCLUDED.gallery, - status_updated_at = EXCLUDED.status_updated_at, - banetype = EXCLUDED.banetype, - architect = EXCLUDED.architect - ''', name, slug, decode_html(acf.get('beskrivelse')), acf.get('gateadresse'), acf.get('postnummer_og_poststed'), acf.get('fylke'), parse_int(acf.get('byggear')), acf.get('sesong'), acf.get('e-post'), acf.get('telefon'), extract_url(acf.get('hjemmeside')), local_main_img, local_logo, None, json.dumps({"drivingrange": decode_html(acf.get("drivingrange")), "treningsgreen": decode_html(acf.get("treningsgreen")), "proshop": decode_html(acf.get("proshop")), "kafe": decode_html(acf.get("kafe")), "bilutleie": decode_html(acf.get("bilutleie")), "kolleutleie": decode_html(acf.get("kolleutleie")), "pro": decode_html(acf.get("pro")), "simulator": decode_html(acf.get("golfsimulator")), "antall_hull": decode_html(acf.get("antall_hull"))}), - acf.get('dato_for_oppdatert_status'), # $16 - json.dumps(local_gallery), decode_html(acf.get('banetype')), - parse_int(acf.get('klubbnummer_norges_golfforbund')), parse_int(acf.get('klubbnummer_golfbox')), gb_booking_url, extract_url(acf.get('facebook_url')), extract_url(acf.get('instagram_url')), extract_url(acf.get('baneguide')), extract_url(acf.get('flyfoto')), extract_url(acf.get('golfbox')), decode_html(acf.get('fotnote')), json.dumps(acf.get('sosiale_lenker') or []), decode_html(acf.get('webkamera')), extract_url(acf.get('varmelding_yr')), decode_html(acf.get('arkitekt')), decode_html(acf.get('navn_standard_medlemskap')), parse_int(acf.get('standard_medlemskap')), decode_html(acf.get('standard_medlemskap_kommentarer')), decode_html(acf.get('navn_rimeligste_alternativ')), parse_int(acf.get('rimeligste_alternativ')), decode_html(acf.get('rimeligste_alternativ_kommentarer')), extract_url(acf.get('medlemskap_url'))) - - fac_id = (await conn.fetchrow("SELECT id FROM facilities WHERE slug = $1", slug))['id'] - - # Baner og Hull - fac_main_len = 0 - for suffix in ['', '_bane_to']: - c_name = acf.get('navn_pa_hovedbane' if suffix == '' else 'navn_pa_sekundar_bane') or ('Hovedbanen' if suffix == '' else 'Bane 2') - status = acf.get('banestatus' if suffix == '' else 'banestatus_sekundar_bane') - if suffix == '_bane_to' and (status == 'finnes_ingen_bane_to' or not parse_int(acf.get('hull_1_par_bane_to'))): continue - course_id = await conn.fetchval('INSERT INTO courses (facility_id, name, status, par, is_main_course, tee_boxes, architect) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id', fac_id, c_name, status, parse_int(acf.get('totalt_par' if suffix == '' else 'totalt_par_bane_to')), (suffix == ''), json.dumps({"herrer": acf.get(f"utslag_herrer{suffix}"), "damer": acf.get(f"utslag_damer{suffix}")}), decode_html(acf.get('arkitekt'))) - curr_len = 0 - for h_num in range(1, 19): - p = parse_int(acf.get(f'hull_{h_num}_par{suffix}')) - if p: - idx = parse_int(acf.get(f'hull_{h_num}_index{suffix}')) - lens = {k: parse_int(acf.get(f'{k}_hull_{h_num}{suffix}')) for k in ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest']} - curr_len += (lens['lengst'] or 0) - await conn.execute('INSERT INTO holes (course_id, hole_number, par, hcp_index, lengths) VALUES ($1, $2, $3, $4, $5::jsonb)', course_id, h_num, p, idx, json.dumps(lens)) - await conn.execute("UPDATE courses SET length_meters = $1 WHERE id = $2", curr_len, course_id) - if suffix == '': fac_main_len = curr_len - await conn.execute("UPDATE facilities SET length_meters = $1 WHERE id = $2", fac_main_len, fac_id) - - page += 1 - await conn.close() - print("✅ IMPORT FERDIG!") - -if __name__ == "__main__": - asyncio.run(run_master_import()) \ No newline at end of file diff --git a/kode_eksport_1/backend_main_py.txt b/kode_eksport_1/backend_main_py.txt deleted file mode 100644 index d57f6e1..0000000 --- a/kode_eksport_1/backend_main_py.txt +++ /dev/null @@ -1,253 +0,0 @@ -""" -TEE OFF BACKEND API v3.6.8 - THE RESTORED MASTER VERSION ---------------------------------------------------------------------------- -REGEL 1: Bruk str (ikke string) for type-hinting. -REGEL 2: Inkluder alle subqueries for banestatus og hull-data. -REGEL 3: Robust JSON-parsing (format_row) for å hindre Frontend-krasj. -REGEL 4: JWT-sesjoner lagres i HTTP-only cookies. -LOV: Aldri trunker eller slett logikk for "effektivitet". ---------------------------------------------------------------------------- -""" - -from fastapi import FastAPI, HTTPException, Response, Cookie, Depends, Request -from fastapi.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager -import asyncpg -import json -import pyotp -import os -from datetime import datetime, date, timedelta -from jose import jwt, JWTError -from passlib.context import CryptContext -from dotenv import load_dotenv - -load_dotenv() - -# --- KONFIGURASJON --- -DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") -SECRET_KEY = os.getenv("JWT_SECRET", "super_secret_change_this_in_production") -ALGORITHM = "HS256" - -# VIKTIG: Vi bruker PBKDF2-SHA256 for å unngå Bcrypt-begrensninger -pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") - -def format_row(row): - """ - Vasker data fra databasen: - 1. Konverterer datoer til ISO-format. - 2. Tvinger tekst-JSON (stringified JSON) over til ekte Python objekter/lister. - 3. Sikrer at lister og objekter aldri er None for å hindre Frontend-krasj. - """ - if row is None: - return None - - d = dict(row) - - # 1. Håndter dato- og tidsformater for JSON-serialisering - for key in ['status_updated_at', 'created_at']: - if isinstance(d.get(key), (date, datetime)): - d[key] = d[key].isoformat() - - # 2. Definer alle felter som inneholder JSON-data - json_list_fields = [ - 'course_statuses', 'courses', 'gallery', 'greenfee', - 'faqs', 'shotzoom', 'social_links', 'holes' - ] - json_dict_fields = [ - 'amenities', 'vtg', 'nsg_data', 'golfamore_data' - ] - - # Vask list-felter - for field in json_list_fields: - if field in d: - val = d[field] - if val is None: - d[field] = [] - elif isinstance(val, str): - try: - d[field] = json.loads(val) - except: - d[field] = [] - elif not isinstance(val, list): - d[field] = [] - - # Vask objekt-felter - for field in json_dict_fields: - if field in d: - val = d[field] - if val is None: - d[field] = {} - elif isinstance(val, str): - try: - d[field] = json.loads(val) - except: - d[field] = {} - elif not isinstance(val, dict): - d[field] = {} - - return d - -@asynccontextmanager -async def lifespan(app: FastAPI): - # Opprett database-pool ved start - try: - print(f"📡 Forsøker å koble til database på: {DB_URL}") - app.state.pool = await asyncpg.create_pool( - DB_URL, - min_size=5, - max_size=20, - command_timeout=60 - ) - print("✅ Database tilkoblet og pool opprettet") - except Exception as e: - print(f"❌ Databasefeil under oppstart: {e}") - raise e - yield - # Lukk pool ved avslutning - await app.state.pool.close() - -app = FastAPI(title="TeeOff API v3.6.8", lifespan=lifespan) - -# CORS - Tillater både lokal utvikling og produksjonsdomene -app.add_middleware( - CORSMiddleware, - allow_origins=[ - "https://nye.teeoff.no", - "http://nye.teeoff.no", - "http://localhost:3000" - ], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# --- AUTH ENDPOINTS --- - -@app.post("/api/auth/login") -async def login(data: dict): - """Steg 1: Sjekk passord og returner temp_token for 2FA.""" - print(f"🔐 Loggin-forsøk for: {data.get('username')}") - - async with app.state.pool.acquire() as conn: - admin = await conn.fetchrow( - "SELECT * FROM admins WHERE username = $1 OR email = $1", - data.get('username') - ) - - if not admin: - print(" - ❌ Bruker ikke funnet i databasen") - raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord") - - h = admin['password_hash'] - print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)") - - # FIKS: Vi pakker KUN selve verify-sjekken inn i try/except - try: - is_valid = pwd_context.verify(data.get('password'), h) - except Exception as e: - print(f" - 🔥 FEIL VED LESING AV HASH: {e}") - raise HTTPException(status_code=500, detail="Internt problem med passord-format") - - # FIKS: 401 kastes nå UTENFOR try-blokken, slik at vi unngår 500-krasj - if not is_valid: - print(" - ❌ Passordet samsvarer ikke med hashen") - raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord") - - temp_token = jwt.encode( - {"sub": admin['username'], "partial": True, "exp": datetime.utcnow() + timedelta(minutes=5)}, - SECRET_KEY, algorithm=ALGORITHM - ) - print(" - ✅ Steg 1 fullført. Temp-token generert.") - return {"step": "2fa", "temp_token": temp_token} - -@app.post("/api/auth/verify-2fa") -async def verify_2fa(data: dict, response: Response): - """Steg 2: Verifiser TOTP-kode og sett session cookie.""" - try: - payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM]) - if not payload.get("partial"): - raise JWTError() - username = payload.get("sub") - except JWTError: - raise HTTPException(status_code=401, detail="Sesjonen har utløpt eller er ugyldig") - - async with app.state.pool.acquire() as conn: - admin = await conn.fetchrow("SELECT otp_secret FROM admins WHERE username = $1", username) - - totp = pyotp.TOTP(admin['otp_secret']) - if not totp.verify(data.get('code')): - print(f" - ❌ Feil 2FA-kode oppgitt for {username}") - raise HTTPException(status_code=401, detail="Feil 2FA-kode") - - final_token = jwt.encode( - {"sub": username, "exp": datetime.utcnow() + timedelta(hours=12)}, - SECRET_KEY, algorithm=ALGORITHM - ) - - # Sett som HTTP-only cookie - response.set_cookie( - key="admin_session", - value=final_token, - httponly=True, - samesite="lax", - secure=False # Sett til True i produksjon (HTTPS) - ) - return {"status": "success"} - -# --- DATA ENDPOINTS --- - -@app.get("/api/facilities") -async def get_facilities(): - """Henter alle golfanlegg med aggregert banestatus for forsiden.""" - async with app.state.pool.acquire() as conn: - rows = await conn.fetch(""" - SELECT f.*, ( - SELECT jsonb_agg(cs) FROM ( - SELECT name, status FROM courses - WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to' - ORDER BY is_main_course DESC, id ASC - ) cs - ) as course_statuses - FROM facilities f - ORDER BY f.name ASC - """) - return [format_row(row) for row in rows] - -@app.get("/api/facilities/{slug}") -async def get_facility(slug: str): - """Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull.""" - async with app.state.pool.acquire() as conn: - row = await conn.fetchrow(""" - SELECT f.*, ( - SELECT jsonb_agg(c_data) FROM ( - SELECT c.*, ( - SELECT jsonb_agg(h_data ORDER BY h_data.hole_number ASC) - FROM (SELECT * FROM holes WHERE course_id = c.id) h_data - ) as holes - FROM courses c - WHERE c.facility_id = f.id - AND (c.is_main_course = true OR (c.status NOT IN ('finnes_ingen_bane_to', 'ukjent'))) - ORDER BY c.is_main_course DESC, c.id ASC - ) c_data - ) as courses - FROM facilities f WHERE f.slug = $1 - """, slug) - - if not row: - raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet") - - return format_row(row) - -@app.get("/api/health") -async def health_check(): - """Enkel sjekk for å se at API og DB lever.""" - try: - async with app.state.pool.acquire() as conn: - await conn.execute("SELECT 1") - return {"status": "healthy", "database": "connected"} - except Exception as e: - return {"status": "unhealthy", "error": str(e)} - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/kode_eksport_1/backend_scrape_golfamore1_3_py.txt b/kode_eksport_1/backend_scrape_golfamore1_3_py.txt deleted file mode 100644 index fd418e0..0000000 --- a/kode_eksport_1/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_1/backend_scrape_nsg_3_py.txt b/kode_eksport_1/backend_scrape_nsg_3_py.txt deleted file mode 100644 index 190b12d..0000000 --- a/kode_eksport_1/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_1/backend_scrape_status_py.txt b/kode_eksport_1/backend_scrape_status_py.txt deleted file mode 100644 index 0a18990..0000000 --- a/kode_eksport_1/backend_scrape_status_py.txt +++ /dev/null @@ -1,174 +0,0 @@ -import asyncio -import os -import asyncpg -import smtplib -import re -from datetime import datetime -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart -from playwright.async_api import async_playwright -try: - from playwright_stealth import stealth_async as apply_stealth -except ImportError: - from playwright_stealth import stealth as apply_stealth - -from dotenv import load_dotenv - -load_dotenv() - -DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff" - -def clean_text(text): - return re.sub(r'[^a-zA-Z0-9æøåÆØÅ]', '', text).lower() - -def interpret_status(text, keyword=None): - t_raw = text.lower() - - if keyword: - k_clean = clean_text(keyword) - if k_clean not in clean_text(t_raw): - return "NOT_FOUND" - - parts = re.split(re.escape(keyword), t_raw, flags=re.IGNORECASE) - if len(parts) > 1: - t_raw = parts[1][:150] - else: - t_raw = t_raw[-200:] - - if any(word in t_raw for word in ["stengt", "lukket", "frost", "snø", "is", "closed", "stenger"]): - return "stengt" - if any(word in t_raw for word in ["vintergreen", "vintergrønn", "vinter"]): - return "aapen_med_vintergreener" - if any(word in t_raw for word in ["snart", "åpner kl"]): - return "aapner_snart" - if any(word in t_raw for word in ["åpen", "åpent", "aapen", "open"]): - return "aapen" - return "ukjent" - -def send_report(changes, warnings, successes): - if not changes and not warnings and not successes: return - subject = f"TeeOff Banestatus Rapport - {datetime.now().strftime('%d.%m.%Y')}" - body = "BANESTATUS RAPPORT\n" + "="*30 + "\n\n" - - if changes: body += "✅ OPPDATERINGER:\n" + "\n".join(changes) + "\n\n" - if warnings: body += "⚠️ MERKNADER / ADVARSLER:\n" + "\n".join(warnings) + "\n\n" - if successes: body += "🆗 VELLYKKEDE SJEKKER (INGEN ENDRING):\n" + "\n".join(successes) + "\n" - - msg = MIMEMultipart() - msg['From'] = os.getenv("SMTP_USER") - msg['To'] = os.getenv("EMAIL_TO") - msg['Subject'] = subject - msg.attach(MIMEText(body, 'plain')) - try: - with smtplib.SMTP_SSL(os.getenv("SMTP_SERVER"), int(os.getenv("SMTP_PORT"))) as server: - server.login(os.getenv("SMTP_USER"), os.getenv("SMTP_PASS")) - server.send_message(msg) - print("✅ Rapport sendt på e-post.") - except Exception as e: - print(f"❌ E-post feil: {e}") - -async def run_daily_scraping(): - print(f"🚀 Starter sjekk {datetime.now().strftime('%H:%M:%S')}...") - conn = await asyncpg.connect(DB_URL) - facilities = await conn.fetch("SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method FROM facilities WHERE scrape_status_url IS NOT NULL") - - changes, warnings, successes = [], [], [] - - async with async_playwright() as p: - browser = await p.chromium.launch(headless=True) - context = await browser.new_context() - - for f in facilities: - page = await context.new_page() - try: await apply_stealth(page) - except: pass - - try: - print(f"🔍 Besøker {f['name']}...") - # Endret fra networkidle til domcontentloaded for å unngå Arendal-timeout - await page.goto(f['scrape_status_url'], timeout=60000, wait_until="domcontentloaded") - await asyncio.sleep(3) # Gir Javascript 3 sekunder på å bygge siden - - full_text = "" - method = f.get('scrape_method') or 'css_selector' - - if method == 'css_selector': - element = page.locator(f['scrape_status_selector']).first - if await element.count() == 0: - warnings.append(f"❌ {f['name']}: Fant ikke CSS-elementet '{f['scrape_status_selector']}'") - continue - full_text = await element.inner_text() - - elif method == 'iframe_golfbox': - frame = page.frame_locator('iframe[src*="golfbox"]') - element = frame.locator(f['scrape_status_selector']).first - if await element.count() == 0: - warnings.append(f"❌ {f['name']}: Fant ikke elementet '{f['scrape_status_selector']}' i iframen") - continue - full_text = await element.inner_text() - - elif method == 'click_then_css': - # Vi forventer formatet: "knappe_selector||tekst_selector" - parts = f['scrape_status_selector'].split('||') - if len(parts) != 2: - warnings.append(f"❌ {f['name']}: Ugyldig selector for click_then_css (mangler ||)") - continue - - btn_selector, text_selector = parts - - # 1. Finn og klikk på knappen - btn = page.locator(btn_selector).first - if await btn.count() == 0: - warnings.append(f"❌ {f['name']}: Fant ikke knappen å klikke på: '{btn_selector}'") - continue - - await btn.click() - - # 2. Vent 2 sekunder så animasjonen (sidepanelet) rekker å bli ferdig - await asyncio.sleep(2) - - # 3. Les av teksten - element = page.locator(text_selector).first - if await element.count() == 0: - warnings.append(f"❌ {f['name']}: Fant ikke tekstboksen '{text_selector}' etter klikk") - continue - - full_text = await element.inner_text() - - else: - warnings.append(f"⚠️ {f['name']}: Ukjent skrapemetode i databasen: '{method}'") - continue - - await conn.execute("UPDATE facilities SET status_updated_at = CURRENT_DATE WHERE id = $1", f['id']) - - courses = await conn.fetch("SELECT id, name, status, scrape_keyword FROM courses WHERE facility_id = $1", f['id']) - for c in courses: - new_status = interpret_status(full_text, c['scrape_keyword']) - - if new_status == "NOT_FOUND": - warnings.append(f"❓ {f['name']} ({c['name']}): Fant ikke søkeordet '{c['scrape_keyword']}' i teksten på siden.") - continue - - old_status = c['status'] or "ukjent" - if new_status != old_status and new_status != "ukjent": - await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", new_status, c['id']) - changes.append(f"🔹 {f['name']} ({c['name']}): {old_status.upper()} ➔ {new_status.upper()}") - print(f"✅ Oppdatert status for {f['name']} - {c['name']}") - else: - successes.append(f"✅ {f['name']} ({c['name']}): {new_status.upper()}") - print(f" - {c['name']}: Ingen endring ({new_status.upper()})") - - except Exception as e: - # Trekker ut kun første linje av feilmeldingen for å unngå massiv og stygg tekst i e-posten - err_msg = str(e).split('\n')[0] - warnings.append(f"🔥 {f['name']}: Feil under skraping: {err_msg}") - finally: - await page.close() - await browser.close() - - await conn.close() - send_report(changes, warnings, successes) - print("🏁 Ferdig.") - -if __name__ == "__main__": - asyncio.run(run_daily_scraping()) \ 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 deleted file mode 100644 index 745a80e..0000000 --- a/kode_eksport_1/backend_sync_greenfee_py.txt +++ /dev/null @@ -1,79 +0,0 @@ -import asyncio, asyncpg, urllib.request, json - -DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff" -# Vi fjerner acf_format=standard da rå-feltnavnene er tryggere her -WP_API_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100" - -def decode_html(text): - if not text: return "" - return str(text).replace('&', '&').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/backend_update_admin_py.txt b/kode_eksport_1/backend_update_admin_py.txt deleted file mode 100644 index d896eb0..0000000 --- a/kode_eksport_1/backend_update_admin_py.txt +++ /dev/null @@ -1,52 +0,0 @@ -""" -TEE OFF ADMIN PASSWORD UPDATER ---------------------------------------------------------------------------- -FUNKSJON: Genererer SQL-kommando for å KUN oppdatere passord for en eksisterende admin. -STATUS: Påvirker IKKE tofaktor (2FA), e-post eller andre brukere. ---------------------------------------------------------------------------- -""" -from passlib.hash import pbkdf2_sha256 -import getpass -import sys - -def update_admin_password(): - print("\n" + "="*50) - print(" TEE OFF ADMIN PASSORD-OPPDATERER") - print("="*50) - - username = input("Brukernavn på admin som skal oppdateres: ").strip() - safe_username = username.replace("'", "''") - - # Passord-verifisering - while True: - password = getpass.getpass("Skriv inn NYTT passord: ") - password_confirm = getpass.getpass("Gjenta NYTT passord: ") - - if password == password_confirm: - if len(password) < 8: - print("⚠️ Advarsel: Passordet bør være minst 8 tegn.") - print(f"\n[DEBUG] Passord akseptert.") - break - else: - print("❌ Passordene er ikke like. Prøv igjen.\n") - - print("⏳ Genererer PBKDF2-hash...") - password_hash = pbkdf2_sha256.hash(password) - - print("\n✅ GENERERING VELLYKKET!") - print("-" * 50) - print("SLIK OPPDATERER DU PASSORDET TRYGT:") - print("-" * 50) - print("1. Gå inn i databasen:") - print(" docker exec -it teeoff_db psql -U teeoff_admin -d teeoff") - print("\n2. Lim inn denne linjen nøyaktig slik den står:") - print(f"UPDATE admins SET password_hash = '{password_hash}' WHERE username = '{safe_username}';") - print("\n3. Skriv 'exit' for å gå ut.") - print("-" * 50 + "\n") - -if __name__ == "__main__": - try: - update_admin_password() - except KeyboardInterrupt: - print("\nAvbrutt.") - sys.exit(0) \ No newline at end of file diff --git a/kode_eksport_1/eksport_script_py.txt b/kode_eksport_1/eksport_script_py.txt deleted file mode 100644 index 288a706..0000000 --- a/kode_eksport_1/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_1/" -TRE_FIL = "/opt/teeoff/fil-tre.txt" - -# Filtyper vi vil kopiere -FILTYPER = ['.py', '.ts', '.tsx'] - -# Mapper vi IKKE vil ha med i treet eller skanne (sparer tid og rot) -IGNORER_MAPPER = ['.git', 'node_modules', '__pycache__', 'kode_eksport', '.next'] - -def generer_tre_og_kopier(): - kilde_sti = Path(KILDE_MAPPE) - eksport_sti = Path(EKSPORT_MAPPE) - - # 1. Opprett eksportmappen hvis den ikke finnes - eksport_sti.mkdir(parents=True, exist_ok=True) - - tre_linjer = [] - kopierte_filer = 0 - - print("Skanner filer og genererer tre...") - - # 2. Gå gjennom alle mapper og filer - for root, dirs, files in os.walk(kilde_sti): - # Fjern ignorerte mapper så vi ikke går inn i dem - dirs[:] = [d for d in dirs if d not in IGNORER_MAPPER] - - # Regn ut innrykk basert på hvor dypt vi er i mappestrukturen - nivaa = root.replace(KILDE_MAPPE, '').count(os.sep) - innrykk = ' ' * 4 * nivaa - mappe_navn = os.path.basename(root) - - # Legg til mappen i treet - if mappe_navn: - tre_linjer.append(f"{innrykk}📁 {mappe_navn}/") - else: - tre_linjer.append(f"📁 {kilde_sti.name}/") - - sub_innrykk = ' ' * 4 * (nivaa + 1) - - # 3. Gå gjennom filene i mappen - for fil in files: - tre_linjer.append(f"{sub_innrykk}📄 {fil}") - - fil_sti = Path(root) / fil - - # 4. Sjekk om filen har riktig endelse og skal kopieres - if fil_sti.suffix in FILTYPER: - # Lag et unikt filnavn for å unngå overskriving - relativ_sti = fil_sti.relative_to(kilde_sti) - nytt_navn = str(relativ_sti).replace(os.sep, '_').replace('.', '_') + '.txt' - ny_sti = eksport_sti / nytt_navn - - # Kopier filen - shutil.copy2(fil_sti, ny_sti) - kopierte_filer += 1 - - # 5. Lagre filteret til tekstfilen - with open(TRE_FIL, 'w', encoding='utf-8') as f: - f.write('\n'.join(tre_linjer)) - - print(f"\n✅ Ferdig!") - print(f"📁 Filtre er lagret i: {TRE_FIL}") - print(f"📝 Kopierte {kopierte_filer} kodefiler til: {EKSPORT_MAPPE}") - -if __name__ == "__main__": - generer_tre_og_kopier() diff --git a/kode_eksport_1/frontend_next-env_d_ts.txt b/kode_eksport_1/frontend_next-env_d_ts.txt deleted file mode 100644 index c4b7818..0000000 --- a/kode_eksport_1/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_1/frontend_next_config_ts.txt b/kode_eksport_1/frontend_next_config_ts.txt deleted file mode 100644 index e9ffa30..0000000 --- a/kode_eksport_1/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_1/frontend_src_app_FacilitySearch_tsx.txt b/kode_eksport_1/frontend_src_app_FacilitySearch_tsx.txt deleted file mode 100644 index 1904530..0000000 --- a/kode_eksport_1/frontend_src_app_FacilitySearch_tsx.txt +++ /dev/null @@ -1,151 +0,0 @@ -"use client"; -/** - * TEE OFF SYSTEM INSTRUCTIONS - FACILITY CARDS v3.4 (STABLE) - * --------------------------------------------------------------------------- - * REGEL 1: Status-badge SKAL vises øverst til venstre. Bruk STATUS_MAP for tekst. - * REGEL 2: DATA-PARSING: Bruk parseJson() for 'course_statuses', 'amenities' og 'nsg_data' - * fordi API-et ofte returnerer disse som strenger. - * REGEL 3: Avstand-pillen skal ha fargen #2d3319 (Mørk oliven) med hvit tekst. - * REGEL 4: NSG (Blå 'N') og Golfamore (Oransje 'G') sirkler skal ha hvit kant (border-2). - * REGEL 5: Bunnen: Antall Hull (grønn pill), Banetype (grå pill), og Ikon-sirkler. - * REGEL 6: LOSBY-LOGIKK: Sjekk alle baner i arrayen. Hvis én er åpen, vis 'aapen'. - * --------------------------------------------------------------------------- - */ - -import { STATUS_MAP } from "@/config/constants"; -import { useState, useEffect, useMemo } from 'react'; -import Link from 'next/link'; - -function getDistance(lat1: number, lon1: number, lat2: number, lon2: number) { - try { - const R = 6371; - const dLat = (lat2 - lat1) * Math.PI / 180; - const dLon = (lon2 - lon1) * Math.PI / 180; - const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon/2) * Math.sin(dLon/2); - return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - } catch (e) { return Infinity; } -} - -export default function FacilitySearch({ initialFacilities }: { initialFacilities: any[] }) { - const [searchQuery, setSearchQuery] = useState(""); - const [userLocation, setUserLocation] = useState<{ lat: number, lng: number } | null>(null); - const [sortMethod, setSortMethod] = useState<'dist' | 'alpha'>('alpha'); - - useEffect(() => { - if ("geolocation" in navigator) { - navigator.geolocation.getCurrentPosition(p => { - setUserLocation({ lat: p.coords.latitude, lng: p.coords.longitude }); - setSortMethod('dist'); - }); - } - }, []); - - const processed = useMemo(() => { - if (!Array.isArray(initialFacilities)) return []; - - return initialFacilities.map(f => { - // --- ROBUST DATA-PARSING (Håndterer tekst vs objekt fra API) --- - const parseJson = (val: any, fallback: any) => { - if (!val) return fallback; - if (typeof val === 'object') return val; - try { return JSON.parse(val); } catch (e) { return fallback; } - }; - - const statuses = parseJson(f.course_statuses, []); - const amenities = parseJson(f.amenities, {}); - const nsgData = parseJson(f.nsg_data, {}); - - const dist = userLocation && f.lat && f.lng ? getDistance(userLocation.lat, userLocation.lng, f.lat, f.lng) : Infinity; - const hasNSG = nsgData && Object.keys(nsgData).length > 0; - const hasGolfamore = f.golfamore === true; - - const words = searchQuery.toLowerCase().trim().split(/\s+/).filter(w => w.length > 0); - const blob = `${f.name} ${f.city} ${f.county}`.toLowerCase(); - const matches = words.every(w => blob.includes(w)); - - return { ...f, statuses, amenities, dist, hasNSG, hasGolfamore, matches }; - }) - .filter(f => f.matches) - .sort((a, b) => { - if (sortMethod === 'dist' && a.dist !== b.dist) return a.dist - b.dist; - return a.name.localeCompare(b.name, 'nb'); - }); - }, [searchQuery, initialFacilities, userLocation, sortMethod]); - - return ( -
-
- -
- - setSearchQuery(e.target.value)} /> - -
- {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.name}

-

{f.city} • {f.county}

- -
-
- {/* Hull-pille */} - - {f.amenities?.antall_hull || '--'} HULL - - {/* Banetype-pille */} - - {f.banetype || 'SKOGSBANE'} - -
- - {/* Sirkel-ikoner (NSG / Golfamore) */} -
- {f.hasNSG && ( -
N
- )} - {f.hasGolfamore && ( -
G
- )} -
-
-
- - ); - })} -
-
- ); -} \ No newline at end of file diff --git a/kode_eksport_1/frontend_src_app_HeroSlider_tsx.txt b/kode_eksport_1/frontend_src_app_HeroSlider_tsx.txt deleted file mode 100644 index 515ce80..0000000 --- a/kode_eksport_1/frontend_src_app_HeroSlider_tsx.txt +++ /dev/null @@ -1,130 +0,0 @@ -"use client"; -/** - * TEE OFF SYSTEM INSTRUCTIONS - HERO SLIDER v2.4 - * --------------------------------------------------------------------------- - * REGEL 1: Kun baner med status 'aapen', 'aapner_snart', 'stenger_snart' - * eller 'aapen_med_vintergreener' skal prioriteres. - * REGEL 2: Baner med status 'nedlagt' eller 'under_utvikling' skal ALDRI vises. - * REGEL 3: Baner med generiske bilder (inneholder 'standard') skal ALDRI vises. - * REGEL 4: MANUELL EKSKLUDERING: Slugs i MANUAL_EXCLUSION_LIST skal aldri vises. - * REGEL 5: Slideren skal vise nøyaktig 5 baner. - * REGEL 6: Maks høyde er låst til 624px. Ingen badges. - * REGEL 7: Typografi: Nedjustert fontstørrelse (4xl mobil / 7xl desktop) for eleganse. - * REGEL 8: Utvalget skal være stabilt i én time (Hourly Seed) før det refreshes. - * --------------------------------------------------------------------------- - */ - -import { useState, useEffect, useMemo } from 'react'; -import Link from 'next/link'; - -const MANUAL_EXCLUSION_LIST = [ - 'alsten-golfklubb', 'askim-golfklubb', 'bergen-golfklubb', 'eidskog-golfklubb', - 'eiker-golfklubb', 'floro-golfklubb', 'garder-golfklubb', 'hafjell-golfklubb', - 'halden-golfklubb', 'haugesund-golfklubb', 'hinnoy-golfklubb', 'hitra-golfklubb', - 'hurum-golfklubb', 'imjelt-pitch-putt', 'karmoy-golfklubb', 'kristiansund-og-omegn-golfklubb', - 'lommedalen-golfklubb', 'laerdal-golfklubb', 'moa-golfsenter', 'modum-golfklubb', - 'nes-golfklubb-09', 'nittedal-golfklubb', 'selbu-golfklubb', 'stryn-golfklubb', - 'sunnfjord-golfklubb', 'tysnes-golfklubb', 'vanylven-golfklubb', 'vesteralen-golfklubb', - 'vestlia-golf' -]; - -export default function HeroSlider({ facilities }: { facilities: any[] }) { - const [currentIndex, setCurrentSlide] = useState(0); - - const sliderItems = useMemo(() => { - if (!Array.isArray(facilities) || facilities.length === 0) return []; - - const preferredStatuses = ['aapen', 'aapner_snart', 'stenger_snart', 'aapen_med_vintergreener']; - const forbiddenStatuses = ['nedlagt', 'under_utvikling']; - - const validCandidates = facilities.filter(f => { - if (MANUAL_EXCLUSION_LIST.includes(f.slug)) return false; - const img = f.image_url || ""; - if (!img || img.toLowerCase().includes('standard') || img.length < 5) return false; - - const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : []; - const isForbidden = statuses.some((s: any) => - forbiddenStatuses.includes((s.status || "").toLowerCase()) - ); - return !isForbidden; - }); - - const highPriority = validCandidates.filter(f => { - const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : []; - return statuses.some((s: any) => preferredStatuses.includes((s.status || "").toLowerCase())); - }); - - const fallbackPool = validCandidates.filter(f => !highPriority.includes(f)); - const now = new Date(); - const hourlySeed = parseInt(`${now.getFullYear()}${now.getMonth()}${now.getDate()}${now.getHours()}`); - - const seededShuffle = (arr: any[]) => { - return [...arr].sort((a, b) => ((a.id * hourlySeed) % 100) - ((b.id * hourlySeed) % 100)); - }; - - let selection = seededShuffle(highPriority); - if (selection.length < 5) { - selection = [...selection, ...seededShuffle(fallbackPool)].slice(0, 5); - } else { - selection = selection.slice(0, 5); - } - return selection; - }, [facilities]); - - useEffect(() => { - if (sliderItems.length <= 1) return; - const interval = setInterval(() => setCurrentSlide((p) => (p + 1) % sliderItems.length), 8000); - return () => clearInterval(interval); - }, [sliderItems.length]); - - if (sliderItems.length === 0) return null; - - return ( -
- {sliderItems.map((f, i) => ( -
- -
- - {f.name} - -
-
-
- {/* 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_admin_login_page_tsx.txt b/kode_eksport_1/frontend_src_app_admin_login_page_tsx.txt deleted file mode 100644 index 9f19401..0000000 --- a/kode_eksport_1/frontend_src_app_admin_login_page_tsx.txt +++ /dev/null @@ -1,103 +0,0 @@ -"use client"; -/** - * TEE OFF ADMIN LOGIN v1.2 - * --------------------------------------------------------------------------- - * PLASSERING: frontend/src/app/admin/login/page.tsx - * FUNKSJON: Offentlig tilgjengelig innlogging for administratorer. - * --------------------------------------------------------------------------- - */ - -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { API_URL } from "@/config/constants"; - -export default function AdminLogin() { - const [step, setStep] = useState(1); - const [formData, setFormData] = useState({ username: '', password: '', code: '' }); - const [tempToken, setTempToken] = useState(''); - const [error, setError] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const router = useRouter(); - - const handleLogin = async (e: React.FormEvent) => { - e.preventDefault(); - setIsLoading(true); - setError(''); - - try { - const res = await fetch(`${API_URL}/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username: formData.username, password: formData.password }) - }); - - const data = await res.json(); - if (res.ok) { - setTempToken(data.temp_token); - setStep(2); - } else { - setError(data.detail || 'Ugyldig pålogging'); - } - } catch (err) { - console.error("🔥 DEN EKTE FEILEN ER:", err); - setError('Systemfeil: Kunne ikke koble til API-et'); - } finally { - setIsLoading(false); - } - }; - - const handleVerify2FA = async (e: React.FormEvent) => { - e.preventDefault(); - setIsLoading(true); - - try { - const res = await fetch(`${API_URL}/auth/verify-2fa`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ temp_token: tempToken, code: formData.code }) - }); - - if (res.ok) { - // VIKTIG: Etter suksess sender vi brukeren til selve dashbordet - router.push('/admin'); - router.refresh(); - } else { - setError('Ugyldig 2FA-kode'); - } - } catch (err) { - setError('Tilkoblingsfeil ved 2FA-verifisering'); - } finally { - setIsLoading(false); - } - }; - - return ( -
-
-
- TeeOff -
-

- {step === 1 ? "Admin Portalen" : "Tofaktor Sjekk"} -

-
- {step === 1 ? ( - <> - setFormData({...formData, username: e.target.value})} required /> - setFormData({...formData, password: e.target.value})} required /> - - ) : ( -
-

Tast inn 6 siffer fra appen din

- setFormData({...formData, code: e.target.value})} autoFocus required /> -
- )} - {error &&
⚠️ {error}
} - -
-
-
- ); -} \ No newline at end of file diff --git a/kode_eksport_1/frontend_src_app_admin_page_tsx.txt b/kode_eksport_1/frontend_src_app_admin_page_tsx.txt deleted file mode 100644 index 0b49133..0000000 --- a/kode_eksport_1/frontend_src_app_admin_page_tsx.txt +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; -/** - * TEE OFF ADMIN DASHBOARD v1.1 - * --------------------------------------------------------------------------- - * PLASSERING: frontend/src/app/admin/page.tsx - * FUNKSJON: Monitorering av banestatus og administrasjon. - * --------------------------------------------------------------------------- - */ - -import { useState, useEffect } from 'react'; -import { API_URL } from "@/config/constants"; - -export default function AdminDashboard() { - const [facilities, setFacilities] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - fetch(`${API_URL}/facilities`) - .then(res => res.json()) - .then(data => { - setFacilities(Array.isArray(data) ? data : []); - setLoading(false); - }) - .catch(() => setLoading(false)); - }, []); - - if (loading) return
LASTER DASHBORD...
; - - return ( -
- {/* SIDEBAR (22%) */} - - - {/* HOVEDINNHOLD (78%) */} -
-
-
-
-

Scraping Monitor

-

Sjekker status på {facilities.length} anlegg

-
- -
- -
- - - - - - - - - - - {facilities.map((f: any) => ( - - - - - - - ))} - -
AnleggKonfigurasjonSiste SjekkStatus
-
{f.name}
-
{f.city}
-
-
{f.scrape_status_url}
-
{f.scrape_status_selector}
-
- {f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'} - - -
-
-
-
-
- ); -} \ No newline at end of file diff --git a/kode_eksport_1/frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt b/kode_eksport_1/frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt deleted file mode 100644 index 8e1423d..0000000 --- a/kode_eksport_1/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_1/frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt b/kode_eksport_1/frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt deleted file mode 100644 index 880e1d4..0000000 --- a/kode_eksport_1/frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt +++ /dev/null @@ -1,353 +0,0 @@ -"use client"; -/** - * TEE OFF DETAIL VIEW - COMPLETE v3.20 - * --------------------------------------------------------------------------- - * FIX: Gjenopprettet "Turneringer" i den flytende knapperaden over bildet. - * FIX: Byttet plass på tekst og sidebar (Tekst øverst på mobil). - * FIX: Økt padding (pb-32) i Hero-teksten på mobil for å unngå krasj med knapper. - * FIX: Alle 4 kontaktpunkter i sidebar er klikkbare (tel:0047 fix inkludert). - * REGEL: Beholder monokrome ikoner, 22/78 layout og robust JSON-parsing. - * --------------------------------------------------------------------------- - */ - -import { useState, useEffect } from 'react'; -import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants"; -import Link from 'next/link'; -import CourseDisplay from './CourseDisplay'; - -const formatPhoneForUrl = (phone: string) => { - if (!phone) return ""; - return phone.replace('+', '00').replace(/\s/g, ''); -}; - -const renderValue = (val: string) => { - if (!val) return "Nei"; - const hasLink = val.includes(' - ); -}; - -const Icon = ({ children, className = "w-5 h-5" }: { children: React.ReactNode, className?: string }) => ( - - {children} - -); - -const ICONS = { - web: <>, - phone: , - mail: <>, - 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 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; - const timer = setInterval(() => setCurrentSlide((p) => (p + 1) % gallery.length), 5000); - return () => clearInterval(timer); - }, [gallery.length]); - - useEffect(() => { - const handleScroll = () => setShowBackToTop(window.scrollY > 500); - window.addEventListener('scroll', handleScroll); - return () => window.removeEventListener('scroll', handleScroll); - }, []); - - const scrollTo = (id: string) => { - const el = document.getElementById(id); - if (el) window.scrollTo({ top: el.getBoundingClientRect().top + window.pageYOffset - 80, behavior: 'smooth' }); - }; - - const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('nb-NO', { day: 'numeric', month: 'long', year: 'numeric' }) : null; - const weatherImg = facility.weather_url?.replace("/graf/dag/", "/innhold/").replace(/\/$/, "") + "/meteogram.svg"; - - return ( -
- - {/* 1. HERO SLIDER */} -
- {gallery.map((img: string, i: number) => ( - - ))} -
- - {/* BANESTATUS BADGES */} -
-
- {activeCourses.map((c: any) => ( - - {c.name.toUpperCase()}: {STATUS_MAP[c.status] || c.status} - - ))} -
- {facility.status_updated_at && ( - - Sist oppdatert: {formatDate(facility.status_updated_at)} - - )} -
- - {/* FLYTENDE HURTIGKNAPPER (Inkludert Turneringer) */} -
- {facility.website_url && } - {facility.golfbox_booking_url && } - {facility.golfbox_tournament_url && } - - {facility.weather_url && } -
- - {/* HERO TEXT - pb-32 på mobil hindrer overlap med knapper */} -
- {facility.logo_url && ( -
Logo
- )} -

{facility.name}

-

{facility.county} • {facility.city}

-
-
- - {/* 2. STICKY NAV */} - - -
- - {/* 3. INTRO & SIDEBAR (PRESENTASJON FØRST FOR MOBIL) */} -
- {/* HOVEDINNHOLD (78%) - Kommer først på mobil pga koderrekkefølge */} -
- {facility.footnote &&
{facility.footnote}
} -
-
- - {/* SIDEBAR (22%) - Kommer etter tekst på mobil */} - -
- - {/* 4. 3-KOLONNE INFO */} -
-
-

Andre Ressurser

-
- {facility.golfbox_booking_url && ( - - Book Starttid - - )} - {facility.golfbox_tournament_url && ( - - Turneringer - - )} - {facility.baneguide_url && ( - - Baneguide - - )} - {facility.flyfoto_url && ( - - Flyfoto - - )} - {shotzoom.map((sz: any, i: number) => ( - - Statistikk: {sz.shotzoom_beskrivelse?.replace(/ ?/g, ' ').trim().toUpperCase()} - - ))} -
-
-
-

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}

-
- {facility.weather_url ? ( Vær ) :

Værvarsel ikke tilgjengelig

} -
-
- - {/* 6. KART SEKSJON */} -
-

Kart

-
-