Nå fungerer HeroSlider tålelig bra
|
|
@ -7,58 +7,34 @@ MEDIA_ENDPOINT = "https://teeoff.no/wp-json/wp/v2/media"
|
||||||
MEDIA_DIR = "./public/media"
|
MEDIA_DIR = "./public/media"
|
||||||
|
|
||||||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||||
|
|
||||||
# Cache for å slippe å spørre om samme bilde-ID flere ganger
|
|
||||||
media_cache = {}
|
media_cache = {}
|
||||||
|
|
||||||
def get_url_from_id(media_id):
|
def get_url_from_id(media_id):
|
||||||
"""Slår opp en WordPress Media-ID og returnerer den faktiske bilde-URLen"""
|
if not media_id or not isinstance(media_id, int): return None
|
||||||
if not media_id or not isinstance(media_id, int):
|
if media_id in media_cache: return media_cache[media_id]
|
||||||
return None
|
|
||||||
|
|
||||||
if media_id in media_cache:
|
|
||||||
return media_cache[media_id]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print(f" 🔍 Slår opp Media-ID: {media_id}...")
|
|
||||||
resp = requests.get(f"{MEDIA_ENDPOINT}/{media_id}", timeout=10)
|
resp = requests.get(f"{MEDIA_ENDPOINT}/{media_id}", timeout=10)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
url = resp.json().get('source_url')
|
url = resp.json().get('source_url')
|
||||||
media_cache[media_id] = url
|
media_cache[media_id] = url
|
||||||
return url
|
return url
|
||||||
except Exception as e:
|
except: return None
|
||||||
print(f" ⚠️ Kunne ikke finne URL for Media-ID {media_id}: {e}")
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def download_media(url, slug, prefix):
|
def download_media(url, slug, prefix):
|
||||||
if not isinstance(url, str) or not url:
|
if not isinstance(url, str) or not url: return None
|
||||||
return None
|
|
||||||
|
|
||||||
# Reparer "triple-slash" og andre WP-feil
|
|
||||||
clean_url = url.replace("https:///", "https://").replace("http:///", "http://")
|
clean_url = url.replace("https:///", "https://").replace("http:///", "http://")
|
||||||
|
if "teeoff.no" not in clean_url: return clean_url
|
||||||
if "teeoff.no" not in clean_url:
|
|
||||||
return clean_url
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ext = clean_url.split('.')[-1].split('?')[0].lower()
|
ext = clean_url.split('.')[-1].split('?')[0].lower()
|
||||||
if len(ext) > 4 or len(ext) < 3: ext = "jpg"
|
if len(ext) > 4 or len(ext) < 3: ext = "jpg"
|
||||||
|
|
||||||
filename = f"{prefix}_{slug}.{ext}"
|
filename = f"{prefix}_{slug}.{ext}"
|
||||||
filepath = os.path.join(MEDIA_DIR, filename)
|
filepath = os.path.join(MEDIA_DIR, filename)
|
||||||
|
if os.path.exists(filepath): return f"/media/{filename}"
|
||||||
if os.path.exists(filepath):
|
|
||||||
return f"/media/{filename}"
|
|
||||||
|
|
||||||
response = requests.get(clean_url, timeout=15)
|
response = requests.get(clean_url, timeout=15)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
with open(filepath, 'wb') as f:
|
with open(filepath, 'wb') as f: f.write(response.content)
|
||||||
f.write(response.content)
|
|
||||||
return f"/media/{filename}"
|
return f"/media/{filename}"
|
||||||
except Exception as e:
|
except: pass
|
||||||
print(f" ⚠️ Feil ved nedlasting: {e}")
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def decode_html(text):
|
def decode_html(text):
|
||||||
|
|
@ -78,17 +54,19 @@ def extract_url(val):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def run_master_import():
|
async def run_master_import():
|
||||||
print("🚀 Starter MASTER IMPORT v8.9.2 (Media ID Resolver)...")
|
print("🚀 Starter MASTER IMPORT v9.2 (Robust datakonvertering & Banetype)...")
|
||||||
conn = await asyncpg.connect(DB_URL)
|
conn = await asyncpg.connect(DB_URL)
|
||||||
await conn.execute("TRUNCATE facilities, courses, holes RESTART IDENTITY CASCADE;")
|
|
||||||
|
# Tømmer kun courses og holes (hjelpetabeller)
|
||||||
|
await conn.execute("TRUNCATE courses, holes RESTART IDENTITY CASCADE;")
|
||||||
|
|
||||||
page = 1
|
page = 1
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(f"{WP_API_URL}&page={page}", headers={'User-Agent': 'TeeOff-V8.9.2'})
|
req = urllib.request.Request(f"{WP_API_URL}&page={page}", headers={'User-Agent': 'TeeOff-V9.2'})
|
||||||
with urllib.request.urlopen(req) as response:
|
with urllib.request.urlopen(req) as response:
|
||||||
data = json.loads(response.read().decode())
|
data = json.loads(response.read().decode())
|
||||||
except Exception: break
|
except: break
|
||||||
if not data: break
|
if not data: break
|
||||||
|
|
||||||
for post in data:
|
for post in data:
|
||||||
|
|
@ -97,85 +75,65 @@ async def run_master_import():
|
||||||
name = decode_html(post.get('title', {}).get('rendered', ''))
|
name = decode_html(post.get('title', {}).get('rendered', ''))
|
||||||
print(f"📦 Mapper {name}...")
|
print(f"📦 Mapper {name}...")
|
||||||
|
|
||||||
# --- 1. HOVEDBILDE ---
|
# Media & Identifiers
|
||||||
featured_img = post.get('_embedded', {}).get('wp:featuredmedia', [{}])[0].get('source_url')
|
local_main_img = download_media(post.get('_embedded', {}).get('wp:featuredmedia', [{}])[0].get('source_url'), slug, "main")
|
||||||
local_main_img = download_media(featured_img, 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")
|
||||||
|
|
||||||
# --- 2. LOGO ---
|
# Galleri
|
||||||
logo_field = acf.get('logo')
|
|
||||||
logo_url = extract_url(logo_field)
|
|
||||||
if not logo_url and isinstance(logo_field, int):
|
|
||||||
logo_url = get_url_from_id(logo_field)
|
|
||||||
local_logo = download_media(logo_url, slug, "logo")
|
|
||||||
|
|
||||||
# --- 3. GALLERI (SLIDER) ---
|
|
||||||
slides = acf.get('slides') or []
|
slides = acf.get('slides') or []
|
||||||
local_gallery = []
|
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)]
|
||||||
if isinstance(slides, list):
|
local_gallery = [url for url in local_gallery if url]
|
||||||
for idx, s in enumerate(slides):
|
|
||||||
url = None
|
|
||||||
if isinstance(s, int): # DIN CASE: Vi har en ID
|
|
||||||
url = get_url_from_id(s)
|
|
||||||
elif isinstance(s, dict):
|
|
||||||
url = s.get('url')
|
|
||||||
elif isinstance(s, str):
|
|
||||||
url = s
|
|
||||||
|
|
||||||
if url:
|
# Golfbox
|
||||||
res = download_media(url, f"{slug}_{idx}", "slide")
|
|
||||||
if res: local_gallery.append(res)
|
|
||||||
|
|
||||||
# --- GOLFBOX & SOSIALE ---
|
|
||||||
booking_id = acf.get('golfbox_booking_id')
|
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
|
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
|
||||||
|
|
||||||
# --- INSERT FACILITY ---
|
# --- UPSERT FACILITY ---
|
||||||
|
# Merk: $16 (status_updated_at) pakkes nå inn i TO_DATE for å unngå krasj
|
||||||
await conn.execute('''
|
await conn.execute('''
|
||||||
INSERT INTO facilities (
|
INSERT INTO facilities (
|
||||||
name, slug, description, established_year, season, address, city, county,
|
name, slug, description, address, city, county, established_year, season,
|
||||||
lat, lng, email, phone, website_url, image_url, amenities, greenfee,
|
email, phone, website_url, image_url, logo_url, video_url,
|
||||||
status_updated_at, logo_url, video_url, guest_requirements,
|
amenities, status_updated_at, gallery, banetype,
|
||||||
faqs, shotzoom, gallery, ngf_number, golfbox_club_id, golfbox_booking_url,
|
ngf_number, golfbox_club_id, golfbox_booking_url,
|
||||||
facebook_url, instagram_url, baneguide_url, flyfoto_url, golfbox_tournament_url,
|
facebook_url, instagram_url, baneguide_url, flyfoto_url,
|
||||||
footnote, social_links, webcam_url, weather_url, architect,
|
golfbox_tournament_url, footnote, social_links, webcam_url,
|
||||||
|
weather_url, architect,
|
||||||
navn_standard_medlemskap, standard_medlemskap, standard_medlemskap_kommentarer,
|
navn_standard_medlemskap, standard_medlemskap, standard_medlemskap_kommentarer,
|
||||||
navn_rimeligste_alternativ, rimeligste_alternativ, rimeligste_alternativ_kommentarer,
|
navn_rimeligste_alternativ, rimeligste_alternativ, rimeligste_alternativ_kommentarer,
|
||||||
medlemskap_url
|
medlemskap_url
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb,
|
) VALUES (
|
||||||
$16::jsonb, TO_DATE(NULLIF($17, ''), 'YYYYMMDD'),
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb,
|
||||||
$18, $19, $20, $21::jsonb, $22::jsonb, $23::jsonb,
|
TO_DATE(NULLIF($16, ''), 'YYYYMMDD'),
|
||||||
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33::jsonb, $34, $35, $36,
|
$17::jsonb, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28::jsonb,
|
||||||
$37, $38, $39, $40, $41, $42, $43)
|
$29, $30, $31, $32, $33, $34, $35, $36, $37, $38
|
||||||
''',
|
)
|
||||||
name, slug, decode_html(acf.get('beskrivelse')), parse_int(acf.get('byggear')), acf.get('sesong'),
|
ON CONFLICT (slug) DO UPDATE SET
|
||||||
acf.get('gateadresse'), acf.get('postnummer_og_poststed'), acf.get('fylke'),
|
name = EXCLUDED.name,
|
||||||
float(acf.get('banekart', {}).get('lat', 0)) if acf.get('banekart') else None,
|
description = EXCLUDED.description,
|
||||||
float(acf.get('banekart', {}).get('lng', 0)) if acf.get('banekart') else None,
|
address = EXCLUDED.address,
|
||||||
acf.get('e-post'), acf.get('telefon'), extract_url(acf.get('hjemmeside')),
|
city = EXCLUDED.city,
|
||||||
local_main_img,
|
phone = EXCLUDED.phone,
|
||||||
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"))}),
|
email = EXCLUDED.email,
|
||||||
json.dumps(acf.get('greenfee_-_voksne') or []),
|
website_url = EXCLUDED.website_url,
|
||||||
acf.get('dato_for_oppdatert_status'), local_logo,
|
image_url = EXCLUDED.image_url,
|
||||||
None, decode_html(acf.get('krav_til_gjestespillere')),
|
logo_url = EXCLUDED.logo_url,
|
||||||
json.dumps([]), json.dumps(acf.get('shotzoom') or []), json.dumps(local_gallery),
|
amenities = EXCLUDED.amenities,
|
||||||
parse_int(acf.get('klubbnummer_norges_golfforbund')), parse_int(acf.get('klubbnummer_golfbox')),
|
gallery = EXCLUDED.gallery,
|
||||||
gb_booking_url, extract_url(acf.get('facebook_url')), extract_url(acf.get('instagram_url')),
|
status_updated_at = EXCLUDED.status_updated_at,
|
||||||
extract_url(acf.get('baneguide')), extract_url(acf.get('flyfoto')), extract_url(acf.get('golfbox')),
|
banetype = EXCLUDED.banetype,
|
||||||
decode_html(acf.get('fotnote')), json.dumps(acf.get('sosiale_lenker') or []),
|
architect = EXCLUDED.architect
|
||||||
decode_html(acf.get('webkamera')), extract_url(acf.get('varmelding_yr')), decode_html(acf.get('arkitekt')),
|
''', 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"))}),
|
||||||
decode_html(acf.get('navn_standard_medlemskap')), parse_int(acf.get('standard_medlemskap')),
|
acf.get('dato_for_oppdatert_status'), # $16
|
||||||
decode_html(acf.get('standard_medlemskap_kommentarer')), decode_html(acf.get('navn_rimeligste_alternativ')),
|
json.dumps(local_gallery), decode_html(acf.get('banetype')),
|
||||||
parse_int(acf.get('rimeligste_alternativ')), decode_html(acf.get('rimeligste_alternativ_kommentarer')),
|
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')))
|
||||||
extract_url(acf.get('medlemskap_url')))
|
|
||||||
|
|
||||||
# Hent facility id for baner
|
fac_id = (await conn.fetchrow("SELECT id FROM facilities WHERE slug = $1", slug))['id']
|
||||||
fac_row = await conn.fetchrow("SELECT id FROM facilities WHERE slug = $1", slug)
|
|
||||||
fac_id = fac_row['id']
|
|
||||||
|
|
||||||
# --- BANER OG HULL (Samme som før) ---
|
# Baner og Hull
|
||||||
fac_main_len = 0
|
fac_main_len = 0
|
||||||
for suffix in ['', '_bane_to']:
|
for suffix in ['', '_bane_to']:
|
||||||
c_name = acf.get('navn_pa_hovedbane' if suffix == '' else 'navn_pa_sekundar_bane') or ('Hovedbane' if suffix == '' else 'Bane 2')
|
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')
|
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
|
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')))
|
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')))
|
||||||
|
|
@ -190,9 +148,10 @@ async def run_master_import():
|
||||||
await conn.execute("UPDATE courses SET length_meters = $1 WHERE id = $2", curr_len, course_id)
|
await conn.execute("UPDATE courses SET length_meters = $1 WHERE id = $2", curr_len, course_id)
|
||||||
if suffix == '': fac_main_len = curr_len
|
if suffix == '': fac_main_len = curr_len
|
||||||
await conn.execute("UPDATE facilities SET length_meters = $1 WHERE id = $2", fac_main_len, fac_id)
|
await conn.execute("UPDATE facilities SET length_meters = $1 WHERE id = $2", fac_main_len, fac_id)
|
||||||
|
|
||||||
page += 1
|
page += 1
|
||||||
await conn.close()
|
await conn.close()
|
||||||
print("✅ GALLERI-BILDER RESOLVED OG IMPORT FERDIG!")
|
print("✅ IMPORT FERDIG!")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(run_master_import())
|
asyncio.run(run_master_import())
|
||||||
|
|
@ -9,27 +9,53 @@ DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
app.state.pool = await asyncpg.create_pool(DB_URL, min_size=5, max_size=20)
|
try:
|
||||||
|
app.state.pool = await asyncpg.create_pool(DB_URL, min_size=5, max_size=20)
|
||||||
|
print("✅ Database tilkoblet og pool opprettet")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Databasefeil: {e}")
|
||||||
|
raise e
|
||||||
yield
|
yield
|
||||||
await app.state.pool.close()
|
await app.state.pool.close()
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(title="TeeOff API v2.4", lifespan=lifespan)
|
||||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
def format_row(row):
|
def format_row(row):
|
||||||
if row is None: return None
|
if row is None: return None
|
||||||
d = dict(row)
|
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
|
# 1. Dato-håndtering
|
||||||
json_fields = ['amenities', 'greenfee', 'membership', 'vtg', 'faqs', 'shotzoom', 'translations', 'course_statuses', 'courses', 'gallery', 'nsg_data', 'golfamore_data']
|
for key in ['status_updated_at', 'created_at']:
|
||||||
for field in json_fields:
|
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 field in d:
|
||||||
if isinstance(d[field], str):
|
if d[field] is None:
|
||||||
|
d[field] = []
|
||||||
|
elif isinstance(d[field], str):
|
||||||
try: d[field] = json.loads(d[field])
|
try: d[field] = json.loads(d[field])
|
||||||
except: pass
|
except: d[field] = []
|
||||||
if d[field] is None: d[field] = [] if field in ['courses', 'faqs', 'gallery'] else {}
|
|
||||||
|
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
|
return d
|
||||||
|
|
||||||
@app.get("/api/facilities")
|
@app.get("/api/facilities")
|
||||||
|
|
@ -39,7 +65,7 @@ async def get_facilities():
|
||||||
SELECT f.*, (
|
SELECT f.*, (
|
||||||
SELECT jsonb_agg(cs) FROM (
|
SELECT jsonb_agg(cs) FROM (
|
||||||
SELECT name, status FROM courses
|
SELECT name, status FROM courses
|
||||||
WHERE facility_id = f.id AND status NOT IN ('finnes_ingen_bane_to', 'nedlagt')
|
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to'
|
||||||
ORDER BY is_main_course DESC, id ASC
|
ORDER BY is_main_course DESC, id ASC
|
||||||
) cs
|
) cs
|
||||||
) as course_statuses
|
) as course_statuses
|
||||||
|
|
@ -59,7 +85,7 @@ async def get_facility(slug: str):
|
||||||
) as holes
|
) as holes
|
||||||
FROM courses c
|
FROM courses c
|
||||||
WHERE c.facility_id = f.id
|
WHERE c.facility_id = f.id
|
||||||
AND (c.is_main_course = true OR (c.status NOT IN ('finnes_ingen_bane_to', 'nedlagt', 'ukjent')))
|
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
|
ORDER BY c.is_main_course DESC, c.id ASC
|
||||||
) c_data
|
) c_data
|
||||||
) as courses
|
) as courses
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ from pathlib import Path
|
||||||
|
|
||||||
# --- KONFIGURASJON ---
|
# --- KONFIGURASJON ---
|
||||||
KILDE_MAPPE = "/opt/teeoff/"
|
KILDE_MAPPE = "/opt/teeoff/"
|
||||||
EKSPORT_MAPPE = "/opt/teeoff/kode_eksport/"
|
EKSPORT_MAPPE = "/opt/teeoff/kode_eksport_1/"
|
||||||
TRE_FIL = "/opt/teeoff/filtre.txt"
|
TRE_FIL = "/opt/teeoff/fil-tre.txt"
|
||||||
|
|
||||||
# Filtyper vi vil kopiere
|
# Filtyper vi vil kopiere
|
||||||
FILTYPER = ['.py', '.ts', '.tsx']
|
FILTYPER = ['.py', '.ts', '.tsx']
|
||||||
|
|
|
||||||
705
fil-tre.txt
Normal file
|
|
@ -0,0 +1,705 @@
|
||||||
|
📁 teeoff/
|
||||||
|
📄 filtre.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
|
||||||
|
📄 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
|
||||||
|
📁 media/
|
||||||
|
📄 slide_naeroysund-golfklubb_5.jpg
|
||||||
|
📄 main_hakadal-golfklubb.jpg
|
||||||
|
📄 slide_baerum-golfklubb_1.jpg
|
||||||
|
📄 slide_egersund-golfklubb_7.jpg
|
||||||
|
📄 main_hammerfest-og-kvalsund-golfklubb.jpg
|
||||||
|
📄 main_odda-golfklubb.jpg
|
||||||
|
📄 slide_holtsmark-golfklubb_1.jpg
|
||||||
|
📄 main_voss-golfklubb.jpeg
|
||||||
|
📄 slide_soon-golfklubb_2.jpg
|
||||||
|
📄 logo_ogna-golfklubb.jpg
|
||||||
|
📄 slide_tyrifjord-golfklubb_4.jpg
|
||||||
|
📄 slide_tjome-golfklubb_2.jpg
|
||||||
|
📄 logo_garder-golfklubb.jpg
|
||||||
|
📄 slide_helgeland-golfklubb_1.jpg
|
||||||
|
📄 slide_kvinnherad-golfklubb_0.jpg
|
||||||
|
📄 main_rauma-golfklubb.jpg
|
||||||
|
📄 slide_naeroysund-golfklubb_0.jpg
|
||||||
|
📄 slide_hallingdal-golfklubb_1.jpeg
|
||||||
|
📄 slide_hakadal-golfklubb_13.jpg
|
||||||
|
📄 slide_sunndal-golfklubb_2.jpg
|
||||||
|
📄 logo_skjeberg-golfklubb.jpg
|
||||||
|
📄 slide_larvik-golfklubb_0.jpg
|
||||||
|
📄 logo_tingvoll-golfklubb.jpg
|
||||||
|
📄 slide_sandefjord-golfklubb_4.jpg
|
||||||
|
📄 logo_onsoy-golfklubb.jpg
|
||||||
|
📄 logo_oppdal-golfklubb.jpg
|
||||||
|
📄 main_nordhaug-golfklubb.jpg
|
||||||
|
📄 main_hof-golfklubb.jpg
|
||||||
|
📄 main_hauger-golfklubb.jpg
|
||||||
|
📄 slide_vradal-golfklubb_1.jpg
|
||||||
|
📄 slide_holtsmark-golfklubb_5.jpg
|
||||||
|
📄 main_baerum-golfklubb.jpg
|
||||||
|
📄 logo_huseby-hanko-golfklubb.png
|
||||||
|
📄 slide_solum-golfklubb_4.jpg
|
||||||
|
📄 logo_hardanger-golfklubb.png
|
||||||
|
📄 logo_kristiansund-og-omegn-golfklubb.jpg
|
||||||
|
📄 main_byneset-golf.jpg
|
||||||
|
📄 logo_laerdal-golfklubb.jpg
|
||||||
|
📄 slide_kjekstad-golfklubb_0.jpg
|
||||||
|
📄 main_namdal-golfklubb.jpg
|
||||||
|
📄 main_hallingdal-golfklubb.jpg
|
||||||
|
📄 logo_ibestad-golfklubb.jpg
|
||||||
|
📄 logo_alta-golfklubb.png
|
||||||
|
📄 slide_norsjo-golfklubb_4.jpg
|
||||||
|
📄 main_elverum-golfklubb.jpg
|
||||||
|
📄 logo_hammerfest-og-kvalsund-golfklubb.jpg
|
||||||
|
📄 main_garder-golfklubb.jpg
|
||||||
|
📄 logo_lommedalen-golfklubb.jpg
|
||||||
|
📄 slide_ski-golfklubb_1.jpg
|
||||||
|
📄 logo_gronmo-golfklubb.jpg
|
||||||
|
📄 logo_hvam-golfklubb.jpg
|
||||||
|
📄 main_nesbyen-golfklubb.jpg
|
||||||
|
📄 slide_naeroysund-golfklubb_2.jpg
|
||||||
|
📄 slide_tjome-golfklubb_0.jpg
|
||||||
|
📄 logo_miklagard-golfklubb.png
|
||||||
|
📄 main_nordfjord-golfklubb.jpg
|
||||||
|
📄 slide_bjaavann-golfklubb_1.jpg
|
||||||
|
📄 main_jaeren-golfklubb.jpg
|
||||||
|
📄 slide_salten-golfklubb-bodo-golfpark_1.jpg
|
||||||
|
📄 slide_larvik-golfklubb_1.jpg
|
||||||
|
📄 main_modum-golfklubb.jpg
|
||||||
|
📄 logo_volda-golfklubb.jpg
|
||||||
|
📄 main_grini-golfklubb.jpg
|
||||||
|
📄 slide_aurskog-golfpark_0.jpg
|
||||||
|
📄 main_miklagard-golfklubb.jpg
|
||||||
|
📄 main_bjaavann-golfklubb.jpg
|
||||||
|
📄 main_fet-golfklubb.jpg
|
||||||
|
📄 slide_atlungstad-golfklubb_2.jpg
|
||||||
|
📄 logo_kvinnherad-golfklubb.jpg
|
||||||
|
📄 slide_borregaard-golfklubb_1.jpg
|
||||||
|
📄 slide_tjome-golfklubb_4.jpg
|
||||||
|
📄 main_rjukan-og-tinn-golfklubb.jpg
|
||||||
|
📄 slide_kjekstad-golfklubb_1.jpg
|
||||||
|
📄 slide_norsjo-golfklubb_3.jpg
|
||||||
|
📄 logo_surnadal-golfklubb.png
|
||||||
|
📄 slide_kongsberg-golfklubb_0.jpg
|
||||||
|
📄 slide_hovden-golfklubb_1.jpg
|
||||||
|
📄 main_hemsedal-golfklubb.jpg
|
||||||
|
📄 slide_tyrifjord-golfklubb_5.jpg
|
||||||
|
📄 logo_alesund-golfklubb.jpg
|
||||||
|
📄 slide_tingvoll-golfklubb_0.jpg
|
||||||
|
📄 slide_tyrifjord-golfklubb_2.jpg
|
||||||
|
📄 slide_bleik-golfstrombane_0.jpg
|
||||||
|
📄 slide_nesbyen-golfklubb_0.jpg
|
||||||
|
📄 main_sunnfjord-golfklubb.jpg
|
||||||
|
📄 slide_hasvik-golfklubb_0.jpg
|
||||||
|
📄 logo_stavanger-golfklubb.png
|
||||||
|
📄 slide_ostmarka-golfklubb_3.jpg
|
||||||
|
📄 main_land-golfklubb.jpg
|
||||||
|
📄 slide_helgeland-golfklubb_5.jpg
|
||||||
|
📄 logo_bergen-golfklubb.png
|
||||||
|
📄 slide_lofoten-golfklubb_1.jpg
|
||||||
|
📄 logo_rjukan-og-tinn-golfklubb.jpg
|
||||||
|
📄 main_alta-golfklubb.jpg
|
||||||
|
📄 slide_asker-golfklubb_1.jpg
|
||||||
|
📄 slide_krokhol-golfklubb_5.jpg
|
||||||
|
📄 logo_nordvegen-golfklubb.png
|
||||||
|
📄 logo_kristiansand-golfklubb.jpg
|
||||||
|
📄 main_asker-golfklubb.jpg
|
||||||
|
📄 slide_sleneset-golfklubb_0.jpg
|
||||||
|
📄 logo_re-golfklubb.jpg
|
||||||
|
📄 slide_oustoen-country-club_0.jpg
|
||||||
|
📄 logo_hallingdal-golfklubb.png
|
||||||
|
📄 logo_fet-golfklubb.jpg
|
||||||
|
📄 slide_trondheim-golfklubb_3.jpg
|
||||||
|
📄 slide_borregaard-golfklubb_3.jpg
|
||||||
|
📄 main_hovden-golfklubb.jpg
|
||||||
|
📄 slide_ostmarka-golfklubb_2.jpg
|
||||||
|
📄 slide_sandane-golfklubb_2.jpg
|
||||||
|
📄 slide_trondheim-golfklubb_6.jpg
|
||||||
|
📄 slide_helgeland-golfklubb_9.jpg
|
||||||
|
📄 logo_borre-golfklubb.png
|
||||||
|
📄 main_karasjok-golfklubb.jpg
|
||||||
|
📄 slide_hallingdal-golfklubb_2.jpeg
|
||||||
|
📄 main_north-cape-golf-club.jpg
|
||||||
|
📄 logo_selbu-golfklubb.png
|
||||||
|
📄 logo_sola-golfklubb-forus.jpg
|
||||||
|
📄 slide_asker-golfklubb_0.jpg
|
||||||
|
📄 main_hafjell-golfklubb.jpg
|
||||||
|
📄 logo_mjosen-golfklubb.png
|
||||||
|
📄 slide_atlungstad-golfklubb_0.jpg
|
||||||
|
📄 main_moa-golfsenter.jpg
|
||||||
|
📄 slide_bleik-golfstrombane_7.jpg
|
||||||
|
📄 main_kvinnherad-golfklubb.jpg
|
||||||
|
📄 main_re-golfklubb.jpg
|
||||||
|
📄 logo_haga-golfklubb.jpg
|
||||||
|
📄 main_roros-golfklubb.jpg
|
||||||
|
📄 main_vestfold-golfklubb.jpg
|
||||||
|
📄 slide_egersund-golfklubb_2.jpg
|
||||||
|
📄 slide_volda-golfklubb_2.jpeg
|
||||||
|
📄 slide_egersund-golfklubb_5.jpg
|
||||||
|
📄 main_gjersjoen-golfklubb.jpg
|
||||||
|
📄 main_gamle-fredrikstad-golfklubb.jpg
|
||||||
|
📄 slide_naeroysund-golfklubb_3.jpg
|
||||||
|
📄 slide_oustoen-country-club_1.jpg
|
||||||
|
📄 logo_hitra-golfklubb.png
|
||||||
|
📄 logo_rygge-flystasjon-golf-klubb.jpg
|
||||||
|
📄 slide_tyrifjord-golfklubb_9.jpg
|
||||||
|
📄 slide_trondheim-golfklubb_5.jpg
|
||||||
|
📄 slide_aurskog-golfpark_1.jpg
|
||||||
|
📄 main_sola-golfklubb-solastranden.jpg
|
||||||
|
📄 logo_trysil-golfklubb.jpg
|
||||||
|
📄 logo_harstad-golfklubb.jpg
|
||||||
|
📄 slide_ogna-golfklubb_1.jpg
|
||||||
|
📄 logo_gamle-fredrikstad-golfklubb.jpg
|
||||||
|
📄 main_stranda-golfklubb.jpg
|
||||||
|
📄 logo_sauda-golfklubb.png
|
||||||
|
📄 slide_egersund-golfklubb_0.jpg
|
||||||
|
📄 logo_solum-golfklubb.jpg
|
||||||
|
📄 main_bjornefjorden-golfklubb.jpg
|
||||||
|
📄 main_vestlia-golf.jpg
|
||||||
|
📄 slide_sandefjord-golfklubb_1.jpg
|
||||||
|
📄 logo_borregaard-golfklubb.jpg
|
||||||
|
📄 main_grimstad-golfklubb.jpg
|
||||||
|
📄 logo_vesteralen-golfklubb.png
|
||||||
|
📄 main_kristiansund-og-omegn-golfklubb.jpg
|
||||||
|
📄 slide_hallingdal-golfklubb_3.jpeg
|
||||||
|
📄 logo_sandnes-golfklubb.jpg
|
||||||
|
📄 main_alesund-golfklubb.jpeg
|
||||||
|
📄 main_steinkjer-golfklubb.jpg
|
||||||
|
📄 main_skei-golfklubb.jpg
|
||||||
|
📄 slide_nesbyen-golfklubb_3.jpg
|
||||||
|
📄 slide_hardanger-golfklubb_0.jpg
|
||||||
|
📄 slide_gumoy-golf_1.jpg
|
||||||
|
📄 main_grenland-og-omegn-golfklubb.jpg
|
||||||
|
📄 main_karmoy-golfklubb.jpg
|
||||||
|
📄 logo_egersund-golfklubb.jpg
|
||||||
|
📄 logo_baerum-golfklubb.png
|
||||||
|
📄 main_kristiansand-golfklubb.jpg
|
||||||
|
📄 slide_kongsberg-golfklubb_2.jpg
|
||||||
|
📄 slide_gumoy-golf_3.jpg
|
||||||
|
📄 logo_smola-golfklubb.jpg
|
||||||
|
📄 logo_grimstad-golfklubb.png
|
||||||
|
📄 logo_stord-golfklubb.png
|
||||||
|
📄 slide_helgeland-golfklubb_6.jpg
|
||||||
|
📄 logo_rauma-golfklubb.png
|
||||||
|
📄 logo_hinnoy-golfklubb.jpg
|
||||||
|
📄 slide_holtsmark-golfklubb_0.jpg
|
||||||
|
📄 logo_varanger-golfklubb.jpg
|
||||||
|
📄 logo_mandal-golfklubb.jpg
|
||||||
|
📄 slide_trondheim-golfklubb_1.jpg
|
||||||
|
📄 logo_tjome-golfklubb.png
|
||||||
|
📄 slide_lofoten-golfklubb_2.jpg
|
||||||
|
📄 main_haga-golfklubb.jpg
|
||||||
|
📄 slide_bjaavann-golfklubb_2.jpg
|
||||||
|
📄 slide_hakadal-golfklubb_8.jpg
|
||||||
|
📄 logo_herdla-golfklubb.jpg
|
||||||
|
📄 main_stavanger-golfklubb.jpg
|
||||||
|
📄 main_lommedalen-golfklubb.jpg
|
||||||
|
📄 main_sirdal-fjellgolf-klubb.jpg
|
||||||
|
📄 main_alsten-golfklubb.jpg
|
||||||
|
📄 logo_lillestrom-golfklubb.png
|
||||||
|
📄 main_ogna-golfklubb.jpg
|
||||||
|
📄 slide_helgeland-golfklubb_8.jpg
|
||||||
|
📄 slide_nesbyen-golfklubb_2.jpg
|
||||||
|
📄 main_tjome-golfklubb.jpg
|
||||||
|
📄 main_sandefjord-golfklubb.jpg
|
||||||
|
📄 main_namsos-golfklubb.jpg
|
||||||
|
📄 logo_notteroy-golfklubb.jpg
|
||||||
|
📄 main_trondheim-golfklubb.jpg
|
||||||
|
📄 slide_solum-golfklubb_1.jpg
|
||||||
|
📄 slide_kongsberg-golfklubb_4.jpg
|
||||||
|
📄 main_utsikten-golfklubb.jpg
|
||||||
|
📄 main_vesteralen-golfklubb.jpg
|
||||||
|
📄 slide_sandefjord-golfklubb_0.jpg
|
||||||
|
📄 slide_hakadal-golfklubb_9.jpg
|
||||||
|
📄 logo_polarsirkelen-golfklubb.jpg
|
||||||
|
📄 logo_sandane-golfklubb.jpg
|
||||||
|
📄 slide_groruddalen-golfklubb_2.jpg
|
||||||
|
📄 main_ski-golfklubb.jpg
|
||||||
|
📄 logo_bjornefjorden-golfklubb.png
|
||||||
|
📄 main_polarsirkelen-golfklubb.jpg
|
||||||
|
📄 slide_krokhol-golfklubb_1.jpg
|
||||||
|
📄 main_groruddalen-golfklubb.jpg
|
||||||
|
📄 slide_krokhol-golfklubb_4.jpg
|
||||||
|
📄 main_vanylven-golfklubb.jpg
|
||||||
|
📄 main_borregaard-golfklubb.jpg
|
||||||
|
📄 slide_sandefjord-golfklubb_5.jpg
|
||||||
|
📄 slide_helgeland-golfklubb_2.jpg
|
||||||
|
📄 main_hinnoy-golfklubb.jpg
|
||||||
|
📄 slide_sunndal-golfklubb_0.jpg
|
||||||
|
📄 logo_groruddalen-golfklubb.png
|
||||||
|
📄 main_tingvoll-golfklubb.jpg
|
||||||
|
📄 slide_tjome-golfklubb_3.jpg
|
||||||
|
📄 main_lindesnes-golfklubb.jpg
|
||||||
|
📄 slide_kongsvingers-golfklubb_5.jpg
|
||||||
|
📄 slide_tromso-golfklubb_0.jpg
|
||||||
|
📄 main_randaberg-golfklubb.jpg
|
||||||
|
📄 logo_krokhol-golfklubb.png
|
||||||
|
📄 main_smola-golfklubb.jpg
|
||||||
|
📄 logo_sande-golfklubb.jpg
|
||||||
|
📄 logo_oslo-golfklubb.jpg
|
||||||
|
📄 logo_preikestolen.jpg
|
||||||
|
📄 logo_sunnfjord-golfklubb.jpg
|
||||||
|
📄 slide_lofoten-golfklubb_3.jpg
|
||||||
|
📄 main_bleik-golfstrombane.jpg
|
||||||
|
📄 slide_gumoy-golf_0.jpg
|
||||||
|
📄 logo_modum-golfklubb.png
|
||||||
|
📄 slide_tingvoll-golfklubb_1.jpg
|
||||||
|
📄 logo_bodo-golfklubb.jpg
|
||||||
|
📄 logo_nesbyen-golfklubb.png
|
||||||
|
📄 main_selbu-golfklubb.jpg
|
||||||
|
📄 slide_holtsmark-golfklubb_2.jpg
|
||||||
|
📄 logo_imjelt-pitch-putt.jpg
|
||||||
|
📄 logo_ullensaker-golfklubb.png
|
||||||
|
📄 slide_elverum-golfklubb_1.jpg
|
||||||
|
📄 logo_ringerike-golfklubb.jpg
|
||||||
|
📄 slide_hakadal-golfklubb_6.jpg
|
||||||
|
📄 slide_volda-golfklubb_4.jpg
|
||||||
|
📄 slide_tyrifjord-golfklubb_6.jpg
|
||||||
|
📄 main_solum-golfklubb.jpg
|
||||||
|
📄 slide_helgeland-golfklubb_4.jpg
|
||||||
|
📄 main_stiklestad-golfklubb.jpg
|
||||||
|
📄 slide_stavanger-golfklubb_0.jpg
|
||||||
|
📄 logo_vestlia-golf.jpg
|
||||||
|
📄 slide_vestfold-golfklubb_0.jpg
|
||||||
|
📄 logo_floro-golfklubb.jpg
|
||||||
|
📄 logo_voss-golfklubb.jpg
|
||||||
|
📄 slide_tyrifjord-golfklubb_8.jpg
|
||||||
|
📄 slide_sotra-golfklubb_2.jpg
|
||||||
|
📄 logo_jaeren-golfklubb.png
|
||||||
|
📄 slide_moss-rygge-golfklubb_0.jpg
|
||||||
|
📄 logo_asker-golfklubb.png
|
||||||
|
📄 slide_bleik-golfstrombane_6.jpg
|
||||||
|
📄 slide_bjaavann-golfklubb_3.jpg
|
||||||
|
📄 main_hvam-golfklubb.jpg
|
||||||
|
📄 logo_eiker-golfklubb.jpg
|
||||||
|
📄 slide_bleik-golfstrombane_9.jpg
|
||||||
|
📄 slide_namdal-golfklubb_0.jpg
|
||||||
|
📄 logo_nordhaug-golfklubb.jpg
|
||||||
|
📄 slide_borregaard-golfklubb_0.jpg
|
||||||
|
📄 slide_kongsberg-golfklubb_5.jpg
|
||||||
|
📄 main_hardanger-golfklubb.jpg
|
||||||
|
📄 slide_ostmarka-golfklubb_5.jpg
|
||||||
|
📄 logo_oppegard-golfklubb.png
|
||||||
|
📄 slide_vradal-golfklubb_0.jpg
|
||||||
|
📄 main_narvik-golfklubb.jpg
|
||||||
|
📄 logo_midt-troms-golfklubb.jpg
|
||||||
|
📄 slide_solum-golfklubb_2.jpg
|
||||||
|
📄 slide_sunndal-golfklubb_1.jpg
|
||||||
|
📄 slide_gjersjoen-golfklubb_1.jpg
|
||||||
|
📄 slide_preikestolen_0.jpg
|
||||||
|
📄 main_huseby-hanko-golfklubb.jpg
|
||||||
|
📄 slide_holtsmark-golfklubb_4.jpg
|
||||||
|
📄 main_larvik-golfklubb.jpg
|
||||||
|
📄 slide_hasvik-golfklubb_1.jpg
|
||||||
|
📄 slide_herdla-golfklubb_1.jpg
|
||||||
|
📄 logo_kjekstad-golfklubb.jpg
|
||||||
|
📄 main_austratt-golfklubb.jpg
|
||||||
|
📄 slide_sandefjord-golfklubb_3.jpg
|
||||||
|
📄 logo_hakadal-golfklubb.png
|
||||||
|
📄 slide_bleik-golfstrombane_3.jpg
|
||||||
|
📄 logo_vanylven-golfklubb.jpg
|
||||||
|
📄 main_skjeberg-golfklubb.jpg
|
||||||
|
📄 slide_hovden-golfklubb_3.jpg
|
||||||
|
📄 slide_ostmarka-golfklubb_0.jpg
|
||||||
|
📄 logo_tromso-golfklubb.png
|
||||||
|
📄 main_stjordal-golfklubb.jpg
|
||||||
|
📄 slide_frosta-golfklubb_3.jpg
|
||||||
|
📄 main_nordvegen-golfklubb.jpg
|
||||||
|
📄 slide_norsjo-golfklubb_1.jpg
|
||||||
|
📄 logo_gumoy-golf.png
|
||||||
|
📄 slide_gronmo-golfklubb_2.jpg
|
||||||
|
📄 slide_hardanger-golfklubb_1.jpg
|
||||||
|
📄 main_oppdal-golfklubb.jpg
|
||||||
|
📄 slide_trondheim-golfklubb_0.jpg
|
||||||
|
📄 main_lillestrom-golfklubb.jpg
|
||||||
|
📄 slide_gjersjoen-golfklubb_0.jpg
|
||||||
|
📄 slide_soon-golfklubb_1.jpg
|
||||||
|
📄 logo_vestfold-golfklubb.jpg
|
||||||
|
📄 slide_stavanger-golfklubb_1.jpg
|
||||||
|
📄 main_norefjell-golfklubb.jpg
|
||||||
|
📄 main_kongsberg-golfklubb.jpg
|
||||||
|
📄 slide_gjovik-og-toten-golfklubb_2.jpg
|
||||||
|
📄 logo_veierland-golfklubb.jpg
|
||||||
|
📄 slide_soon-golfklubb_0.jpg
|
||||||
|
📄 slide_egersund-golfklubb_6.jpg
|
||||||
|
📄 slide_norsjo-golfklubb_0.jpg
|
||||||
|
📄 slide_kristiansand-golfklubb_0.jpg
|
||||||
|
📄 main_hurum-golfklubb.jpg
|
||||||
|
📄 main_sola-golfklubb-forus.jpg
|
||||||
|
📄 logo_mork-golfklubb.jpg
|
||||||
|
📄 logo_molde-golfklubb.png
|
||||||
|
📄 main_kongsvingers-golfklubb.jpg
|
||||||
|
📄 main_sunndal-golfklubb.jpg
|
||||||
|
📄 logo_utsikten-golfklubb.jpg
|
||||||
|
📄 logo_austratt-golfklubb.jpg
|
||||||
|
📄 logo_selje-golfklubb.jpg
|
||||||
|
📄 main_stord-golfklubb.jpg
|
||||||
|
📄 logo_sorknes-golfklubb.jpg
|
||||||
|
📄 slide_krokhol-golfklubb_3.jpg
|
||||||
|
📄 slide_hovden-golfklubb_4.jpg
|
||||||
|
📄 main_preikestolen.jpg
|
||||||
|
📄 logo_aurskog-golfpark.png
|
||||||
|
📄 main_aurskog-golfpark.jpg
|
||||||
|
📄 slide_mork-golfklubb_1.jpg
|
||||||
|
📄 logo_roros-golfklubb.jpg
|
||||||
|
📄 main_harstad-golfklubb.jpg
|
||||||
|
📄 main_helgeland-golfklubb.jpg
|
||||||
|
📄 slide_ogna-golfklubb_0.jpg
|
||||||
|
📄 slide_kvinnherad-golfklubb_1.jpg
|
||||||
|
📄 logo_haugesund-golfklubb.png
|
||||||
|
📄 slide_bjaavann-golfklubb_0.jpg
|
||||||
|
📄 slide_hakadal-golfklubb_3.jpg
|
||||||
|
📄 slide_ostmarka-golfklubb_6.jpg
|
||||||
|
📄 slide_bleik-golfstrombane_11.jpg
|
||||||
|
📄 logo_moss-rygge-golfklubb.png
|
||||||
|
📄 slide_borregaard-golfklubb_2.jpg
|
||||||
|
📄 main_lofoten-golfklubb.jpg
|
||||||
|
📄 slide_bleik-golfstrombane_5.jpg
|
||||||
|
📄 logo_oustoen-country-club.jpg
|
||||||
|
📄 main_oslo-golfklubb.jpg
|
||||||
|
📄 slide_gjovik-og-toten-golfklubb_0.jpg
|
||||||
|
📄 slide_frosta-golfklubb_1.jpg
|
||||||
|
📄 slide_ostmarka-golfklubb_7.jpg
|
||||||
|
📄 slide_helgeland-golfklubb_3.jpg
|
||||||
|
📄 logo_vradal-golfklubb.png
|
||||||
|
📄 main_salten-golfklubb-bodo-golfpark.jpg
|
||||||
|
📄 main_rygge-flystasjon-golf-klubb.jpg
|
||||||
|
📄 slide_trondheim-golfklubb_4.jpg
|
||||||
|
📄 main_meland-golfklubb.jpg
|
||||||
|
📄 slide_sotra-golfklubb_1.jpg
|
||||||
|
📄 main_sandane-golfklubb.jpg
|
||||||
|
📄 slide_baerum-golfklubb_0.jpg
|
||||||
|
📄 slide_egersund-golfklubb_3.jpg
|
||||||
|
📄 slide_frosta-golfklubb_5.jpg
|
||||||
|
📄 logo_sandefjord-golfklubb.png
|
||||||
|
📄 slide_egersund-golfklubb_4.jpg
|
||||||
|
📄 slide_sandane-golfklubb_1.jpg
|
||||||
|
📄 slide_bleik-golfstrombane_2.jpg
|
||||||
|
📄 main_eiker-golfklubb.jpg
|
||||||
|
📄 main_tromso-golfklubb.jpg
|
||||||
|
📄 logo_tysnes-golfklubb.jpg
|
||||||
|
📄 slide_hallingdal-golfklubb_4.jpeg
|
||||||
|
📄 main_ringerike-golfklubb.jpg
|
||||||
|
📄 main_norsjo-golfklubb.jpg
|
||||||
|
📄 logo_byneset-golf.jpg
|
||||||
|
📄 logo_land-golfklubb.png
|
||||||
|
📄 slide_bleik-golfstrombane_8.jpg
|
||||||
|
📄 logo_losby-golfklubb.png
|
||||||
|
📄 logo_husoy-golfklubb.png
|
||||||
|
📄 main_nittedal-golfklubb.jpg
|
||||||
|
📄 logo_trondheim-par3golf-havstein.png
|
||||||
|
📄 main_mork-golfklubb.jpg
|
||||||
|
📄 slide_sleneset-golfklubb_1.jpg
|
||||||
|
📄 logo_ekholtbruket-golfklubb.jpg
|
||||||
|
📄 slide_hauger-golfklubb_1.jpg
|
||||||
|
📄 main_borre-golfklubb.jpg
|
||||||
|
📄 main_kragero-golfklubb.jpg
|
||||||
|
📄 main_lonne-golfklubb.jpg
|
||||||
|
📄 logo_namsos-golfklubb.jpg
|
||||||
|
📄 slide_hallingdal-golfklubb_0.jpeg
|
||||||
|
📄 slide_solum-golfklubb_0.jpg
|
||||||
|
📄 main_holtsmark-golfklubb.jpg
|
||||||
|
📄 main_oustoen-country-club.jpg
|
||||||
|
📄 slide_hakadal-golfklubb_12.jpg
|
||||||
|
📄 slide_gronmo-golfklubb_1.jpg
|
||||||
|
📄 main_mjosen-golfklubb.jpg
|
||||||
|
📄 logo_sunndal-golfklubb.jpg
|
||||||
|
📄 logo_larvik-golfklubb.jpg
|
||||||
|
📄 slide_ekholtbruket-golfklubb_0.jpg
|
||||||
|
📄 main_husoy-golfklubb.jpg
|
||||||
|
📄 logo_drammen-golfklubb.png
|
||||||
|
📄 logo_helgeland-golfklubb.png
|
||||||
|
📄 main_gjerdrum-golfklubb.jpg
|
||||||
|
📄 slide_sandefjord-golfklubb_2.jpg
|
||||||
|
📄 main_hvaler-golfklubb.jpg
|
||||||
|
📄 logo_sunnmore-golfklubb.jpg
|
||||||
|
📄 slide_hauger-golfklubb_0.jpg
|
||||||
|
📄 main_naeroysund-golfklubb.jpg
|
||||||
|
📄 slide_hakadal-golfklubb_5.jpg
|
||||||
|
📄 logo_bamble-golfklubb.png
|
||||||
|
📄 slide_sandane-golfklubb_0.jpg
|
||||||
|
📄 main_bodo-golfklubb.jpeg
|
||||||
|
📄 main_drammen-golfklubb.jpg
|
||||||
|
📄 slide_frosta-golfklubb_4.jpg
|
||||||
|
📄 slide_solum-golfklubb_3.jpg
|
||||||
|
📄 slide_bleik-golfstrombane_1.jpg
|
||||||
|
📄 main_trondheim-par3golf-havstein.jpg
|
||||||
|
📄 logo_kongsvingers-golfklubb.jpg
|
||||||
|
📄 slide_volda-golfklubb_3.jpeg
|
||||||
|
📄 slide_tyrifjord-golfklubb_3.jpg
|
||||||
|
📄 slide_hakadal-golfklubb_2.jpg
|
||||||
|
📄 slide_hovden-golfklubb_2.jpg
|
||||||
|
📄 slide_kongsvingers-golfklubb_4.jpg
|
||||||
|
📄 main_arendal-omegn-golfklubb.jpg
|
||||||
|
📄 logo_bjaavann-golfklubb.png
|
||||||
|
📄 main_sande-golfklubb.jpg
|
||||||
|
📄 main_ballerud-golfklubb.jpg
|
||||||
|
📄 logo_norefjell-golfklubb.png
|
||||||
|
📄 slide_kristiansand-golfklubb_1.jpg
|
||||||
|
📄 main_veierland-golfklubb.jpg
|
||||||
|
📄 logo_karmoy-golfklubb.png
|
||||||
|
📄 logo_lofoten-golfklubb.png
|
||||||
|
📄 slide_lofoten-golfklubb_0.jpg
|
||||||
|
📄 logo_hurum-golfklubb.png
|
||||||
|
📄 slide_holtsmark-golfklubb_3.jpg
|
||||||
|
📄 logo_hemsedal-golfklubb.jpg
|
||||||
|
📄 slide_borregaard-golfklubb_5.jpg
|
||||||
|
📄 logo_haugaland-golfklubb.png
|
||||||
|
📄 slide_bjornefjorden-golfklubb_1.jpg
|
||||||
|
📄 main_notteroy-golfklubb.jpg
|
||||||
|
📄 slide_kongsvingers-golfklubb_3.jpg
|
||||||
|
📄 slide_kongsvingers-golfklubb_2.jpg
|
||||||
|
📄 main_fana-golfklubb.jpg
|
||||||
|
📄 logo_giske-golfklubb.jpg
|
||||||
|
📄 slide_hakadal-golfklubb_11.jpg
|
||||||
|
📄 main_nes-golfklubb-09.jpg
|
||||||
|
📄 main_volda-golfklubb.jpeg
|
||||||
|
📄 logo_arendal-omegn-golfklubb.png
|
||||||
|
📄 slide_gjovik-og-toten-golfklubb_1.jpg
|
||||||
|
📄 main_soon-golfklubb.jpg
|
||||||
|
📄 logo_soon-golfklubb.png
|
||||||
|
📄 slide_gumoy-golf_2.jpg
|
||||||
|
📄 logo_alsten-golfklubb.png
|
||||||
|
📄 main_halden-golfklubb.jpg
|
||||||
|
📄 logo_narvik-golfklubb.jpg
|
||||||
|
📄 logo_randaberg-golfklubb.jpg
|
||||||
|
📄 slide_hakadal-golfklubb_10.jpg
|
||||||
|
📄 main_ostmarka-golfklubb.jpg
|
||||||
|
📄 logo_bleik-golfstrombane.jpg
|
||||||
|
📄 logo_hasvik-golfklubb.png
|
||||||
|
📄 main_floro-golfklubb.jpg
|
||||||
|
📄 main_gronmo-golfklubb.jpg
|
||||||
|
📄 slide_hakadal-golfklubb_7.jpg
|
||||||
|
📄 slide_egersund-golfklubb_8.jpg
|
||||||
|
📄 logo_atlungstad-golfklubb.png
|
||||||
|
📄 main_sunnmore-golfklubb.jpeg
|
||||||
|
📄 main_sleneset-golfklubb.jpg
|
||||||
|
📄 logo_lonne-golfklubb.png
|
||||||
|
📄 main_haugaland-golfklubb.jpg
|
||||||
|
📄 main_egersund-golfklubb.jpg
|
||||||
|
📄 slide_solum-golfklubb_5.jpg
|
||||||
|
📄 slide_gamle-fredrikstad-golfklubb_1.jpg
|
||||||
|
📄 main_sotra-golfklubb.jpg
|
||||||
|
📄 main_trysil-golfklubb.jpg
|
||||||
|
📄 logo_kongsberg-golfklubb.png
|
||||||
|
📄 slide_tromso-golfklubb_1.jpg
|
||||||
|
📄 logo_frosta-golfklubb.jpg
|
||||||
|
📄 slide_groruddalen-golfklubb_1.jpg
|
||||||
|
📄 logo_hafjell-golfklubb.png
|
||||||
|
📄 logo_sola-golfklubb-solastranden.png
|
||||||
|
📄 logo_holtsmark-golfklubb.png
|
||||||
|
📄 slide_naeroysund-golfklubb_1.jpg
|
||||||
|
📄 slide_groruddalen-golfklubb_0.jpg
|
||||||
|
📄 slide_naeroysund-golfklubb_4.jpg
|
||||||
|
📄 slide_kjekstad-golfklubb_2.jpg
|
||||||
|
📄 slide_gronmo-golfklubb_0.jpg
|
||||||
|
📄 slide_preikestolen_1.jpg
|
||||||
|
📄 logo_fana-golfklubb.jpg
|
||||||
|
📄 slide_sorknes-golfklubb_0.jpg
|
||||||
|
📄 slide_atlungstad-golfklubb_3.jpg
|
||||||
|
📄 main_losby-golfklubb.jpg
|
||||||
|
📄 slide_borregaard-golfklubb_4.jpg
|
||||||
|
📄 slide_hakadal-golfklubb_1.jpg
|
||||||
|
📄 main_sandnes-golfklubb.jpg
|
||||||
|
📄 slide_norsjo-golfklubb_2.jpg
|
||||||
|
📄 main_frosta-golfklubb.jpg
|
||||||
|
📄 slide_krokhol-golfklubb_0.jpg
|
||||||
|
📄 logo_askim-golfklubb.png
|
||||||
|
📄 main_stryn-golfklubb.jpg
|
||||||
|
📄 slide_lofoten-golfklubb_5.jpg
|
||||||
|
📄 logo_karasjok-golfklubb.jpg
|
||||||
|
📄 slide_krokhol-golfklubb_2.jpg
|
||||||
|
📄 slide_hakadal-golfklubb_14.jpg
|
||||||
|
📄 logo_gjovik-og-toten-golfklubb.png
|
||||||
|
📄 main_haugesund-golfklubb.jpg
|
||||||
|
📄 logo_stjordal-golfklubb.jpg
|
||||||
|
📄 logo_drobak-golfklubb.png
|
||||||
|
📄 main_atlungstad-golfklubb.jpg
|
||||||
|
📄 logo_trondheim-golfklubb.jpg
|
||||||
|
📄 logo_gjersjoen-golfklubb.png
|
||||||
|
📄 slide_lofoten-golfklubb_4.jpg
|
||||||
|
📄 slide_helgeland-golfklubb_7.jpg
|
||||||
|
📄 main_molde-golfklubb.jpg
|
||||||
|
📄 slide_tyrifjord-golfklubb_7.jpg
|
||||||
|
📄 logo_gjerdrum-golfklubb.png
|
||||||
|
📄 main_onsoy-golfklubb.jpg
|
||||||
|
📄 slide_trondheim-golfklubb_2.jpg
|
||||||
|
📄 slide_mork-golfklubb_0.jpg
|
||||||
|
📄 slide_frosta-golfklubb_2.jpg
|
||||||
|
📄 slide_giske-golfklubb_1.jpeg
|
||||||
|
📄 slide_moss-rygge-golfklubb_1.jpg
|
||||||
|
📄 slide_ostmarka-golfklubb_1.jpg
|
||||||
|
📄 slide_volda-golfklubb_0.jpeg
|
||||||
|
📄 logo_sotra-golfklubb.jpg
|
||||||
|
📄 main_mandal-golfklubb.jpg
|
||||||
|
📄 logo_grenland-og-omegn-golfklubb.jpg
|
||||||
|
📄 slide_volda-golfklubb_1.jpeg
|
||||||
|
📄 main_imjelt-pitch-putt.jpg
|
||||||
|
📄 logo_nittedal-golfklubb.png
|
||||||
|
📄 main_gumoy-golf.jpg
|
||||||
|
📄 logo_hauger-golfklubb.png
|
||||||
|
📄 slide_hakadal-golfklubb_4.jpg
|
||||||
|
📄 slide_ostmarka-golfklubb_4.jpg
|
||||||
|
📄 slide_hakadal-golfklubb_0.jpg
|
||||||
|
📄 main_surnadal-golfklubb.jpg
|
||||||
|
📄 logo_hof-golfklubb.png
|
||||||
|
📄 slide_sleneset-golfklubb_2.jpg
|
||||||
|
📄 logo_halden-golfklubb.png
|
||||||
|
📄 logo_naeroysund-golfklubb.png
|
||||||
|
📄 logo_elverum-golfklubb.png
|
||||||
|
📄 main_bamble-golfklubb.jpg
|
||||||
|
📄 logo_kragero-golfklubb.png
|
||||||
|
📄 logo_valdres-golfklubb.png
|
||||||
|
📄 slide_tyrifjord-golfklubb_1.jpg
|
||||||
|
📄 logo_grini-golfklubb.png
|
||||||
|
📄 logo_lindesnes-golfklubb.jpg
|
||||||
|
📄 slide_lofoten-golfklubb_6.jpg
|
||||||
|
📄 main_sauda-golfklubb.jpg
|
||||||
|
📄 main_moss-rygge-golfklubb.jpg
|
||||||
|
📄 main_dalane-golfklubb.jpg
|
||||||
|
📄 logo_skei-golfklubb.jpg
|
||||||
|
📄 logo_ostmarka-golfklubb.png
|
||||||
|
📄 slide_nesbyen-golfklubb_1.jpg
|
||||||
|
📄 logo_stryn-golfklubb.png
|
||||||
|
📄 main_vradal-golfklubb.jpg
|
||||||
|
📄 slide_hovden-golfklubb_0.jpg
|
||||||
|
📄 main_gjovik-og-toten-golfklubb.jpg
|
||||||
|
📄 logo_hvaler-golfklubb.png
|
||||||
|
📄 main_giske-golfklubb.jpeg
|
||||||
|
📄 slide_herdla-golfklubb_0.jpg
|
||||||
|
📄 main_ibestad-golfklubb.jpg
|
||||||
|
📄 slide_kongsvingers-golfklubb_0.jpg
|
||||||
|
📄 slide_atlungstad-golfklubb_1.jpg
|
||||||
|
📄 logo_nordfjord-golfklubb.png
|
||||||
|
📄 logo_eidskog-golfklubb.png
|
||||||
|
📄 main_hitra-golfklubb.jpg
|
||||||
|
📄 logo_dalane-golfklubb.jpg
|
||||||
|
📄 main_kjekstad-golfklubb.jpg
|
||||||
|
📄 logo_odda-golfklubb.jpg
|
||||||
|
📄 logo_ballerud-golfklubb.jpg
|
||||||
|
📄 main_klaebu-golfklubb.jpg
|
||||||
|
📄 main_hasvik-golfklubb.jpg
|
||||||
|
📄 logo_klaebu-golfklubb.png
|
||||||
|
📄 slide_bleik-golfstrombane_4.jpg
|
||||||
|
📄 logo_north-cape-golf-club.jpg
|
||||||
|
📄 slide_tyrifjord-golfklubb_0.jpg
|
||||||
|
📄 logo_moa-golfsenter.png
|
||||||
|
📄 main_herdla-golfklubb.jpg
|
||||||
|
📄 slide_gjersjoen-golfklubb_2.jpg
|
||||||
|
📄 logo_stiklestad-golfklubb.png
|
||||||
|
📄 slide_bleik-golfstrombane_10.jpg
|
||||||
|
📄 logo_hovden-golfklubb.jpg
|
||||||
|
📄 slide_frosta-golfklubb_0.jpg
|
||||||
|
📄 logo_sirdal-fjellgolf-klubb.png
|
||||||
|
📄 main_ekholtbruket-golfklubb.jpg
|
||||||
|
📄 slide_salten-golfklubb-bodo-golfpark_0.jpg
|
||||||
|
📄 main_askim-golfklubb.jpg
|
||||||
|
📄 slide_elverum-golfklubb_0.jpg
|
||||||
|
📄 slide_gamle-fredrikstad-golfklubb_0.jpg
|
||||||
|
📄 main_oppegard-golfklubb.jpg
|
||||||
|
📄 slide_sorknes-golfklubb_1.jpg
|
||||||
|
📄 main_drobak-golfklubb.jpg
|
||||||
|
📄 slide_kongsvingers-golfklubb_1.jpg
|
||||||
|
📄 slide_helgeland-golfklubb_0.jpg
|
||||||
|
📄 slide_vestfold-golfklubb_1.jpg
|
||||||
|
📄 slide_kongsberg-golfklubb_1.jpg
|
||||||
|
📄 slide_tjome-golfklubb_1.jpg
|
||||||
|
📄 slide_ekholtbruket-golfklubb_1.jpg
|
||||||
|
📄 main_bergen-golfklubb.jpg
|
||||||
|
📄 logo_salten-golfklubb-bodo-golfpark.jpg
|
||||||
|
📄 slide_egersund-golfklubb_1.jpg
|
||||||
|
📄 slide_trondheim-golfklubb_7.jpg
|
||||||
|
📄 logo_stranda-golfklubb.jpg
|
||||||
|
📄 main_eidskog-golfklubb.jpg
|
||||||
|
📄 logo_nes-golfklubb-09.jpg
|
||||||
|
📄 logo_ski-golfklubb.png
|
||||||
|
📄 logo_norsjo-golfklubb.png
|
||||||
|
📄 slide_kongsberg-golfklubb_3.jpg
|
||||||
|
📄 slide_ski-golfklubb_0.jpg
|
||||||
|
📄 main_sorknes-golfklubb.jpg
|
||||||
|
📄 main_laerdal-golfklubb.jpg
|
||||||
|
📄 slide_giske-golfklubb_0.jpeg
|
||||||
|
📄 main_ullensaker-golfklubb.jpg
|
||||||
|
📄 main_midt-troms-golfklubb.jpg
|
||||||
|
📄 main_tysnes-golfklubb.jpg
|
||||||
|
📄 logo_namdal-golfklubb.jpg
|
||||||
|
📄 logo_tyrifjord-golfklubb.png
|
||||||
|
📄 slide_bjornefjorden-golfklubb_0.jpg
|
||||||
|
📄 slide_hovden-golfklubb_5.jpg
|
||||||
|
📄 main_tyrifjord-golfklubb.jpg
|
||||||
|
📄 slide_sotra-golfklubb_0.jpg
|
||||||
|
📄 main_valdres-golfklubb.jpg
|
||||||
|
📄 logo_steinkjer-golfklubb.jpg
|
||||||
|
📄 main_krokhol-golfklubb.jpg
|
||||||
|
📄 main_varanger-golfklubb.jpg
|
||||||
|
📄 logo_meland-golfklubb.png
|
||||||
|
📄 slide_namdal-golfklubb_1.jpg
|
||||||
|
📄 main_selje-golfklubb.jpg
|
||||||
|
📁 src/
|
||||||
|
📄 struktur_dump.txt
|
||||||
|
📁 components/
|
||||||
|
📄 Header.tsx
|
||||||
|
📁 config/
|
||||||
|
📄 constants.ts
|
||||||
|
📁 app/
|
||||||
|
📄 FacilitySearch.tsx
|
||||||
|
📄 HeroSlider.tsx
|
||||||
|
📄 favicon.ico
|
||||||
|
📄 globals.css
|
||||||
|
📄 page.tsx
|
||||||
|
📄 layout.tsx
|
||||||
|
📁 golfbaner/
|
||||||
|
📁 [slug]/
|
||||||
|
📄 CourseDisplay.tsx
|
||||||
|
📄 page.tsx
|
||||||
|
📄 FacilityDetailView.tsx
|
||||||
|
📁 kode_eksport_1/
|
||||||
|
📄 frontend_src_components_Header_tsx.txt
|
||||||
|
📄 frontend_next-env_d_ts.txt
|
||||||
|
📄 frontend_src_app_layout_tsx.txt
|
||||||
|
📄 frontend_src_app_page_tsx.txt
|
||||||
|
📄 eksport_script_py.txt
|
||||||
|
📄 frontend_src_app_golfbaner_[slug]_page_tsx.txt
|
||||||
|
📄 frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt
|
||||||
|
📄 frontend_next_config_ts.txt
|
||||||
|
📄 frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt
|
||||||
|
📄 frontend_src_app_HeroSlider_tsx.txt
|
||||||
|
📄 frontend_src_app_FacilitySearch_tsx.txt
|
||||||
|
📄 frontend_src_config_constants_ts.txt
|
||||||
|
📁 backend/
|
||||||
|
📄 scrape_nsg_3.py
|
||||||
|
📄 import_gallery.py
|
||||||
|
📄 scrape_golfamore1.2.py
|
||||||
|
📄 scrape_golfamore1.3.py
|
||||||
|
📄 requirements.txt
|
||||||
|
📄 import_wp.py
|
||||||
|
📄 main.py
|
||||||
|
📄 Dockerfile
|
||||||
|
📄 scrape_golfamore.py
|
||||||
|
📁 public/
|
||||||
|
📁 media/
|
||||||
BIN
frontend/public/media/main_austratt-golfklubb.jpg
Normal file
|
After Width: | Height: | Size: 337 KiB |
BIN
frontend/public/media/slide_atlungstad-golfklubb_2.jpg
Normal file
|
After Width: | Height: | Size: 447 KiB |
BIN
frontend/public/media/slide_atlungstad-golfklubb_3.jpg
Normal file
|
After Width: | Height: | Size: 398 KiB |
BIN
frontend/public/media/slide_bjaavann-golfklubb_3.jpg
Normal file
|
After Width: | Height: | Size: 945 KiB |
BIN
frontend/public/media/slide_bleik-golfstrombane_10.jpg
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
frontend/public/media/slide_bleik-golfstrombane_11.jpg
Normal file
|
After Width: | Height: | Size: 219 KiB |
BIN
frontend/public/media/slide_borregaard-golfklubb_4.jpg
Normal file
|
After Width: | Height: | Size: 267 KiB |
BIN
frontend/public/media/slide_borregaard-golfklubb_5.jpg
Normal file
|
After Width: | Height: | Size: 480 KiB |
BIN
frontend/public/media/slide_egersund-golfklubb_5.jpg
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
frontend/public/media/slide_egersund-golfklubb_6.jpg
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
frontend/public/media/slide_egersund-golfklubb_8.jpg
Normal file
|
After Width: | Height: | Size: 422 KiB |
BIN
frontend/public/media/slide_giske-golfklubb_1.jpeg
Normal file
|
After Width: | Height: | Size: 294 KiB |
BIN
frontend/public/media/slide_gjovik-og-toten-golfklubb_2.jpg
Normal file
|
After Width: | Height: | Size: 222 KiB |
BIN
frontend/public/media/slide_groruddalen-golfklubb_2.jpg
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
frontend/public/media/slide_gumoy-golf_1.jpg
Normal file
|
After Width: | Height: | Size: 382 KiB |
BIN
frontend/public/media/slide_gumoy-golf_2.jpg
Normal file
|
After Width: | Height: | Size: 382 KiB |
BIN
frontend/public/media/slide_hakadal-golfklubb_10.jpg
Normal file
|
After Width: | Height: | Size: 435 KiB |
BIN
frontend/public/media/slide_hakadal-golfklubb_12.jpg
Normal file
|
After Width: | Height: | Size: 401 KiB |
BIN
frontend/public/media/slide_hakadal-golfklubb_14.jpg
Normal file
|
After Width: | Height: | Size: 458 KiB |
BIN
frontend/public/media/slide_hakadal-golfklubb_3.jpg
Normal file
|
After Width: | Height: | Size: 350 KiB |
BIN
frontend/public/media/slide_hakadal-golfklubb_4.jpg
Normal file
|
After Width: | Height: | Size: 382 KiB |
BIN
frontend/public/media/slide_hakadal-golfklubb_6.jpg
Normal file
|
After Width: | Height: | Size: 468 KiB |
BIN
frontend/public/media/slide_hakadal-golfklubb_8.jpg
Normal file
|
After Width: | Height: | Size: 488 KiB |
BIN
frontend/public/media/slide_hardanger-golfklubb_1.jpg
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
frontend/public/media/slide_helgeland-golfklubb_5.jpg
Normal file
|
After Width: | Height: | Size: 369 KiB |
BIN
frontend/public/media/slide_helgeland-golfklubb_6.jpg
Normal file
|
After Width: | Height: | Size: 456 KiB |
BIN
frontend/public/media/slide_helgeland-golfklubb_8.jpg
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
frontend/public/media/slide_hovden-golfklubb_4.jpg
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
frontend/public/media/slide_hovden-golfklubb_5.jpg
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
frontend/public/media/slide_kjekstad-golfklubb_2.jpg
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
frontend/public/media/slide_kongsberg-golfklubb_2.jpg
Normal file
|
After Width: | Height: | Size: 345 KiB |
BIN
frontend/public/media/slide_kongsberg-golfklubb_3.jpg
Normal file
|
After Width: | Height: | Size: 444 KiB |
BIN
frontend/public/media/slide_kongsberg-golfklubb_5.jpg
Normal file
|
After Width: | Height: | Size: 322 KiB |
BIN
frontend/public/media/slide_kongsvingers-golfklubb_2.jpg
Normal file
|
After Width: | Height: | Size: 342 KiB |
BIN
frontend/public/media/slide_kongsvingers-golfklubb_3.jpg
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
frontend/public/media/slide_kongsvingers-golfklubb_5.jpg
Normal file
|
After Width: | Height: | Size: 297 KiB |
BIN
frontend/public/media/slide_lofoten-golfklubb_3.jpg
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
frontend/public/media/slide_lofoten-golfklubb_4.jpg
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
frontend/public/media/slide_lofoten-golfklubb_6.jpg
Normal file
|
After Width: | Height: | Size: 219 KiB |
BIN
frontend/public/media/slide_mork-golfklubb_1.jpg
Normal file
|
After Width: | Height: | Size: 336 KiB |
BIN
frontend/public/media/slide_sorknes-golfklubb_0.jpg
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
frontend/public/media/slide_sorknes-golfklubb_1.jpg
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
frontend/public/media/slide_sotra-golfklubb_2.jpg
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
frontend/public/media/slide_trondheim-golfklubb_4.jpg
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
frontend/public/media/slide_trondheim-golfklubb_5.jpg
Normal file
|
After Width: | Height: | Size: 251 KiB |
BIN
frontend/public/media/slide_trondheim-golfklubb_7.jpg
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
frontend/public/media/slide_tyrifjord-golfklubb_0.jpg
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
frontend/public/media/slide_tyrifjord-golfklubb_1.jpg
Normal file
|
After Width: | Height: | Size: 481 KiB |
BIN
frontend/public/media/slide_tyrifjord-golfklubb_3.jpg
Normal file
|
After Width: | Height: | Size: 317 KiB |
BIN
frontend/public/media/slide_tyrifjord-golfklubb_4.jpg
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
frontend/public/media/slide_tyrifjord-golfklubb_6.jpg
Normal file
|
After Width: | Height: | Size: 393 KiB |
BIN
frontend/public/media/slide_tyrifjord-golfklubb_7.jpg
Normal file
|
After Width: | Height: | Size: 369 KiB |
BIN
frontend/public/media/slide_tyrifjord-golfklubb_9.jpg
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
frontend/public/media/slide_volda-golfklubb_2.jpeg
Normal file
|
After Width: | Height: | Size: 303 KiB |
BIN
frontend/public/media/slide_volda-golfklubb_3.jpeg
Normal file
|
After Width: | Height: | Size: 356 KiB |
|
|
@ -16,74 +16,75 @@ function getDistance(lat1: number, lon1: number, lat2: number, lon2: number) {
|
||||||
export default function FacilitySearch({ initialFacilities }: { initialFacilities: any[] }) {
|
export default function FacilitySearch({ initialFacilities }: { initialFacilities: any[] }) {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [userLocation, setUserLocation] = useState<{ lat: number, lng: number } | null>(null);
|
const [userLocation, setUserLocation] = useState<{ lat: number, lng: number } | null>(null);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [sortMethod, setSortMethod] = useState<'dist' | 'alpha'>('alpha');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
|
||||||
if ("geolocation" in navigator) {
|
if ("geolocation" in navigator) {
|
||||||
navigator.geolocation.getCurrentPosition(p => setUserLocation({ lat: p.coords.latitude, lng: p.coords.longitude }));
|
navigator.geolocation.getCurrentPosition(p => {
|
||||||
|
setUserLocation({ lat: p.coords.latitude, lng: p.coords.longitude });
|
||||||
|
setSortMethod('dist');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const processed = useMemo(() => {
|
const processed = useMemo(() => {
|
||||||
if (!mounted || !Array.isArray(initialFacilities)) return [];
|
if (!Array.isArray(initialFacilities)) return [];
|
||||||
const words = searchQuery.toLowerCase().trim().split(/\s+/).filter(w => w.length > 0);
|
const words = searchQuery.toLowerCase().trim().split(/\s+/).filter(w => w.length > 0);
|
||||||
|
|
||||||
return initialFacilities.map(f => {
|
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 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 hasNSG = f.nsg_data && Object.keys(f.nsg_data).length > 0;
|
||||||
const matches = words.every(w => searchBlob.includes(w));
|
const hasGolfamore = f.golfamore && (f.golfamore_data?.terms || f.golfamore === true);
|
||||||
|
|
||||||
return { ...f, dist, statuses, matches };
|
const blob = `${f.name} ${f.city} ${f.county} ${hasNSG ? 'nsg seniorgolf' : ''} ${hasGolfamore ? 'golfamore' : ''}`.toLowerCase();
|
||||||
|
const matches = words.every(w => blob.includes(w));
|
||||||
|
|
||||||
|
return { ...f, dist, hasNSG, hasGolfamore, matches };
|
||||||
})
|
})
|
||||||
.filter(f => f.matches)
|
.filter(f => f.matches)
|
||||||
.sort((a, b) => (userLocation && a.dist !== b.dist ? a.dist - b.dist : (a.name || "").localeCompare(b.name || "", 'nb')));
|
.sort((a, b) => {
|
||||||
}, [searchQuery, initialFacilities, userLocation, mounted]);
|
if (sortMethod === 'dist' && a.dist !== b.dist) return a.dist - b.dist;
|
||||||
|
return a.name.localeCompare(b.name, 'nb');
|
||||||
if (!mounted) return null;
|
});
|
||||||
|
}, [searchQuery, initialFacilities, userLocation, sortMethod]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto p-6 -mt-8 relative z-40">
|
<div className="max-w-[1400px] mx-auto px-6 py-12 relative z-40">
|
||||||
<div className="text-center mb-4">
|
<div className="text-center mb-6">
|
||||||
<span className="text-[10px] uppercase font-black text-[#7ca982] bg-white px-4 py-1.5 rounded-full shadow-md border border-[#f1f7ed]">
|
<button onClick={() => setSortMethod(sortMethod === 'dist' ? 'alpha' : 'dist')} className="bg-white px-6 py-3 rounded-full shadow-md text-[10px] font-black text-[#8bc34a] uppercase tracking-widest border border-gray-100">
|
||||||
{userLocation ? "GPS AKTIV" : "SORTERER ALFABETISK"} • {processed.length} BANER
|
{sortMethod === 'dist' ? "📍 Nærmeste baner først" : "🔠 Alfabetisk visning"} • {processed.length} baner
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input className="w-full p-6 rounded-3xl shadow-2xl mb-12 text-gray-900 border-none ring-1 ring-black/5 text-xl outline-none focus:ring-2 focus:ring-[#7ca982] transition-all bg-white" placeholder='Søk baner...' value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
|
<input className="w-full p-8 rounded-[2.5rem] shadow-2xl mb-16 text-gray-900 border-none ring-1 ring-black/5 text-2xl outline-none focus:ring-4 focus:ring-[#8bc34a]/20 transition-all bg-white" placeholder='Søk baner, fylke eller "nsg"...' value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
|
||||||
{processed.map((f: any) => (
|
{processed.map((f: any) => (
|
||||||
<Link href={`/golfbaner/${f.slug}`} key={f.id} className="bg-white rounded-[2.5rem] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-300 border border-gray-100 flex flex-col group">
|
<Link href={`/golfbaner/${f.slug}`} key={f.id} className="bg-white rounded-[3rem] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-gray-100 flex flex-col group relative">
|
||||||
<div className="h-48 relative bg-gray-200 overflow-hidden">
|
<div className="h-64 relative overflow-hidden bg-gray-100">
|
||||||
<img src={f.image_url || "/Toppbilde-standard.jpg"} className="w-full h-full object-cover transition duration-700 group-hover:scale-105" alt={f.name} />
|
<img src={f.image_url || "/Toppbilde-standard.jpg"} className="w-full h-full object-cover transition duration-1000 group-hover:scale-105" alt={f.name} />
|
||||||
{f.dist !== Infinity && <div className="absolute top-4 right-4 bg-white/90 backdrop-blur-sm px-3 py-1.5 rounded-xl text-xs font-black shadow-lg text-gray-800">{Math.round(f.dist)} km unna</div>}
|
<div className="absolute top-6 left-6 flex flex-col gap-2">
|
||||||
<div className="absolute top-4 left-4 flex flex-col items-start gap-2 z-20">
|
{(Array.isArray(f.course_statuses) ? f.course_statuses : []).slice(0, 1).map((s: any, idx: number) => {
|
||||||
{f.statuses.map((s: any, idx: number) => {
|
const raw = (s.status || "").toLowerCase();
|
||||||
const raw = (s.status || "ukjent").toLowerCase();
|
let color = "bg-gray-500";
|
||||||
let color = "bg-gray-500 text-white";
|
if (raw === 'aapen') color = "bg-[#8bc34a]";
|
||||||
if (raw.includes('aapen') && !raw.includes('vinter')) color = "bg-green-600 text-white";
|
else if (raw.includes('vinter')) color = "bg-emerald-600";
|
||||||
else if (raw.includes('vinter')) color = "bg-emerald-400 text-gray-900";
|
else if (raw.includes('snart')) color = "bg-amber-500";
|
||||||
else if (raw.includes('snart')) color = "bg-yellow-500 text-gray-900";
|
else if (raw === 'stengt') color = "bg-red-600";
|
||||||
else if (raw.includes('stengt')) color = "bg-red-600 text-white";
|
return <div key={idx} className={`${color} text-white px-4 py-2 rounded-xl text-[10px] font-black uppercase shadow-xl`}>{STATUS_MAP[s.status] || s.status}</div>;
|
||||||
return <div key={idx} className={`px-3 py-1.5 rounded-xl text-[10px] font-black uppercase shadow-lg border border-white/10 backdrop-blur-sm ${color}`}>{f.statuses.length > 1 ? `${s.name}: ${STATUS_MAP[raw] || raw}` : (STATUS_MAP[raw] || raw)}</div>;
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="absolute top-6 right-6 flex gap-2">
|
||||||
|
{f.hasNSG && <div className="w-10 h-10 bg-blue-600 text-white rounded-xl flex items-center justify-center font-black text-xs shadow-2xl">NSG</div>}
|
||||||
|
{f.hasGolfamore && <div className="w-10 h-10 bg-[#ff5722] text-white rounded-xl flex items-center justify-center font-black text-xs shadow-2xl">G</div>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-8 flex flex-col flex-grow">
|
<div className="p-10 flex flex-col flex-grow">
|
||||||
<h3 className="font-black text-2xl text-gray-900 mb-1 group-hover:text-[#7ca982] transition-colors">{f.name}</h3>
|
<h3 className="font-black text-3xl text-[#11280f] mb-2 group-hover:text-[#8bc34a] transition-colors leading-tight">{f.name}</h3>
|
||||||
<p className="text-gray-400 text-sm font-bold uppercase tracking-widest">{f.city}{f.county ? ` • ${f.county}` : ''}</p>
|
<p className="text-gray-400 text-sm font-bold uppercase tracking-widest">{f.city} • {f.county}</p>
|
||||||
<div className="pt-6 border-t border-gray-50 flex justify-between items-center mt-auto">
|
<div className="pt-8 mt-auto border-t border-gray-50 flex items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<span className="bg-[#f1f7ed] text-[#8bc34a] px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest">{f.amenities?.antall_hull || '--'} Hull</span>
|
||||||
<span className="bg-[#f1f7ed] text-[#7ca982] px-3 py-1 rounded-lg text-xs font-black uppercase">{f.amenities?.antall_hull || f.holes || '--'} Hull</span>
|
<span className="bg-gray-50 text-gray-400 px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-gray-100">{f.banetype || 'Park/Skog'}</span>
|
||||||
{f.golfamore && <div className="bg-orange-500 text-white w-5 h-5 flex items-center justify-center rounded-md text-[8px] font-black" title="GolfAmore">G</div>}
|
|
||||||
{f.nsg_data?.url && <div className="bg-blue-600 text-white w-5 h-5 flex items-center justify-center rounded-md text-[8px] font-black" title="SeniorGolf">S</div>}
|
|
||||||
</div>
|
|
||||||
<span className="text-[#7ca982] font-black text-sm uppercase group-hover:translate-x-1 transition-transform">Se bane →</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,130 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { FALLBACK_IMAGE } from "@/config/constants";
|
/**
|
||||||
import { useState, useEffect } from 'react';
|
* 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';
|
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[] }) {
|
export default function HeroSlider({ facilities }: { facilities: any[] }) {
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentSlide] = useState(0);
|
||||||
const [sliderItems, setSliderItems] = useState<any[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const sliderItems = useMemo(() => {
|
||||||
if (!Array.isArray(facilities)) return;
|
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 filtered = facilities.filter(f => {
|
|
||||||
// Robust status-sjekk
|
|
||||||
const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : [];
|
const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : [];
|
||||||
const interesting = ['aapen', 'aapen_med_vintergreener', 'aapner_snart', 'stenger_snart', 'stengt'];
|
const isForbidden = statuses.some((s: any) =>
|
||||||
const hasStatus = statuses.some((s: any) => s && s.status && interesting.includes(s.status));
|
forbiddenStatuses.includes((s.status || "").toLowerCase())
|
||||||
|
);
|
||||||
// Bilde-sjekk
|
return !isForbidden;
|
||||||
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));
|
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]);
|
}, [facilities]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sliderItems.length <= 1) return;
|
if (sliderItems.length <= 1) return;
|
||||||
const t = setInterval(() => setCurrentIndex(p => (p + 1) % sliderItems.length), 6000);
|
const interval = setInterval(() => setCurrentSlide((p) => (p + 1) % sliderItems.length), 8000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(interval);
|
||||||
}, [sliderItems]);
|
}, [sliderItems.length]);
|
||||||
|
|
||||||
if (sliderItems.length === 0) return null;
|
if (sliderItems.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-[55vh] md:h-[65vh] w-full bg-[#11280f] overflow-hidden">
|
<section className="relative h-[65vh] max-h-[624px] w-full overflow-hidden bg-[#11280f]">
|
||||||
{sliderItems.map((f, i) => (
|
{sliderItems.map((f, i) => (
|
||||||
<div key={f.id} className={`absolute inset-0 transition-opacity duration-1000 ${i === currentIndex ? 'opacity-100 z-10' : 'opacity-0 z-0'}`}>
|
<div
|
||||||
<div className="absolute inset-0 bg-black/40 z-10" />
|
key={f.id}
|
||||||
<img src={f.front_image_url || f.image_url} alt={f.name} className="w-full h-full object-cover" />
|
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
|
||||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center text-center px-4">
|
i === currentIndex ? 'opacity-100 z-10' : 'opacity-0 z-0'
|
||||||
<h1 className="text-4xl md:text-7xl font-black text-white mb-2 drop-shadow-2xl">{f.name}</h1>
|
}`}
|
||||||
<p className="text-sm md:text-lg text-white/90 mb-8 uppercase tracking-widest font-bold">{f.city} • {f.county}</p>
|
>
|
||||||
<Link href={`/golfbaner/${f.slug}`} className="bg-[#7ca982] text-white px-10 py-4 rounded-2xl font-black uppercase text-xs tracking-widest hover:bg-[#5b8260] transition-all shadow-2xl">Se golfanlegget</Link>
|
<Link href={`/golfbaner/${f.slug}`} className="block h-full relative group">
|
||||||
</div>
|
<div className="absolute inset-0 bg-gradient-to-t from-[#11280f] via-[#11280f]/40 to-black/10 z-10" />
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={f.image_url}
|
||||||
|
alt={f.name}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-[10s] scale-100 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center">
|
||||||
|
<div className="max-w-[1400px] mx-auto px-6 w-full">
|
||||||
|
<div className="max-w-4xl animate-in fade-in slide-in-from-bottom-8 duration-1000">
|
||||||
|
{/* FONT NEDJUSTERT FRA text-6xl md:text-9xl TIL text-4xl md:text-7xl */}
|
||||||
|
<h2 className="text-4xl md:text-7xl font-black text-white tracking-tighter drop-shadow-2xl leading-[0.9] mb-4">
|
||||||
|
{f.name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/90 text-sm md:text-xl font-bold uppercase tracking-[0.4em] drop-shadow-md">
|
||||||
|
{f.county} <span className="text-[#8bc34a] mx-2">•</span> {f.city}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
|
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-30 flex gap-4">
|
||||||
|
{sliderItems.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setCurrentSlide(i)}
|
||||||
|
className={`h-1 transition-all duration-500 rounded-full ${
|
||||||
|
i === currentIndex ? 'w-16 bg-[#8bc34a]' : 'w-4 bg-white/20'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -5,8 +5,7 @@ import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants";
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import CourseDisplay from './CourseDisplay';
|
import CourseDisplay from './CourseDisplay';
|
||||||
|
|
||||||
// --- UNIFORME MONOKROME IKONER (#11280f) ---
|
// --- MONOKROME SVG IKONER (#11280f) ---
|
||||||
// FIKSET: Lagt til React Fragments <>...</> rundt ikoner med flere stier
|
|
||||||
const Icon = ({ children, className = "w-5 h-5" }: { children: React.ReactNode, className?: string }) => (
|
const Icon = ({ children, className = "w-5 h-5" }: { children: React.ReactNode, className?: string }) => (
|
||||||
<svg className={`${className} flex-shrink-0`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">{children}</svg>
|
<svg className={`${className} flex-shrink-0`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">{children}</svg>
|
||||||
);
|
);
|
||||||
|
|
@ -33,11 +32,9 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
const activeCourses = (facility.courses || []).filter((c: any) => c.holes && c.holes.length > 0);
|
const activeCourses = (facility.courses || []).filter((c: any) => c.holes && c.holes.length > 0);
|
||||||
const amenities = facility.amenities || {};
|
const amenities = facility.amenities || {};
|
||||||
const gallery = Array.isArray(facility.gallery) && facility.gallery.length > 0 ? facility.gallery : [facility.image_url || FALLBACK_IMAGE];
|
const gallery = Array.isArray(facility.gallery) && facility.gallery.length > 0 ? facility.gallery : [facility.image_url || FALLBACK_IMAGE];
|
||||||
const socialLinks = Array.isArray(facility.social_links) ? facility.social_links : [];
|
|
||||||
const shotzoom = Array.isArray(facility.shotzoom) ? facility.shotzoom : [];
|
const shotzoom = Array.isArray(facility.shotzoom) ? facility.shotzoom : [];
|
||||||
const linkClass = "text-orange-600 hover:underline transition-colors font-bold";
|
const linkClass = "text-orange-600 hover:underline transition-colors font-bold";
|
||||||
|
|
||||||
// Slider Logic
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gallery.length <= 1) return;
|
if (gallery.length <= 1) return;
|
||||||
const timer = setInterval(() => setCurrentSlide((p) => (p + 1) % gallery.length), 5000);
|
const timer = setInterval(() => setCurrentSlide((p) => (p + 1) % gallery.length), 5000);
|
||||||
|
|
@ -55,35 +52,28 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
if (el) window.scrollTo({ top: el.getBoundingClientRect().top + window.pageYOffset - 80, behavior: 'smooth' });
|
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";
|
const weatherImg = facility.weather_url?.replace("/graf/dag/", "/innhold/").replace(/\/$/, "") + "/meteogram.svg";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[#f1f7ed] pb-20 relative font-sans text-[#11280f]">
|
<main className="min-h-screen bg-[#f1f7ed] pb-20 relative font-sans text-[#11280f]">
|
||||||
|
|
||||||
{/* HERO / SLIDER */}
|
|
||||||
<div className="h-[55vh] min-h-[450px] relative overflow-hidden bg-[#11280f]">
|
<div className="h-[55vh] min-h-[450px] relative overflow-hidden bg-[#11280f]">
|
||||||
{gallery.map((img: string, i: number) => (
|
{gallery.map((img: string, i: number) => (
|
||||||
<img key={i} src={img} className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-1000 ${i === currentSlide ? 'opacity-100 z-10' : 'opacity-0 z-0'}`} alt="" />
|
<img key={i} src={img} className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-1000 ${i === currentSlide ? 'opacity-100 z-10' : 'opacity-0 z-0'}`} alt="" />
|
||||||
))}
|
))}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-[#11280f]/90 via-transparent to-black/10 z-20" />
|
<div className="absolute inset-0 bg-gradient-to-t from-[#11280f]/90 via-transparent to-black/10 z-20" />
|
||||||
|
|
||||||
{/* TOP-RIGHT STATUS */}
|
|
||||||
<div className="absolute top-8 right-8 z-40 flex flex-col items-end gap-1">
|
<div className="absolute top-8 right-8 z-40 flex flex-col items-end gap-1">
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
{activeCourses.map((c: any) => (
|
{activeCourses.map((c: any) => (
|
||||||
<span key={c.id} className="px-3 py-1.5 rounded-lg text-[10px] font-black uppercase bg-[#7ca982] text-white shadow-xl">
|
<span key={c.id} className="px-3 py-1.5 rounded-lg text-[10px] font-black uppercase bg-[#7ca982] text-white shadow-xl">{STATUS_MAP[c.status] || c.status}</span>
|
||||||
{activeCourses.length > 1 ? `${c.name}: ${STATUS_MAP[c.status] || c.status}` : STATUS_MAP[c.status] || c.status}
|
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{facility.status_updated_at && (
|
{facility.status_updated_at && <span className="text-white/60 text-[10px] uppercase font-bold tracking-widest mt-1">Sist oppdatert: {formatDate(facility.status_updated_at)}</span>}
|
||||||
<span className="text-white/60 text-[10px] uppercase font-bold tracking-widest mt-1">
|
|
||||||
Sist oppdatert: {new Date(facility.status_updated_at).toLocaleDateString('nb-NO', { day: 'numeric', month: 'long', year: 'numeric' })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* BOTTOM-RIGHT BUTTONS */}
|
|
||||||
<div className="absolute bottom-8 right-8 z-40 flex gap-2.5 bg-black/30 backdrop-blur-md p-2 rounded-2xl border border-white/10 shadow-2xl">
|
<div className="absolute bottom-8 right-8 z-40 flex gap-2.5 bg-black/30 backdrop-blur-md p-2 rounded-2xl border border-white/10 shadow-2xl">
|
||||||
{facility.website_url && <a href={facility.website_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center text-[#11280f] hover:text-orange-600 transition-all"><Icon children={ICONS.web} /></a>}
|
{facility.website_url && <a href={facility.website_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center text-[#11280f] hover:text-orange-600 transition-all"><Icon children={ICONS.web} /></a>}
|
||||||
{facility.golfbox_booking_url && <a href={facility.golfbox_booking_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center text-[#11280f] hover:text-orange-600 transition-all"><Icon children={ICONS.booking} /></a>}
|
{facility.golfbox_booking_url && <a href={facility.golfbox_booking_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center text-[#11280f] hover:text-orange-600 transition-all"><Icon children={ICONS.booking} /></a>}
|
||||||
|
|
@ -92,13 +82,9 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
{facility.weather_url && <a href={facility.weather_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center text-[#11280f] hover:text-orange-600 transition-all"><Icon children={ICONS.weather} /></a>}
|
{facility.weather_url && <a href={facility.weather_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center text-[#11280f] hover:text-orange-600 transition-all"><Icon children={ICONS.weather} /></a>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-30 max-w-[1200px] mx-auto px-6 w-full h-full flex flex-col justify-end pb-24 md:pb-12 text-center md:text-left">
|
<div className="relative z-30 max-w-[1200px] mx-auto px-6 w-full h-full flex flex-col justify-end pb-12 text-center md:text-left">
|
||||||
{facility.logo_url && (
|
{facility.logo_url && <div className="hidden md:block mb-8 w-24 h-24 bg-white p-2 rounded-2xl shadow-2xl border-4 border-white/20 overflow-hidden"><img src={facility.logo_url} className="w-full h-full object-contain" alt="" /></div>}
|
||||||
<div className="hidden md:block mb-8 w-24 h-24 bg-white p-2 rounded-2xl shadow-2xl border-4 border-white/20 overflow-hidden">
|
<h1 className="text-5xl md:text-8xl font-black text-white mb-3 tracking-tighter drop-shadow-2xl">{facility.name}</h1>
|
||||||
<img src={facility.logo_url} className="w-full h-full object-contain" alt="" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<h1 className="text-4xl md:text-7xl font-black text-white mb-3 tracking-tighter drop-shadow-2xl">{facility.name}</h1>
|
|
||||||
<p className="text-[#7ca982] uppercase tracking-[0.4em] font-black text-xs md:text-sm pl-1">{facility.county} • {facility.city}</p>
|
<p className="text-[#7ca982] uppercase tracking-[0.4em] font-black text-xs md:text-sm pl-1">{facility.county} • {facility.city}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -117,7 +103,6 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
|
|
||||||
<div className="max-w-[1200px] mx-auto px-0 md:px-6 space-y-4 md:space-y-12 mt-0 md:mt-12">
|
<div className="max-w-[1200px] mx-auto px-0 md:px-6 space-y-4 md:space-y-12 mt-0 md:mt-12">
|
||||||
|
|
||||||
{/* RAD 1: INTRO (22%) */}
|
|
||||||
<section id="intro" className="flex flex-col lg:flex-row gap-0 md:gap-8 items-stretch">
|
<section id="intro" className="flex flex-col lg:flex-row gap-0 md:gap-8 items-stretch">
|
||||||
<div className="lg:w-[22%] bg-white p-10 md:rounded-[3rem] shadow-sm flex flex-col border-b md:border-none">
|
<div className="lg:w-[22%] bg-white p-10 md:rounded-[3rem] shadow-sm flex flex-col border-b md:border-none">
|
||||||
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-8">Kontakt & Adresse</h3>
|
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-8">Kontakt & Adresse</h3>
|
||||||
|
|
@ -126,19 +111,18 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
<a href={`tel:${facility.phone}`} className="flex items-center gap-4"><Icon children={ICONS.phone} /> {facility.phone || 'Ikke oppgitt'}</a>
|
<a href={`tel:${facility.phone}`} className="flex items-center gap-4"><Icon children={ICONS.phone} /> {facility.phone || 'Ikke oppgitt'}</a>
|
||||||
<a href={`mailto:${facility.email}`} className="flex items-center gap-4 truncate"><Icon children={ICONS.mail} /> {facility.email || 'Ikke oppgitt'}</a>
|
<a href={`mailto:${facility.email}`} className="flex items-center gap-4 truncate"><Icon children={ICONS.mail} /> {facility.email || 'Ikke oppgitt'}</a>
|
||||||
<a href={`https://www.google.com/maps/search/?api=1&query=${facility.lat},${facility.lng}`} target="_blank" className="flex items-start gap-4 leading-tight hover:text-orange-600 transition-colors pt-2"><Icon children={ICONS.map} className="mt-1" /> <span>{facility.address}<br/>{facility.city}</span></a>
|
<a href={`https://www.google.com/maps/search/?api=1&query=${facility.lat},${facility.lng}`} target="_blank" className="flex items-start gap-4 leading-tight hover:text-orange-600 transition-colors pt-2"><Icon children={ICONS.map} className="mt-1" /> <span>{facility.address}<br/>{facility.city}</span></a>
|
||||||
<div className="pt-6 border-t border-gray-50 flex gap-5 text-[#11280f]">
|
<div className="pt-6 border-t border-gray-50 flex gap-5">
|
||||||
{facility.facebook_url && <a href={facility.facebook_url} target="_blank" className="hover:text-orange-600 transition-all"><Icon children={ICONS.facebook} className="w-6 h-6" /></a>}
|
{facility.facebook_url && <a href={facility.facebook_url} target="_blank" className="hover:text-orange-600 transition-all"><Icon children={ICONS.facebook} className="w-6 h-6" /></a>}
|
||||||
{facility.instagram_url && <a href={facility.instagram_url} target="_blank" className="hover:text-orange-600 transition-all"><Icon children={ICONS.instagram} className="w-6 h-6" /></a>}
|
{facility.instagram_url && <a href={facility.instagram_url} target="_blank" className="hover:text-orange-600 transition-all"><Icon children={ICONS.instagram} className="w-6 h-6" /></a>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:w-[78%] bg-white p-10 md:p-16 md:rounded-[3rem] shadow-sm border-b md:border-none leading-relaxed text-lg md:text-xl text-gray-600">
|
<div className="lg:w-[78%] bg-white p-10 md:p-16 md:rounded-[3rem] shadow-sm border-b md:border-none">
|
||||||
{facility.footnote && <div className="mb-8 pb-8 border-b border-gray-50 italic text-gray-400 text-lg leading-relaxed">{facility.footnote}</div>}
|
{facility.footnote && <div className="mb-8 pb-8 border-b border-gray-50 italic text-gray-400 text-lg leading-relaxed font-serif">{facility.footnote}</div>}
|
||||||
<div dangerouslySetInnerHTML={{ __html: facility.description }} />
|
<div className="leading-relaxed text-lg md:text-xl text-gray-600" dangerouslySetInnerHTML={{ __html: facility.description }} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* RAD 2: VÆR */}
|
|
||||||
<section id="weather" className="bg-white p-0 md:p-12 md:rounded-[3rem] shadow-sm border-b md:border-none overflow-hidden text-center">
|
<section id="weather" className="bg-white p-0 md:p-12 md:rounded-[3rem] shadow-sm border-b md:border-none overflow-hidden text-center">
|
||||||
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-[0.2em] py-8 md:py-0 md:mb-10 flex items-center justify-center gap-3"><Icon children={ICONS.weather} /> Vær for {facility.name}</h3>
|
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-[0.2em] py-8 md:py-0 md:mb-10 flex items-center justify-center gap-3"><Icon children={ICONS.weather} /> Vær for {facility.name}</h3>
|
||||||
<div className="w-full flex justify-center px-4 md:px-0">
|
<div className="w-full flex justify-center px-4 md:px-0">
|
||||||
|
|
@ -146,16 +130,15 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* RAD 3: DETALJER */}
|
|
||||||
<section id="details" className="flex flex-col lg:flex-row gap-4 md:gap-8">
|
<section id="details" className="flex flex-col lg:flex-row gap-4 md:gap-8">
|
||||||
<div className="lg:w-[22%] bg-white p-10 md:rounded-[3rem] shadow-sm border-b md:border-none">
|
<div className="lg:w-[22%] bg-white p-10 md:rounded-[3rem] shadow-sm border-b md:border-none">
|
||||||
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter">Andre ressurser</h3>
|
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter">Andre ressurser</h3>
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5 text-[#11280f]">
|
||||||
{facility.golfbox_booking_url && <a href={facility.golfbox_booking_url} target="_blank" className="flex justify-between items-center p-4 bg-gray-50 rounded-2xl text-[11px] font-black uppercase hover:bg-orange-600 hover:text-white transition-all shadow-sm"><span><Icon children={ICONS.booking} className="inline mr-3" /> Book Starttid</span><span>→</span></a>}
|
{facility.golfbox_booking_url && <a href={facility.golfbox_booking_url} target="_blank" className="flex justify-between items-center p-4 bg-gray-50 rounded-2xl text-[11px] font-black uppercase hover:bg-orange-600 hover:text-white transition-all"><span><Icon children={ICONS.booking} className="inline mr-3" /> Book Starttid</span><span>→</span></a>}
|
||||||
{facility.golfbox_tournament_url && <a href={facility.golfbox_tournament_url} target="_blank" className="flex justify-between items-center p-4 bg-gray-50 rounded-2xl text-[11px] font-black uppercase hover:bg-orange-600 hover:text-white transition-all shadow-sm"><span><Icon children={ICONS.trophy} className="inline mr-3" /> Turneringer</span><span>→</span></a>}
|
{facility.golfbox_tournament_url && <a href={facility.golfbox_tournament_url} target="_blank" className="flex justify-between items-center p-4 bg-gray-50 rounded-2xl text-[11px] font-black uppercase hover:bg-orange-600 hover:text-white transition-all"><span><Icon children={ICONS.trophy} className="inline mr-3" /> Turneringer</span><span>→</span></a>}
|
||||||
{facility.baneguide_url && <a href={facility.baneguide_url} target="_blank" className="flex justify-between items-center p-4 bg-gray-50 rounded-2xl text-[11px] font-black uppercase hover:bg-orange-600 hover:text-white transition-all shadow-sm"><span><Icon children={ICONS.guide} className="inline mr-3" /> Baneguide</span><span>→</span></a>}
|
{facility.baneguide_url && <a href={facility.baneguide_url} target="_blank" className="flex justify-between items-center p-4 bg-gray-50 rounded-2xl text-[11px] font-black uppercase hover:bg-orange-600 hover:text-white transition-all"><span><Icon children={ICONS.guide} className="inline mr-3" /> Baneguide</span><span>→</span></a>}
|
||||||
{facility.flyfoto_url && <a href={facility.flyfoto_url} target="_blank" className="flex justify-between items-center p-4 bg-gray-50 rounded-2xl text-[11px] font-black uppercase hover:bg-orange-600 hover:text-white transition-all shadow-sm"><span><Icon children={ICONS.camera} className="inline mr-3" /> Flyfoto</span><span>→</span></a>}
|
{facility.flyfoto_url && <a href={facility.flyfoto_url} target="_blank" className="flex justify-between items-center p-4 bg-gray-50 rounded-2xl text-[11px] font-black uppercase hover:bg-orange-600 hover:text-white transition-all"><span><Icon children={ICONS.camera} className="inline mr-3" /> Flyfoto</span><span>→</span></a>}
|
||||||
{facility.webcam_url && <a href={facility.webcam_url} target="_blank" className="flex justify-between items-center p-4 bg-gray-50 rounded-2xl text-[11px] font-black uppercase hover:bg-orange-600 hover:text-white transition-all shadow-sm"><span><Icon children={ICONS.webcam} className="inline mr-3" /> Webkamera</span><span>→</span></a>}
|
{facility.webcam_url && <a href={facility.webcam_url} target="_blank" className="flex justify-between items-center p-4 bg-gray-50 rounded-2xl text-[11px] font-black uppercase hover:bg-orange-600 hover:text-white transition-all"><span><Icon children={ICONS.webcam} className="inline mr-3" /> Webkamera</span><span>→</span></a>}
|
||||||
{shotzoom.map((sz: any, i: number) => (
|
{shotzoom.map((sz: any, i: number) => (
|
||||||
<a key={i} href={sz.shotzoom_url} target="_blank" title="Statistikk fra Shotzoom" className="flex justify-between items-center p-4 bg-gray-50 rounded-2xl text-[11px] font-black uppercase text-orange-600 hover:bg-orange-600 hover:text-white transition-all border border-orange-50 shadow-sm"><span>📊 Statistikk: {sz.shotzoom_beskrivelse?.replace(' ', ' ')}</span><span>→</span></a>
|
<a key={i} href={sz.shotzoom_url} target="_blank" title="Statistikk fra Shotzoom" className="flex justify-between items-center p-4 bg-gray-50 rounded-2xl text-[11px] font-black uppercase text-orange-600 hover:bg-orange-600 hover:text-white transition-all border border-orange-50 shadow-sm"><span>📊 Statistikk: {sz.shotzoom_beskrivelse?.replace(' ', ' ')}</span><span>→</span></a>
|
||||||
))}
|
))}
|
||||||
|
|
@ -170,6 +153,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400 font-medium">Lengde:</span><span>{facility.length_meters ? `${facility.length_meters}m` : '--'}</span></div>
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400 font-medium">Lengde:</span><span>{facility.length_meters ? `${facility.length_meters}m` : '--'}</span></div>
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400 font-medium">Sesong:</span><span>{facility.season || '--'}</span></div>
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400 font-medium">Sesong:</span><span>{facility.season || '--'}</span></div>
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400 font-medium">Byggeår:</span><span>{facility.established_year || '--'}</span></div>
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400 font-medium">Byggeår:</span><span>{facility.established_year || '--'}</span></div>
|
||||||
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400 font-medium">Banetype:</span><span>{facility.banetype || 'Park/Skog'}</span></div>
|
||||||
<div className="flex justify-between pb-1"><span className="text-gray-400 font-medium">Arkitekt:</span><span className="text-right truncate ml-4">{facility.architect || '--'}</span></div>
|
<div className="flex justify-between pb-1"><span className="text-gray-400 font-medium">Arkitekt:</span><span className="text-right truncate ml-4">{facility.architect || '--'}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -188,7 +172,6 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* RAD 4: KART & VIDEO */}
|
|
||||||
<section id="map" className="space-y-6">
|
<section id="map" className="space-y-6">
|
||||||
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Kart <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
|
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Kart <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
|
||||||
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-xl h-[450px] md:h-[650px] border-y-4 md:border-[12px] border-white bg-gray-100"><iframe width="100%" height="100%" style={{ border: 0 }} src={`https://maps.google.com/maps?q=${facility.lat},${facility.lng}&t=k&z=15&ie=UTF8&iwloc=&output=embed`} allowFullScreen /></div>
|
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-xl h-[450px] md:h-[650px] border-y-4 md:border-[12px] border-white bg-gray-100"><iframe width="100%" height="100%" style={{ border: 0 }} src={`https://maps.google.com/maps?q=${facility.lat},${facility.lng}&t=k&z=15&ie=UTF8&iwloc=&output=embed`} allowFullScreen /></div>
|
||||||
|
|
@ -201,11 +184,10 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* RAD 6: PRISER & MEDLEMSKAP */}
|
|
||||||
<section id="prices" className="grid grid-cols-1 lg:grid-cols-2 gap-0 md:gap-8">
|
<section id="prices" className="grid grid-cols-1 lg:grid-cols-2 gap-0 md:gap-8">
|
||||||
<div className="bg-white p-10 md:p-14 md:rounded-[3rem] shadow-sm border-b md:border-none">
|
<div className="bg-white p-10 md:p-14 md:rounded-[3rem] shadow-sm border-b md:border-none">
|
||||||
<h3 className="text-2xl font-black mb-10 uppercase tracking-tighter flex items-center gap-4"><span>⛳</span> Gjestespill</h3>
|
<h3 className="text-2xl font-black mb-10 uppercase tracking-tighter flex items-center gap-4"><span>⛳</span> Gjestespill</h3>
|
||||||
<div className="space-y-2 text-gray-700">
|
<div className="space-y-2">
|
||||||
{Array.isArray(facility.greenfee) && facility.greenfee.length > 0 ? (
|
{Array.isArray(facility.greenfee) && facility.greenfee.length > 0 ? (
|
||||||
facility.greenfee.map((g: any, i: number) => (
|
facility.greenfee.map((g: any, i: number) => (
|
||||||
<div key={i} className="flex justify-between py-4 border-b border-gray-50 font-bold text-lg"><span className="text-gray-400 font-medium">{g.priskategori}</span><span className="text-[#11280f]">kr {g.pris_voksne || '--'},-</span></div>
|
<div key={i} className="flex justify-between py-4 border-b border-gray-50 font-bold text-lg"><span className="text-gray-400 font-medium">{g.priskategori}</span><span className="text-[#11280f]">kr {g.pris_voksne || '--'},-</span></div>
|
||||||
|
|
@ -231,7 +213,6 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* RAD 7: SCOREKORT */}
|
|
||||||
<section id="scorecards" className="pt-10 space-y-20 overflow-hidden">
|
<section id="scorecards" className="pt-10 space-y-20 overflow-hidden">
|
||||||
<h3 className="text-center text-3xl md:text-5xl font-black uppercase tracking-tighter">Scorekort</h3>
|
<h3 className="text-center text-3xl md:text-5xl font-black uppercase tracking-tighter">Scorekort</h3>
|
||||||
<div className="w-full flex flex-col items-center gap-20">
|
<div className="w-full flex flex-col items-center gap-20">
|
||||||
|
|
@ -244,7 +225,6 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TILBAKE TIL TOPPEN */}
|
|
||||||
{showBackToTop && ( <button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className="fixed bottom-8 right-8 w-14 h-14 bg-[#11280f] text-white rounded-full shadow-2xl flex items-center justify-center text-2xl z-[100] hover:scale-110 transition-all border-4 border-white/20">↑</button> )}
|
{showBackToTop && ( <button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className="fixed bottom-8 right-8 w-14 h-14 bg-[#11280f] text-white rounded-full shadow-2xl flex items-center justify-center text-2xl z-[100] hover:scale-110 transition-all border-4 border-white/20">↑</button> )}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,17 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import Header from "@/components/Header";
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "TeeOff.no - Din guide til norske golfbaner",
|
||||||
description: "Generated by create next app",
|
description: "Oppdatert banestatus, priser og informasjon om alle norske golfanlegg.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="nb">
|
||||||
<body
|
<body className="antialiased bg-[#f1f7ed]">
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<Header />
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
45
frontend/src/components/Header.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
"use client";
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-[100] bg-white/95 backdrop-blur-md border-b border-gray-100 shadow-sm">
|
||||||
|
<div className="max-w-[1400px] mx-auto px-6 h-20 flex items-center justify-between">
|
||||||
|
|
||||||
|
{/* LOGO */}
|
||||||
|
<Link href="/" className="h-10 md:h-12 transition-transform hover:scale-105 active:scale-95">
|
||||||
|
<img src="/TeeOff-logo-Retina-1.png" alt="TeeOff.no" className="h-full w-auto object-contain" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* DESKTOP NAV */}
|
||||||
|
<nav className="hidden md:flex items-center gap-8 text-[11px] font-black uppercase tracking-widest text-gray-500">
|
||||||
|
<Link href="/" className="hover:text-[#8bc34a]">Hjem</Link>
|
||||||
|
<Link href="/golfbaner" className="hover:text-[#8bc34a]">Finn Bane</Link>
|
||||||
|
<Link href="/medlemskap" className="hover:text-[#8bc34a]">Medlemskap</Link>
|
||||||
|
<Link href="/om-oss" className="hover:text-[#8bc34a]">Om oss</Link>
|
||||||
|
<Link href="/logg-inn" className="px-5 py-2 bg-[#ff5722] text-white rounded-xl hover:bg-[#e64a19] transition-all">Admin</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* HAMBURGER (Mobil) */}
|
||||||
|
<button onClick={() => setIsOpen(!isOpen)} className="md:hidden p-2 text-[#11280f]">
|
||||||
|
<div className="w-6 h-0.5 bg-current mb-1.5 transition-all"></div>
|
||||||
|
<div className="w-6 h-0.5 bg-current mb-1.5"></div>
|
||||||
|
<div className="w-6 h-0.5 bg-current"></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MOBIL MENY OVERLAY */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="md:hidden absolute top-20 left-0 w-full bg-white border-b border-gray-100 p-6 flex flex-col gap-6 shadow-2xl animate-in slide-in-from-top duration-300">
|
||||||
|
<Link onClick={() => setIsOpen(false)} href="/" className="text-lg font-black uppercase text-[#11280f]">Hjem</Link>
|
||||||
|
<Link onClick={() => setIsOpen(false)} href="/golfbaner" className="text-lg font-black uppercase text-[#11280f]">Finn Bane</Link>
|
||||||
|
<Link onClick={() => setIsOpen(false)} href="/medlemskap" className="text-lg font-black uppercase text-[#11280f]">Medlemskap</Link>
|
||||||
|
<Link onClick={() => setIsOpen(false)} href="/logg-inn" className="text-[#ff5722] font-black uppercase">Admin Logg inn</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
kode_eksport_1/backend_import_gallery_py.txt
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import asyncio
|
||||||
|
import asyncpg
|
||||||
|
import urllib.request
|
||||||
|
import json
|
||||||
|
|
||||||
|
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
||||||
|
|
||||||
|
async def fetch_json(url):
|
||||||
|
"""Hjelpefunksjon for å hente JSON fra en URL"""
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={'User-Agent': 'TeeOff-Migrator/2.0'})
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
return json.loads(response.read().decode())
|
||||||
|
except Exception as e:
|
||||||
|
# print(f"⚠️ Kunne ikke hente {url}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fetch_media_urls_by_ids(media_ids):
|
||||||
|
"""Henter URLer for en liste med media-IDer (ACF Slides)"""
|
||||||
|
if not media_ids or not isinstance(media_ids, list) or len(media_ids) == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
valid_ids = [str(mid) for mid in media_ids if isinstance(mid, (int, str)) and str(mid).isdigit()]
|
||||||
|
if not valid_ids: return []
|
||||||
|
|
||||||
|
ids_str = ",".join(valid_ids)
|
||||||
|
url = f"https://teeoff.no/wp-json/wp/v2/media?include={ids_str}"
|
||||||
|
data = await fetch_json(url)
|
||||||
|
|
||||||
|
urls = []
|
||||||
|
if data:
|
||||||
|
for m in data:
|
||||||
|
if 'source_url' in m:
|
||||||
|
urls.append(m['source_url'])
|
||||||
|
return urls
|
||||||
|
|
||||||
|
async def run_robust_import():
|
||||||
|
print("🕵️♂️ Starter den store bildejakten (sjekker både Utvalgt bilde og Slides)...")
|
||||||
|
conn = await asyncpg.connect(DB_URL)
|
||||||
|
|
||||||
|
# VIKTIG: Vi tømmer tabellen for å starte med blanke ark og unngå duplikater
|
||||||
|
await conn.execute("TRUNCATE facility_images CASCADE;")
|
||||||
|
print("🗑️ Tømte gammel bilde-tabell. Starter import...")
|
||||||
|
|
||||||
|
# Hent alle anleggene fra vår egen database
|
||||||
|
facilities = await conn.fetch("SELECT id, slug, name FROM facilities ORDER BY name")
|
||||||
|
|
||||||
|
total_images_saved = 0
|
||||||
|
|
||||||
|
for i, fac in enumerate(facilities):
|
||||||
|
fac_id = fac['id']
|
||||||
|
slug = fac['slug']
|
||||||
|
name = fac['name']
|
||||||
|
print(f"[{i+1}/{len(facilities)}] Sjekker: {name} ({slug})...")
|
||||||
|
|
||||||
|
# Hent data fra WP med ?_embed for å få tak i Utvalgt bilde lett
|
||||||
|
wp_url = f"https://teeoff.no/wp-json/wp/v2/golfbaner?slug={slug}&_embed"
|
||||||
|
wp_data_list = await fetch_json(wp_url)
|
||||||
|
|
||||||
|
if not wp_data_list:
|
||||||
|
print(" ❌ Fant ikke anlegget i WordPress API.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
post = wp_data_list[0]
|
||||||
|
final_image_urls = []
|
||||||
|
|
||||||
|
# 1. SJEKK: "Utvalgt bilde" (Standard WordPress)
|
||||||
|
try:
|
||||||
|
embedded = post.get('_embedded', {})
|
||||||
|
if 'wp:featuredmedia' in embedded and len(embedded['wp:featuredmedia']) > 0:
|
||||||
|
feat_media = embedded['wp:featuredmedia'][0]
|
||||||
|
feat_url = feat_media.get('source_url')
|
||||||
|
if feat_url:
|
||||||
|
final_image_urls.append(feat_url)
|
||||||
|
# print(f" -> Fant utvalgt bilde.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Feil ved sjekk av utvalgt bilde: {e}")
|
||||||
|
|
||||||
|
# 2. SJEKK: ACF Slides (Bildekarusell)
|
||||||
|
try:
|
||||||
|
acf = post.get('acf') or {}
|
||||||
|
slides_ids = acf.get('slides')
|
||||||
|
slide_urls = await fetch_media_urls_by_ids(slides_ids)
|
||||||
|
if slide_urls:
|
||||||
|
final_image_urls.extend(slide_urls)
|
||||||
|
# print(f" -> Fant {len(slide_urls)} bilder i slider.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Feil ved sjekk av slides: {e}")
|
||||||
|
|
||||||
|
# Fjern duplikater (hvis samme bilde er brukt begge steder) og bevar rekkefølgen
|
||||||
|
unique_urls = list(dict.fromkeys(final_image_urls))
|
||||||
|
|
||||||
|
# LAGRE I DATABASEN
|
||||||
|
if unique_urls:
|
||||||
|
sort_order = 0
|
||||||
|
for url in unique_urls:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO facility_images (facility_id, image_url, sort_order) VALUES ($1, $2, $3)",
|
||||||
|
fac_id, url, sort_order
|
||||||
|
)
|
||||||
|
sort_order += 1
|
||||||
|
print(f" ✅ Lagret {len(unique_urls)} unike bilder.")
|
||||||
|
total_images_saved += len(unique_urls)
|
||||||
|
else:
|
||||||
|
print(" ⚠️ Fant INGEN bilder for dette anlegget.")
|
||||||
|
|
||||||
|
print(f"\n🎉 FERDIG! Totalt {total_images_saved} bilder er nå trygt lagret i galleriet.")
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(run_robust_import())
|
||||||
157
kode_eksport_1/backend_import_wp_py.txt
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import asyncio, asyncpg, urllib.request, json, re, os, requests
|
||||||
|
|
||||||
|
# --- KONFIGURASJON ---
|
||||||
|
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
||||||
|
WP_API_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100&_embed"
|
||||||
|
MEDIA_ENDPOINT = "https://teeoff.no/wp-json/wp/v2/media"
|
||||||
|
MEDIA_DIR = "./public/media"
|
||||||
|
|
||||||
|
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||||
|
media_cache = {}
|
||||||
|
|
||||||
|
def get_url_from_id(media_id):
|
||||||
|
if not media_id or not isinstance(media_id, int): return None
|
||||||
|
if media_id in media_cache: return media_cache[media_id]
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{MEDIA_ENDPOINT}/{media_id}", timeout=10)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
url = resp.json().get('source_url')
|
||||||
|
media_cache[media_id] = url
|
||||||
|
return url
|
||||||
|
except: return None
|
||||||
|
|
||||||
|
def download_media(url, slug, prefix):
|
||||||
|
if not isinstance(url, str) or not url: return None
|
||||||
|
clean_url = url.replace("https:///", "https://").replace("http:///", "http://")
|
||||||
|
if "teeoff.no" not in clean_url: return clean_url
|
||||||
|
try:
|
||||||
|
ext = clean_url.split('.')[-1].split('?')[0].lower()
|
||||||
|
if len(ext) > 4 or len(ext) < 3: ext = "jpg"
|
||||||
|
filename = f"{prefix}_{slug}.{ext}"
|
||||||
|
filepath = os.path.join(MEDIA_DIR, filename)
|
||||||
|
if os.path.exists(filepath): return f"/media/{filename}"
|
||||||
|
response = requests.get(clean_url, timeout=15)
|
||||||
|
if response.status_code == 200:
|
||||||
|
with open(filepath, 'wb') as f: f.write(response.content)
|
||||||
|
return f"/media/{filename}"
|
||||||
|
except: pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def decode_html(text):
|
||||||
|
if not text: return ""
|
||||||
|
return str(text).replace('&', '&').replace('&', '&').replace(' ', ' ').strip()
|
||||||
|
|
||||||
|
def parse_int(val):
|
||||||
|
if val is None or val == '': return None
|
||||||
|
try:
|
||||||
|
nums = re.findall(r'\d+', str(val))
|
||||||
|
return int(nums[0]) if nums else None
|
||||||
|
except: return None
|
||||||
|
|
||||||
|
def extract_url(val):
|
||||||
|
if isinstance(val, dict): return val.get('url')
|
||||||
|
if isinstance(val, str): return val
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def run_master_import():
|
||||||
|
print("🚀 Starter MASTER IMPORT v9.2 (Robust datakonvertering & Banetype)...")
|
||||||
|
conn = await asyncpg.connect(DB_URL)
|
||||||
|
|
||||||
|
# Tømmer kun courses og holes (hjelpetabeller)
|
||||||
|
await conn.execute("TRUNCATE courses, holes RESTART IDENTITY CASCADE;")
|
||||||
|
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(f"{WP_API_URL}&page={page}", headers={'User-Agent': 'TeeOff-V9.2'})
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
data = json.loads(response.read().decode())
|
||||||
|
except: break
|
||||||
|
if not data: break
|
||||||
|
|
||||||
|
for post in data:
|
||||||
|
acf = post.get('acf', {})
|
||||||
|
slug = post['slug']
|
||||||
|
name = decode_html(post.get('title', {}).get('rendered', ''))
|
||||||
|
print(f"📦 Mapper {name}...")
|
||||||
|
|
||||||
|
# Media & Identifiers
|
||||||
|
local_main_img = download_media(post.get('_embedded', {}).get('wp:featuredmedia', [{}])[0].get('source_url'), slug, "main")
|
||||||
|
local_logo = download_media(get_url_from_id(acf.get('logo')) if isinstance(acf.get('logo'), int) else extract_url(acf.get('logo')), slug, "logo")
|
||||||
|
|
||||||
|
# Galleri
|
||||||
|
slides = acf.get('slides') or []
|
||||||
|
local_gallery = [download_media(get_url_from_id(s) if isinstance(s, int) else extract_url(s), f"{slug}_{i}", "slide") for i, s in enumerate(slides)]
|
||||||
|
local_gallery = [url for url in local_gallery if url]
|
||||||
|
|
||||||
|
# Golfbox
|
||||||
|
booking_id = acf.get('golfbox_booking_id')
|
||||||
|
gb_booking_url = f"http://www.golfbox.no/site/system/redirect.asp?locale=nb_NO&rUrl=%2Fsite%2Fressources%2Fbooking%2Fgrid.asp%3FRessource_GUID%3D%{{{str(booking_id).strip().replace('{','').replace('}','')}}}" if booking_id else None
|
||||||
|
|
||||||
|
# --- UPSERT FACILITY ---
|
||||||
|
# Merk: $16 (status_updated_at) pakkes nå inn i TO_DATE for å unngå krasj
|
||||||
|
await conn.execute('''
|
||||||
|
INSERT INTO facilities (
|
||||||
|
name, slug, description, address, city, county, established_year, season,
|
||||||
|
email, phone, website_url, image_url, logo_url, video_url,
|
||||||
|
amenities, status_updated_at, gallery, banetype,
|
||||||
|
ngf_number, golfbox_club_id, golfbox_booking_url,
|
||||||
|
facebook_url, instagram_url, baneguide_url, flyfoto_url,
|
||||||
|
golfbox_tournament_url, footnote, social_links, webcam_url,
|
||||||
|
weather_url, architect,
|
||||||
|
navn_standard_medlemskap, standard_medlemskap, standard_medlemskap_kommentarer,
|
||||||
|
navn_rimeligste_alternativ, rimeligste_alternativ, rimeligste_alternativ_kommentarer,
|
||||||
|
medlemskap_url
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb,
|
||||||
|
TO_DATE(NULLIF($16, ''), 'YYYYMMDD'),
|
||||||
|
$17::jsonb, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28::jsonb,
|
||||||
|
$29, $30, $31, $32, $33, $34, $35, $36, $37, $38
|
||||||
|
)
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
address = EXCLUDED.address,
|
||||||
|
city = EXCLUDED.city,
|
||||||
|
phone = EXCLUDED.phone,
|
||||||
|
email = EXCLUDED.email,
|
||||||
|
website_url = EXCLUDED.website_url,
|
||||||
|
image_url = EXCLUDED.image_url,
|
||||||
|
logo_url = EXCLUDED.logo_url,
|
||||||
|
amenities = EXCLUDED.amenities,
|
||||||
|
gallery = EXCLUDED.gallery,
|
||||||
|
status_updated_at = EXCLUDED.status_updated_at,
|
||||||
|
banetype = EXCLUDED.banetype,
|
||||||
|
architect = EXCLUDED.architect
|
||||||
|
''', name, slug, decode_html(acf.get('beskrivelse')), acf.get('gateadresse'), acf.get('postnummer_og_poststed'), acf.get('fylke'), parse_int(acf.get('byggear')), acf.get('sesong'), acf.get('e-post'), acf.get('telefon'), extract_url(acf.get('hjemmeside')), local_main_img, local_logo, None, json.dumps({"drivingrange": decode_html(acf.get("drivingrange")), "treningsgreen": decode_html(acf.get("treningsgreen")), "proshop": decode_html(acf.get("proshop")), "kafe": decode_html(acf.get("kafe")), "bilutleie": decode_html(acf.get("bilutleie")), "kolleutleie": decode_html(acf.get("kolleutleie")), "pro": decode_html(acf.get("pro")), "simulator": decode_html(acf.get("golfsimulator")), "antall_hull": decode_html(acf.get("antall_hull"))}),
|
||||||
|
acf.get('dato_for_oppdatert_status'), # $16
|
||||||
|
json.dumps(local_gallery), decode_html(acf.get('banetype')),
|
||||||
|
parse_int(acf.get('klubbnummer_norges_golfforbund')), parse_int(acf.get('klubbnummer_golfbox')), gb_booking_url, extract_url(acf.get('facebook_url')), extract_url(acf.get('instagram_url')), extract_url(acf.get('baneguide')), extract_url(acf.get('flyfoto')), extract_url(acf.get('golfbox')), decode_html(acf.get('fotnote')), json.dumps(acf.get('sosiale_lenker') or []), decode_html(acf.get('webkamera')), extract_url(acf.get('varmelding_yr')), decode_html(acf.get('arkitekt')), decode_html(acf.get('navn_standard_medlemskap')), parse_int(acf.get('standard_medlemskap')), decode_html(acf.get('standard_medlemskap_kommentarer')), decode_html(acf.get('navn_rimeligste_alternativ')), parse_int(acf.get('rimeligste_alternativ')), decode_html(acf.get('rimeligste_alternativ_kommentarer')), extract_url(acf.get('medlemskap_url')))
|
||||||
|
|
||||||
|
fac_id = (await conn.fetchrow("SELECT id FROM facilities WHERE slug = $1", slug))['id']
|
||||||
|
|
||||||
|
# Baner og Hull
|
||||||
|
fac_main_len = 0
|
||||||
|
for suffix in ['', '_bane_to']:
|
||||||
|
c_name = acf.get('navn_pa_hovedbane' if suffix == '' else 'navn_pa_sekundar_bane') or ('Hovedbanen' if suffix == '' else 'Bane 2')
|
||||||
|
status = acf.get('banestatus' if suffix == '' else 'banestatus_sekundar_bane')
|
||||||
|
if suffix == '_bane_to' and (status == 'finnes_ingen_bane_to' or not parse_int(acf.get('hull_1_par_bane_to'))): continue
|
||||||
|
course_id = await conn.fetchval('INSERT INTO courses (facility_id, name, status, par, is_main_course, tee_boxes, architect) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id', fac_id, c_name, status, parse_int(acf.get('totalt_par' if suffix == '' else 'totalt_par_bane_to')), (suffix == ''), json.dumps({"herrer": acf.get(f"utslag_herrer{suffix}"), "damer": acf.get(f"utslag_damer{suffix}")}), decode_html(acf.get('arkitekt')))
|
||||||
|
curr_len = 0
|
||||||
|
for h_num in range(1, 19):
|
||||||
|
p = parse_int(acf.get(f'hull_{h_num}_par{suffix}'))
|
||||||
|
if p:
|
||||||
|
idx = parse_int(acf.get(f'hull_{h_num}_index{suffix}'))
|
||||||
|
lens = {k: parse_int(acf.get(f'{k}_hull_{h_num}{suffix}')) for k in ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest']}
|
||||||
|
curr_len += (lens['lengst'] or 0)
|
||||||
|
await conn.execute('INSERT INTO holes (course_id, hole_number, par, hcp_index, lengths) VALUES ($1, $2, $3, $4, $5::jsonb)', course_id, h_num, p, idx, json.dumps(lens))
|
||||||
|
await conn.execute("UPDATE courses SET length_meters = $1 WHERE id = $2", curr_len, course_id)
|
||||||
|
if suffix == '': fac_main_len = curr_len
|
||||||
|
await conn.execute("UPDATE facilities SET length_meters = $1 WHERE id = $2", fac_main_len, fac_id)
|
||||||
|
|
||||||
|
page += 1
|
||||||
|
await conn.close()
|
||||||
|
print("✅ IMPORT FERDIG!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(run_master_import())
|
||||||
95
kode_eksport_1/backend_main_py.txt
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
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):
|
||||||
|
try:
|
||||||
|
app.state.pool = await asyncpg.create_pool(DB_URL, min_size=5, max_size=20)
|
||||||
|
print("✅ Database tilkoblet og pool opprettet")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Databasefeil: {e}")
|
||||||
|
raise e
|
||||||
|
yield
|
||||||
|
await app.state.pool.close()
|
||||||
|
|
||||||
|
app = FastAPI(title="TeeOff API v2.4", 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)
|
||||||
|
|
||||||
|
# 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():
|
||||||
|
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):
|
||||||
|
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)
|
||||||
|
return format_row(row)
|
||||||
110
kode_eksport_1/backend_scrape_golfamore1_2_py.txt
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
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())
|
||||||
124
kode_eksport_1/backend_scrape_golfamore1_3_py.txt
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import asyncio
|
||||||
|
import asyncpg
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
||||||
|
|
||||||
|
# Data hentet direkte fra bildet du sendte
|
||||||
|
GOLFAMORE_DATA = {
|
||||||
|
"borre": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 14, 19, 20, 21",
|
||||||
|
"nesfjellet": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
|
||||||
|
"vradal": "Kortet er gyldig alle dager, ikke uke 28, 29, 30, 31",
|
||||||
|
"alta": "Kortet er gyldig alle dager",
|
||||||
|
"elverum": "Kortet er gyldig hverdager (ikke helligdager)",
|
||||||
|
"gronmo": "Kortet er gyldig alle dager",
|
||||||
|
"notteroy": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
|
||||||
|
"roros": "Kortet er gyldig alle dager",
|
||||||
|
"stiklestad": "Kortet er gyldig alle dager",
|
||||||
|
"arendalomegn": "Kortet er gyldig alle dager, ikke uke 27, 28, 29, 30",
|
||||||
|
"northcape": "Kortet er gyldig alle dager",
|
||||||
|
"trysil": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 28, 29, 30, 31",
|
||||||
|
"mork": "Kortet er gyldig hverdager (ikke helligdager)",
|
||||||
|
"norsjo": "Kortet er gyldig alle dager",
|
||||||
|
"ringerike": "Kortet er gyldig alle dager",
|
||||||
|
"stord": "Kortet er gyldig alle dager",
|
||||||
|
"sunnmore": "Kortet er gyldig alle dager",
|
||||||
|
"bodogolfparksalten": "Kortet er gyldig alle dager",
|
||||||
|
"drammen": "Kortet er gyldig alle dager",
|
||||||
|
"gjoviktoten": "Kortet er gyldig alle dager",
|
||||||
|
"grenlandomegn": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
|
||||||
|
"nes09": "Kortet er gyldig alle dager, ikke uke 15, 16, 17, 18",
|
||||||
|
"romerike": "Kortet er gyldig alle dager",
|
||||||
|
"bamble": "Kortet er gyldig alle dager",
|
||||||
|
"bleik": "Kortet er gyldig alle dager",
|
||||||
|
"krokhol": "Kortet er gyldig alle dager",
|
||||||
|
"skjeberg": "Kortet er gyldig hverdager (ikke helligdager)",
|
||||||
|
"utsikten": "Kortet er gyldig hverdager (ikke helligdager), ikke uke 27, 28, 29, 30",
|
||||||
|
"eiker": "Kortet er gyldig alle dager",
|
||||||
|
"hafjell": "Kortet er gyldig alle dager",
|
||||||
|
"mandal": "Kortet er gyldig alle dager, ikke uke 27, 28, 29, 30",
|
||||||
|
"mjosen": "Kortet er gyldig alle dager",
|
||||||
|
"randsfjorden": "Kortet er gyldig alle dager",
|
||||||
|
"ski": "Kortet er gyldig alle dager",
|
||||||
|
"bjornefjorden": "Kortet er gyldig alle dager",
|
||||||
|
"sande": "Kortet er gyldig alle dager",
|
||||||
|
"haugesund": "Kortet er gyldig alle dager",
|
||||||
|
"midttroms": "Kortet er gyldig alle dager",
|
||||||
|
"skei": "Kortet er gyldig hverdager (ikke helligdager)",
|
||||||
|
"sorknes": "Kortet er gyldig alle dager",
|
||||||
|
"gjerdrum": "Kortet er gyldig alle dager",
|
||||||
|
"herdla": "Kortet er gyldig alle dager",
|
||||||
|
"hovden": "Kortet er gyldig alle dager",
|
||||||
|
"oppdal": "Kortet er gyldig alle dager",
|
||||||
|
"gjersjoen": "Kortet er gyldig alle dager",
|
||||||
|
"ogna": "Kortet er gyldig alle dager",
|
||||||
|
"tonsberg": "Kortet er gyldig alle dager",
|
||||||
|
"ullensaker": "Kortet er gyldig alle dager",
|
||||||
|
"hof": "Kortet er gyldig hverdager (ikke helligdager)",
|
||||||
|
"klabu": "Kortet er gyldig alle dager",
|
||||||
|
"hemsedal": "Kortet er gyldig alle dager",
|
||||||
|
"narvik": "Kortet er gyldig alle dager",
|
||||||
|
"norefjell": "Kortet er gyldig hverdager (ikke helligdager)",
|
||||||
|
"austratt": "Kortet er gyldig alle dager",
|
||||||
|
"hammerfest": "Kortet er gyldig alle dager",
|
||||||
|
"helgeland": "Kortet er gyldig alle dager",
|
||||||
|
"jaren": "Kortet er gyldig alle dager",
|
||||||
|
"namdal": "Kortet er gyldig alle dager",
|
||||||
|
"namsos": "Kortet er gyldig alle dager",
|
||||||
|
"nordfjord": "Kortet er gyldig alle dager",
|
||||||
|
"polarsirkelen": "Kortet er gyldig alle dager",
|
||||||
|
"sandnesbarheim": "Kortet er gyldig alle dager",
|
||||||
|
"steinkjer": "Kortet er gyldig alle dager",
|
||||||
|
"varanger": "Kortet er gyldig alle dager"
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean(text):
|
||||||
|
if not text: return ""
|
||||||
|
# Fjerner alt som ikke er bokstaver/tall for matching
|
||||||
|
s = text.lower().replace("golfklubb", "").replace("gk", "").replace(" og ", "").replace("&", "").strip()
|
||||||
|
return re.sub(r'[^a-z0-9]', '', s)
|
||||||
|
|
||||||
|
async def update_golfamore():
|
||||||
|
print("\n🚀 OPPDATERER GOLFAMORE FRA BILDE-DATA...")
|
||||||
|
conn = await asyncpg.connect(DB_URL)
|
||||||
|
facilities = await conn.fetch("SELECT id, name FROM facilities")
|
||||||
|
|
||||||
|
# Lag et vasket map av bilde-dataen
|
||||||
|
image_data_clean = {clean(name): val for name, val in GOLFAMORE_DATA.items()}
|
||||||
|
|
||||||
|
matches = 0
|
||||||
|
for fac in facilities:
|
||||||
|
fac_id = fac['id']
|
||||||
|
fac_name = fac['name']
|
||||||
|
fac_clean = clean(fac_name)
|
||||||
|
|
||||||
|
validity = None
|
||||||
|
# Prøv eksakt match først
|
||||||
|
if fac_clean in image_data_clean:
|
||||||
|
validity = image_data_clean[fac_clean]
|
||||||
|
else:
|
||||||
|
# Prøv delvis match (f.eks "Arendal" i "Arendal & Omegn")
|
||||||
|
for key, val in image_data_clean.items():
|
||||||
|
if len(fac_clean) > 4 and (fac_clean in key or key in fac_clean):
|
||||||
|
validity = val
|
||||||
|
break
|
||||||
|
|
||||||
|
if validity:
|
||||||
|
print(f"✅ Match funnet: {fac_name}")
|
||||||
|
ga_data = {"validity": validity}
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE facilities
|
||||||
|
SET golfamore = true, golfamore_data = $1
|
||||||
|
WHERE id = $2
|
||||||
|
""", json.dumps(ga_data), fac_id)
|
||||||
|
matches += 1
|
||||||
|
else:
|
||||||
|
# Hvis den ikke er i listen fra bildet, sett til false
|
||||||
|
await conn.execute("UPDATE facilities SET golfamore = false, golfamore_data = '{}' WHERE id = $1", fac_id)
|
||||||
|
|
||||||
|
await conn.close()
|
||||||
|
print(f"\n🎉 Ferdig! {matches} baner ble oppdatert med Golfamore-info.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(update_golfamore())
|
||||||
100
kode_eksport_1/backend_scrape_golfamore_py.txt
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
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'<loc>(https://www.golfamore.com/no/golfklubb/.*?/)</loc>', 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())
|
||||||
96
kode_eksport_1/backend_scrape_nsg_3_py.txt
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import asyncio
|
||||||
|
import asyncpg
|
||||||
|
import httpx
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
|
||||||
|
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
||||||
|
|
||||||
|
def clean_name(text):
|
||||||
|
if not text: return ""
|
||||||
|
s = text.lower().replace("golfklubb", "").replace("gk", "").replace("par3golf", "").replace(" & ", "").strip()
|
||||||
|
return re.sub(r'[^a-z]', '', s)
|
||||||
|
|
||||||
|
def clean_nsg_content(text):
|
||||||
|
"""Fjerner doble linjeskift og kutter teksten før websidemenyen starter"""
|
||||||
|
if not text: return ""
|
||||||
|
# Fjern alt som ligner på bunn-menyen til NSG
|
||||||
|
garbage_phrases = [
|
||||||
|
"Klubbens hjemmeside", "Resultatlister i Golfbox", "Livescoring",
|
||||||
|
"Scoreinntasting", "Lagserie", "Turneringer", "Innmelding"
|
||||||
|
]
|
||||||
|
for phrase in garbage_phrases:
|
||||||
|
text = text.split(phrase)[0]
|
||||||
|
|
||||||
|
# Rydd opp i linjeskift og doble mellomrom
|
||||||
|
text = text.replace('\r', '').replace('\n', ' ')
|
||||||
|
text = re.sub(r'\s+', ' ', text).strip()
|
||||||
|
return text
|
||||||
|
|
||||||
|
async def get_nsg_links(client):
|
||||||
|
links = []
|
||||||
|
urls = ["https://seniorgolf.no/lojalitetskort-sitemap.xml", "https://seniorgolf.no/fordelskortet/"]
|
||||||
|
for url in urls:
|
||||||
|
try:
|
||||||
|
resp = await client.get(url)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
if ".xml" in url:
|
||||||
|
found = re.findall(r'<loc>(https://seniorgolf.no/lojalitetskort/.*?/)</loc>', resp.text)
|
||||||
|
if found: return list(set(found))
|
||||||
|
else:
|
||||||
|
soup = BeautifulSoup(resp.text, 'html.parser')
|
||||||
|
links.extend([l['href'] for l in soup.select('a[href*="/lojalitetskort/"]')])
|
||||||
|
except: continue
|
||||||
|
return list(set(links))
|
||||||
|
|
||||||
|
async def scrape_nsg():
|
||||||
|
print("🚀 Starter NSG VASKEMASKIN v3.8...")
|
||||||
|
conn = await asyncpg.connect(DB_URL)
|
||||||
|
facilities = await conn.fetch("SELECT id, name FROM facilities")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=20.0, headers={'User-Agent': 'Mozilla/5.0'}) as client:
|
||||||
|
all_nsg_links = await get_nsg_links(client)
|
||||||
|
link_map = {clean_name(l.split('/')[-2].replace('-', ' ')): l for l in all_nsg_links}
|
||||||
|
|
||||||
|
matches_found = 0
|
||||||
|
for fac in facilities:
|
||||||
|
fac_name_clean = clean_name(fac['name'])
|
||||||
|
match_url = link_map.get(fac_name_clean)
|
||||||
|
|
||||||
|
if not match_url:
|
||||||
|
for slug, url in link_map.items():
|
||||||
|
if fac_name_clean in slug or slug in fac_name_clean:
|
||||||
|
match_url = url
|
||||||
|
break
|
||||||
|
|
||||||
|
if match_url:
|
||||||
|
try:
|
||||||
|
f_resp = await client.get(match_url)
|
||||||
|
f_soup = BeautifulSoup(f_resp.text, 'html.parser')
|
||||||
|
|
||||||
|
# Finn hovedinnholdet i stedet for hele siden for å unngå menyer
|
||||||
|
main_content = f_soup.find('div', {'class': 'entry-content'}) or f_soup
|
||||||
|
text = main_content.get_text()
|
||||||
|
|
||||||
|
st = re.search(r"Starttider:?\s*(.*?)(?=Greenfee|Booking|Adresse|Kontakt|$)", text, re.S | re.I)
|
||||||
|
gf = re.search(r"Greenfee:?\s*(.*?)(?=Booking|Adresse|Kontakt|$)", text, re.S | re.I)
|
||||||
|
bk = re.search(r"Booking:?\s*(.*?)(?=Adresse|Kontakt|$)", text, re.S | re.I)
|
||||||
|
|
||||||
|
nsg_data = {
|
||||||
|
"url": match_url,
|
||||||
|
"starttider": clean_nsg_content(st.group(1)) if st else "Se nettside",
|
||||||
|
"greenfee": clean_nsg_content(gf.group(1)) if gf else "Se nettside",
|
||||||
|
"booking": clean_nsg_content(bk.group(1)) if bk else "Se nettside"
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.execute("UPDATE facilities SET nsg_data = $1 WHERE id = $2", json.dumps(nsg_data), fac['id'])
|
||||||
|
print(f"✅ Vasket & Lagret: {fac['name']}")
|
||||||
|
matches_found += 1
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
await conn.close()
|
||||||
|
print(f"\n🎉 Vask ferdig! {matches_found} baner er nå 100% klare.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(scrape_nsg())
|
||||||
72
kode_eksport_1/eksport_script_py.txt
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# --- KONFIGURASJON ---
|
||||||
|
KILDE_MAPPE = "/opt/teeoff/"
|
||||||
|
EKSPORT_MAPPE = "/opt/teeoff/kode_eksport_1/"
|
||||||
|
TRE_FIL = "/opt/teeoff/fil-tre.txt"
|
||||||
|
|
||||||
|
# Filtyper vi vil kopiere
|
||||||
|
FILTYPER = ['.py', '.ts', '.tsx']
|
||||||
|
|
||||||
|
# Mapper vi IKKE vil ha med i treet eller skanne (sparer tid og rot)
|
||||||
|
IGNORER_MAPPER = ['.git', 'node_modules', '__pycache__', 'kode_eksport', '.next']
|
||||||
|
|
||||||
|
def generer_tre_og_kopier():
|
||||||
|
kilde_sti = Path(KILDE_MAPPE)
|
||||||
|
eksport_sti = Path(EKSPORT_MAPPE)
|
||||||
|
|
||||||
|
# 1. Opprett eksportmappen hvis den ikke finnes
|
||||||
|
eksport_sti.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
tre_linjer = []
|
||||||
|
kopierte_filer = 0
|
||||||
|
|
||||||
|
print("Skanner filer og genererer tre...")
|
||||||
|
|
||||||
|
# 2. Gå gjennom alle mapper og filer
|
||||||
|
for root, dirs, files in os.walk(kilde_sti):
|
||||||
|
# Fjern ignorerte mapper så vi ikke går inn i dem
|
||||||
|
dirs[:] = [d for d in dirs if d not in IGNORER_MAPPER]
|
||||||
|
|
||||||
|
# Regn ut innrykk basert på hvor dypt vi er i mappestrukturen
|
||||||
|
nivaa = root.replace(KILDE_MAPPE, '').count(os.sep)
|
||||||
|
innrykk = ' ' * 4 * nivaa
|
||||||
|
mappe_navn = os.path.basename(root)
|
||||||
|
|
||||||
|
# Legg til mappen i treet
|
||||||
|
if mappe_navn:
|
||||||
|
tre_linjer.append(f"{innrykk}📁 {mappe_navn}/")
|
||||||
|
else:
|
||||||
|
tre_linjer.append(f"📁 {kilde_sti.name}/")
|
||||||
|
|
||||||
|
sub_innrykk = ' ' * 4 * (nivaa + 1)
|
||||||
|
|
||||||
|
# 3. Gå gjennom filene i mappen
|
||||||
|
for fil in files:
|
||||||
|
tre_linjer.append(f"{sub_innrykk}📄 {fil}")
|
||||||
|
|
||||||
|
fil_sti = Path(root) / fil
|
||||||
|
|
||||||
|
# 4. Sjekk om filen har riktig endelse og skal kopieres
|
||||||
|
if fil_sti.suffix in FILTYPER:
|
||||||
|
# Lag et unikt filnavn for å unngå overskriving
|
||||||
|
relativ_sti = fil_sti.relative_to(kilde_sti)
|
||||||
|
nytt_navn = str(relativ_sti).replace(os.sep, '_').replace('.', '_') + '.txt'
|
||||||
|
ny_sti = eksport_sti / nytt_navn
|
||||||
|
|
||||||
|
# Kopier filen
|
||||||
|
shutil.copy2(fil_sti, ny_sti)
|
||||||
|
kopierte_filer += 1
|
||||||
|
|
||||||
|
# 5. Lagre filteret til tekstfilen
|
||||||
|
with open(TRE_FIL, 'w', encoding='utf-8') as f:
|
||||||
|
f.write('\n'.join(tre_linjer))
|
||||||
|
|
||||||
|
print(f"\n✅ Ferdig!")
|
||||||
|
print(f"📁 Filtre er lagret i: {TRE_FIL}")
|
||||||
|
print(f"📝 Kopierte {kopierte_filer} kodefiler til: {EKSPORT_MAPPE}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
generer_tre_og_kopier()
|
||||||
6
kode_eksport_1/frontend_next-env_d_ts.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
7
kode_eksport_1/frontend_next_config_ts.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
95
kode_eksport_1/frontend_src_app_FacilitySearch_tsx.txt
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
"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 [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 [];
|
||||||
|
const words = searchQuery.toLowerCase().trim().split(/\s+/).filter(w => w.length > 0);
|
||||||
|
|
||||||
|
return initialFacilities.map(f => {
|
||||||
|
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 blob = `${f.name} ${f.city} ${f.county} ${hasNSG ? 'nsg seniorgolf' : ''} ${hasGolfamore ? 'golfamore' : ''}`.toLowerCase();
|
||||||
|
const matches = words.every(w => blob.includes(w));
|
||||||
|
|
||||||
|
return { ...f, dist, hasNSG, hasGolfamore, matches };
|
||||||
|
})
|
||||||
|
.filter(f => f.matches)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (sortMethod === 'dist' && a.dist !== b.dist) return a.dist - b.dist;
|
||||||
|
return a.name.localeCompare(b.name, 'nb');
|
||||||
|
});
|
||||||
|
}, [searchQuery, initialFacilities, userLocation, sortMethod]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-[1400px] mx-auto px-6 py-12 relative z-40">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<button onClick={() => setSortMethod(sortMethod === 'dist' ? 'alpha' : 'dist')} className="bg-white px-6 py-3 rounded-full shadow-md text-[10px] font-black text-[#8bc34a] uppercase tracking-widest border border-gray-100">
|
||||||
|
{sortMethod === 'dist' ? "📍 Nærmeste baner først" : "🔠 Alfabetisk visning"} • {processed.length} baner
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input className="w-full p-8 rounded-[2.5rem] shadow-2xl mb-16 text-gray-900 border-none ring-1 ring-black/5 text-2xl outline-none focus:ring-4 focus:ring-[#8bc34a]/20 transition-all bg-white" placeholder='Søk baner, fylke eller "nsg"...' value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
|
||||||
|
{processed.map((f: any) => (
|
||||||
|
<Link href={`/golfbaner/${f.slug}`} key={f.id} className="bg-white rounded-[3rem] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-gray-100 flex flex-col group relative">
|
||||||
|
<div className="h-64 relative overflow-hidden bg-gray-100">
|
||||||
|
<img src={f.image_url || "/Toppbilde-standard.jpg"} className="w-full h-full object-cover transition duration-1000 group-hover:scale-105" alt={f.name} />
|
||||||
|
<div className="absolute top-6 left-6 flex flex-col gap-2">
|
||||||
|
{(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 <div key={idx} className={`${color} text-white px-4 py-2 rounded-xl text-[10px] font-black uppercase shadow-xl`}>{STATUS_MAP[s.status] || s.status}</div>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-6 right-6 flex gap-2">
|
||||||
|
{f.hasNSG && <div className="w-10 h-10 bg-blue-600 text-white rounded-xl flex items-center justify-center font-black text-xs shadow-2xl">NSG</div>}
|
||||||
|
{f.hasGolfamore && <div className="w-10 h-10 bg-[#ff5722] text-white rounded-xl flex items-center justify-center font-black text-xs shadow-2xl">G</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-10 flex flex-col flex-grow">
|
||||||
|
<h3 className="font-black text-3xl text-[#11280f] mb-2 group-hover:text-[#8bc34a] transition-colors leading-tight">{f.name}</h3>
|
||||||
|
<p className="text-gray-400 text-sm font-bold uppercase tracking-widest">{f.city} • {f.county}</p>
|
||||||
|
<div className="pt-8 mt-auto border-t border-gray-50 flex items-center gap-3">
|
||||||
|
<span className="bg-[#f1f7ed] text-[#8bc34a] px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest">{f.amenities?.antall_hull || '--'} Hull</span>
|
||||||
|
<span className="bg-gray-50 text-gray-400 px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-gray-100">{f.banetype || 'Park/Skog'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
kode_eksport_1/frontend_src_app_HeroSlider_tsx.txt
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"use client";
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
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 img = f.image_url || "";
|
||||||
|
// Filter: Unngå standardbilder og krev fungerende bilde-sti
|
||||||
|
const isRealImage = img && !img.toLowerCase().includes('standard');
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
return isRealImage && isActive;
|
||||||
|
})
|
||||||
|
.sort(() => 0.5 - Math.random()) // Tilfeldig utvalg ved last
|
||||||
|
.slice(0, 5);
|
||||||
|
}, [facilities]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sliderItems.length <= 1) return;
|
||||||
|
const t = setInterval(() => setCurrentSlide(p => (p + 1) % sliderItems.length), 6000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [sliderItems]);
|
||||||
|
|
||||||
|
if (sliderItems.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-[60vh] md:h-[75vh] w-full overflow-hidden bg-[#11280f]">
|
||||||
|
{sliderItems.map((f, i) => (
|
||||||
|
<div key={f.id} className={`absolute inset-0 transition-opacity duration-1000 ${i === currentIndex ? 'opacity-100 z-10' : 'opacity-0 z-0'}`}>
|
||||||
|
<Link href={`/golfbaner/${f.slug}`} className="block h-full relative group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-black/70 via-transparent to-black/20 z-10" />
|
||||||
|
<img src={f.image_url} alt="" className="w-full h-full object-cover transition-transform duration-[10s] group-hover:scale-110" />
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center">
|
||||||
|
<div className="max-w-[1400px] mx-auto px-6 w-full">
|
||||||
|
<div className="min-h-[120px] md:min-h-[200px] flex flex-col justify-end">
|
||||||
|
<h2 className="text-5xl md:text-8xl font-black text-white tracking-tighter drop-shadow-2xl leading-[0.9] max-w-4xl line-clamp-2">
|
||||||
|
{f.name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/90 text-lg md:text-2xl mt-4 font-bold uppercase tracking-[0.3em] drop-shadow-md">
|
||||||
|
{f.county} • {f.city}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
"use client";
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { STATUS_MAP } from "@/config/constants";
|
||||||
|
|
||||||
|
// Designerens definisjon av fargetemaer - Nå med kraftigere tints for kolonnene
|
||||||
|
const getTeeTheme = (label: string) => {
|
||||||
|
const name = label.toLowerCase();
|
||||||
|
|
||||||
|
if (name.includes("svart") || name.includes("black")) {
|
||||||
|
return { header: "bg-gray-900 text-white", col: "bg-gray-100", text: "text-gray-900" };
|
||||||
|
}
|
||||||
|
if (name.includes("hvit") || name.includes("white")) {
|
||||||
|
return { header: "bg-white text-gray-800 border border-gray-300", col: "bg-gray-50", text: "text-gray-700" };
|
||||||
|
}
|
||||||
|
if (name.includes("gul") || name.includes("yellow")) {
|
||||||
|
return { header: "bg-yellow-400 text-yellow-950", col: "bg-yellow-50", text: "text-yellow-900" };
|
||||||
|
}
|
||||||
|
if (name.includes("blå") || name.includes("bla") || name.includes("blue")) {
|
||||||
|
return { header: "bg-blue-600 text-white", col: "bg-blue-50", text: "text-blue-900" };
|
||||||
|
}
|
||||||
|
if (name.includes("rød") || name.includes("rod") || name.includes("red")) {
|
||||||
|
return { header: "bg-red-500 text-white", col: "bg-red-50", text: "text-red-900" };
|
||||||
|
}
|
||||||
|
if (name.includes("grønn") || name.includes("gronn") || name.includes("green")) {
|
||||||
|
return { header: "bg-emerald-500 text-white", col: "bg-emerald-50", text: "text-emerald-900" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEFAULT: Nøytral grå for utslag med tall (f.eks "52", "45")
|
||||||
|
return { header: "bg-gray-200 text-gray-700", col: "bg-gray-100/60", text: "text-gray-600" };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CourseDisplay({ course }: { course: any }) {
|
||||||
|
const [hcp, setHcp] = useState("15.0");
|
||||||
|
const [gender, setGender] = useState<'herrer' | 'damer'>('herrer');
|
||||||
|
const [selectedTeeIndex, setSelectedTeeIndex] = useState(0);
|
||||||
|
|
||||||
|
const allHoles = course.holes || [];
|
||||||
|
const holesOut = allHoles.filter((h: any) => h.hole_number <= 9);
|
||||||
|
const holesIn = allHoles.filter((h: any) => h.hole_number > 9);
|
||||||
|
const hasInHoles = holesIn.length > 0;
|
||||||
|
|
||||||
|
const lengthKeys = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest'];
|
||||||
|
const availableTees = course.tee_boxes?.[gender] || [];
|
||||||
|
|
||||||
|
const activeColumns = lengthKeys
|
||||||
|
.filter(k => allHoles.some((h: any) => h.lengths?.[k]))
|
||||||
|
.map((key, idx) => {
|
||||||
|
const info = availableTees[idx];
|
||||||
|
const label = info?.navn_utslag || info?.navn_utslag_damer || key.toUpperCase();
|
||||||
|
return { key, label, theme: getTeeTheme(label) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kalkulering av SpH
|
||||||
|
const activeTee = availableTees[selectedTeeIndex];
|
||||||
|
let playingHandicap = 0;
|
||||||
|
if (activeTee && hcp) {
|
||||||
|
const exactHcp = Number(hcp.replace(',', '.'));
|
||||||
|
const slope = Number(activeTee.slopeverdi || activeTee.slopeverdi_damer || 113);
|
||||||
|
const cr = Number(String(activeTee.baneverdi || activeTee.baneverdi_damer || course.par).replace(',', '.'));
|
||||||
|
playingHandicap = Math.round((exactHcp * (slope / 113)) + (cr - course.par));
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExtraStrokes = (hcpIndex: number) => {
|
||||||
|
if (!hcpIndex || isNaN(playingHandicap)) return 0;
|
||||||
|
const base = Math.floor(playingHandicap / 18);
|
||||||
|
const rem = playingHandicap % 18;
|
||||||
|
return base + (hcpIndex <= rem ? 1 : 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sumPar = (holes: any[]) => holes.reduce((acc, h) => acc + (h.par || 0), 0);
|
||||||
|
const sumLen = (holes: any[], key: string) => holes.reduce((acc, h) => acc + (h.lengths?.[key] || 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-[3rem] shadow-sm border border-gray-200 overflow-hidden mb-12">
|
||||||
|
|
||||||
|
{/* HEADER / KALKULATOR */}
|
||||||
|
<div className="p-8 md:p-12 flex flex-col md:flex-row justify-between items-center gap-8 border-b border-gray-100 bg-white">
|
||||||
|
<div className="text-center md:text-left">
|
||||||
|
<h2 className="text-5xl font-black text-[#11280f] tracking-tighter">{course.name}</h2>
|
||||||
|
<p className="text-[#7ca982] font-black uppercase text-xs tracking-[0.2em] mt-2">Par {course.par} • {course.length_meters || '--'} meter</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6 bg-gray-50 p-6 rounded-[2.5rem] border border-gray-100">
|
||||||
|
<div className="flex flex-col"><span className="text-[9px] font-black text-[#7ca982] uppercase ml-1">Kjønn</span>
|
||||||
|
<select value={gender} onChange={e => { setGender(e.target.value as any); setSelectedTeeIndex(0); }} className="bg-transparent text-[#11280f] font-black outline-none border-b-2 border-[#7ca982]/30 pb-1 cursor-pointer">
|
||||||
|
<option value="herrer">HERRER</option><option value="damer">DAMER</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col"><span className="text-[9px] font-black text-[#7ca982] uppercase ml-1">Utslag</span>
|
||||||
|
<select value={selectedTeeIndex} onChange={e => setSelectedTeeIndex(Number(e.target.value))} className="bg-transparent text-[#11280f] font-black outline-none border-b-2 border-[#7ca982]/30 pb-1 cursor-pointer">
|
||||||
|
{availableTees.map((t: any, i: number) => (<option key={i} value={i}>{t.navn_utslag || t.navn_utslag_damer}</option>))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col"><span className="text-[9px] font-black text-[#7ca982] uppercase ml-1">Ditt HCP</span>
|
||||||
|
<input type="text" value={hcp} onChange={e => setHcp(e.target.value)} className="w-12 bg-transparent text-[#11280f] font-black text-center border-b-2 border-[#7ca982]/30" />
|
||||||
|
</div>
|
||||||
|
<div className="pl-6 border-l border-gray-200 text-center">
|
||||||
|
<p className="text-[9px] uppercase font-black text-[#7ca982] mb-1">SpH</p>
|
||||||
|
<p className="text-4xl font-black text-[#11280f] leading-none">{playingHandicap || 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SCOREKORT TABELL */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-center border-collapse table-fixed min-w-[850px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-white text-[10px] text-gray-400 font-black uppercase">
|
||||||
|
<th className="w-20 p-5 text-left pl-10 border-b border-gray-100">Hull</th>
|
||||||
|
<th className="w-16 p-5 border-l border-gray-100 border-b border-gray-100">Par</th>
|
||||||
|
<th className="w-16 p-5 border-l border-gray-100 border-b border-gray-100">HCP</th>
|
||||||
|
<th className="w-24 p-5 border-l border-gray-100 border-b border-gray-100 bg-[#7ca982]/10 text-[#7ca982]">Mottatt</th>
|
||||||
|
<th className="w-24 p-5 border-l border-gray-100 border-b border-gray-100 bg-[#7ca982]/20 text-[#11280f]">Din Par</th>
|
||||||
|
{activeColumns.map((col, i) => (
|
||||||
|
<th key={i} className={`p-5 border-l border-white font-black ${col.theme.header}`}>{col.label}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="font-bold text-[#11280f]">
|
||||||
|
{/* UT-RUNDE */}
|
||||||
|
{holesOut.map((h: any) => {
|
||||||
|
const extra = getExtraStrokes(h.hcp_index);
|
||||||
|
return (
|
||||||
|
<tr key={h.id} className="border-t border-gray-100 group hover:bg-white transition-colors">
|
||||||
|
<td className="p-4 text-left pl-10 font-black text-lg text-gray-800">{h.hole_number}</td>
|
||||||
|
<td className="p-4 border-l border-gray-100 bg-white">{h.par}</td>
|
||||||
|
<td className="p-4 border-l border-gray-100 text-gray-300 text-xs font-mono">{h.hcp_index}</td>
|
||||||
|
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/5 text-[#7ca982] font-mono">{extra > 0 ? `+${extra}` : '-'}</td>
|
||||||
|
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/10 text-lg font-mono">{h.par + extra}</td>
|
||||||
|
{activeColumns.map((col, i) => (
|
||||||
|
<td key={i} className={`p-4 border-l border-white font-mono transition-all ${col.theme.col} ${col.theme.text}`}>
|
||||||
|
{h.lengths?.[col.key] || '--'}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* UT RAD */}
|
||||||
|
<tr className="bg-[#f1f7ed]/50 text-[#11280f] font-black border-y border-gray-200">
|
||||||
|
<td className="p-4 text-left pl-10 uppercase tracking-widest text-[10px] text-gray-400">Ut</td>
|
||||||
|
<td className="p-4 border-l border-gray-100">{sumPar(holesOut)}</td>
|
||||||
|
<td colSpan={3} className="border-l border-gray-100 bg-white"></td>
|
||||||
|
{activeColumns.map((col, i) => (
|
||||||
|
<td key={i} className={`p-4 border-l border-white font-mono ${col.theme.col} text-gray-900`}>{sumLen(holesOut, col.key)}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* INN-RUNDE */}
|
||||||
|
{hasInHoles && holesIn.map((h: any) => {
|
||||||
|
const extra = getExtraStrokes(h.hcp_index);
|
||||||
|
return (
|
||||||
|
<tr key={h.id} className="border-t border-gray-100 group hover:bg-white transition-colors">
|
||||||
|
<td className="p-4 text-left pl-10 font-black text-lg text-gray-800">{h.hole_number}</td>
|
||||||
|
<td className="p-4 border-l border-gray-100 bg-white">{h.par}</td>
|
||||||
|
<td className="p-4 border-l border-gray-100 text-gray-300 text-xs font-mono">{h.hcp_index}</td>
|
||||||
|
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/5 text-[#7ca982] font-mono">{extra > 0 ? `+${extra}` : '-'}</td>
|
||||||
|
<td className="p-4 border-l border-gray-100 bg-[#7ca982]/10 text-lg font-mono">{h.par + extra}</td>
|
||||||
|
{activeColumns.map((col, i) => (
|
||||||
|
<td key={i} className={`p-4 border-l border-white font-mono transition-all ${col.theme.col} ${col.theme.text}`}>
|
||||||
|
{h.lengths?.[col.key] || '--'}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* INN RAD */}
|
||||||
|
{hasInHoles && (
|
||||||
|
<tr className="bg-[#f1f7ed]/50 text-[#11280f] font-black border-y border-gray-200">
|
||||||
|
<td className="p-4 text-left pl-10 uppercase tracking-widest text-[10px] text-gray-400">Inn</td>
|
||||||
|
<td className="p-4 border-l border-gray-100">{sumPar(holesIn)}</td>
|
||||||
|
<td colSpan={3} className="border-l border-gray-100 bg-white"></td>
|
||||||
|
{activeColumns.map((col, i) => (
|
||||||
|
<td key={i} className={`p-4 border-l border-white font-mono ${col.theme.col} text-gray-900`}>{sumLen(holesIn, col.key)}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TOTAL RAD */}
|
||||||
|
<tr className="bg-[#11280f] text-white text-xl font-black">
|
||||||
|
<td className="p-8 text-left pl-10 uppercase tracking-tighter">Totalt</td>
|
||||||
|
<td className="p-8 border-l border-white/10">{sumPar(allHoles)}</td>
|
||||||
|
<td colSpan={3} className="border-l border-white/10 bg-[#1a3a17]"></td>
|
||||||
|
{activeColumns.map((col, i) => (
|
||||||
|
<td key={i} className={`p-8 border-l border-white/10 font-mono ${col.theme.header.split(' ')[0]}`}>
|
||||||
|
{sumLen(allHoles, col.key)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
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 Icon = ({ children, className = "w-5 h-5" }: { children: React.ReactNode, className?: string }) => (
|
||||||
|
<svg className={`${className} flex-shrink-0`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">{children}</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
web: <><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></>,
|
||||||
|
phone: <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />,
|
||||||
|
mail: <><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" /><polyline points="22,6 12,13 2,6" /></>,
|
||||||
|
map: <><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" /><circle cx="12" cy="10" r="3" /></>,
|
||||||
|
booking: <><rect x="3" y="4" width="18" height="18" rx="2" ry="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" /></>,
|
||||||
|
trophy: <><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" /><path d="M18 9h1.5a2.5 2.5 0 0 1 0-5H18" /><path d="M4 22h16" /><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" /><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" /><path d="M18 2H6v7a6 6 0 0 0 12 0V2z" /></>,
|
||||||
|
guide: <><polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21" /><line x1="9" y1="3" x2="9" y2="18" /><line x1="15" y1="6" x2="15" y2="21" /></>,
|
||||||
|
camera: <><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" /><circle cx="12" cy="13" r="4" /></>,
|
||||||
|
webcam: <><circle cx="12" cy="12" r="8" /><circle cx="12" cy="12" r="3" /><line x1="12" y1="2" x2="12" y2="4" /><line x1="12" y1="20" x2="12" y2="22" /><line x1="20" y1="12" x2="22" y2="12" /><line x1="2" y1="12" x2="4" y2="12" /></>,
|
||||||
|
weather: <path d="M17.5 19c2.5 0 4.5-2 4.5-4.5 0-2.4-1.8-4.3-4.2-4.5C17.3 6.1 13.5 3 9 3 5.2 3 2 5.8 2 9.5a5.5 5.5 0 0 0 5.5 5.5h10" />,
|
||||||
|
facebook: <path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />,
|
||||||
|
instagram: <><rect x="2" y="2" width="20" height="20" rx="5" ry="5" /><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" /><line x1="17.5" y1="6.5" x2="17.51" y2="6.5" /></>
|
||||||
|
};
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gallery.length <= 1) return;
|
||||||
|
const timer = setInterval(() => setCurrentSlide((p) => (p + 1) % gallery.length), 5000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [gallery.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => setShowBackToTop(window.scrollY > 500);
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollTo = (id: string) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) window.scrollTo({ top: el.getBoundingClientRect().top + window.pageYOffset - 80, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('nb-NO', { day: 'numeric', month: 'long', year: 'numeric' }) : null;
|
||||||
|
|
||||||
|
const weatherImg = facility.weather_url?.replace("/graf/dag/", "/innhold/").replace(/\/$/, "") + "/meteogram.svg";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[#f1f7ed] pb-20 relative font-sans text-[#11280f]">
|
||||||
|
|
||||||
|
<div className="h-[55vh] min-h-[450px] relative overflow-hidden bg-[#11280f]">
|
||||||
|
{gallery.map((img: string, i: number) => (
|
||||||
|
<img key={i} src={img} className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-1000 ${i === currentSlide ? 'opacity-100 z-10' : 'opacity-0 z-0'}`} alt="" />
|
||||||
|
))}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-[#11280f]/90 via-transparent to-black/10 z-20" />
|
||||||
|
|
||||||
|
<div className="absolute top-8 right-8 z-40 flex flex-col items-end gap-1">
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
{activeCourses.map((c: any) => (
|
||||||
|
<span key={c.id} className="px-3 py-1.5 rounded-lg text-[10px] font-black uppercase bg-[#7ca982] text-white shadow-xl">{STATUS_MAP[c.status] || c.status}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{facility.status_updated_at && <span className="text-white/60 text-[10px] uppercase font-bold tracking-widest mt-1">Sist oppdatert: {formatDate(facility.status_updated_at)}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-8 right-8 z-40 flex gap-2.5 bg-black/30 backdrop-blur-md p-2 rounded-2xl border border-white/10 shadow-2xl">
|
||||||
|
{facility.website_url && <a href={facility.website_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center text-[#11280f] hover:text-orange-600 transition-all"><Icon children={ICONS.web} /></a>}
|
||||||
|
{facility.golfbox_booking_url && <a href={facility.golfbox_booking_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center text-[#11280f] hover:text-orange-600 transition-all"><Icon children={ICONS.booking} /></a>}
|
||||||
|
{facility.golfbox_tournament_url && <a href={facility.golfbox_tournament_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center text-[#11280f] hover:text-orange-600 transition-all"><Icon children={ICONS.trophy} /></a>}
|
||||||
|
<a href={`https://www.google.com/maps/search/?api=1&query=${facility.lat},${facility.lng}`} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center text-[#11280f] hover:text-orange-600 transition-all"><Icon children={ICONS.map} /></a>
|
||||||
|
{facility.weather_url && <a href={facility.weather_url} target="_blank" className="w-9 h-9 bg-white rounded-xl flex items-center justify-center text-[#11280f] hover:text-orange-600 transition-all"><Icon children={ICONS.weather} /></a>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-30 max-w-[1200px] mx-auto px-6 w-full h-full flex flex-col justify-end pb-12 text-center md:text-left">
|
||||||
|
{facility.logo_url && <div className="hidden md:block mb-8 w-24 h-24 bg-white p-2 rounded-2xl shadow-2xl border-4 border-white/20 overflow-hidden"><img src={facility.logo_url} className="w-full h-full object-contain" alt="" /></div>}
|
||||||
|
<h1 className="text-5xl md:text-8xl font-black text-white mb-3 tracking-tighter drop-shadow-2xl">{facility.name}</h1>
|
||||||
|
<p className="text-[#7ca982] uppercase tracking-[0.4em] font-black text-xs md:text-sm pl-1">{facility.county} • {facility.city}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="sticky top-0 z-50 bg-white/95 backdrop-blur-md border-b border-gray-100 shadow-sm overflow-hidden">
|
||||||
|
<div className="max-w-[1200px] mx-auto px-6 flex justify-between md:justify-start gap-4 md:gap-10 h-16 items-center text-[10px] font-black uppercase tracking-widest text-gray-400">
|
||||||
|
<button onClick={() => scrollTo('intro')}>Info</button>
|
||||||
|
<button onClick={() => scrollTo('weather')}>Vær</button>
|
||||||
|
<button onClick={() => scrollTo('details')}>Detaljer</button>
|
||||||
|
<button onClick={() => scrollTo('map')}>Kart</button>
|
||||||
|
<button onClick={() => scrollTo('video')}>Video</button>
|
||||||
|
<button onClick={() => scrollTo('prices')}>Priser</button>
|
||||||
|
<button onClick={() => scrollTo('scorecards')}>Scorekort</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="max-w-[1200px] mx-auto px-0 md:px-6 space-y-4 md:space-y-12 mt-0 md:mt-12">
|
||||||
|
|
||||||
|
<section id="intro" className="flex flex-col lg:flex-row gap-0 md:gap-8 items-stretch">
|
||||||
|
<div className="lg:w-[22%] bg-white p-10 md:rounded-[3rem] shadow-sm flex flex-col border-b md:border-none">
|
||||||
|
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-8">Kontakt & Adresse</h3>
|
||||||
|
<div className="space-y-6 text-sm font-bold">
|
||||||
|
<a href={facility.website_url} target="_blank" className={`flex items-center gap-4 ${linkClass}`}><Icon children={ICONS.web} /> Besøk nettsiden</a>
|
||||||
|
<a href={`tel:${facility.phone}`} className="flex items-center gap-4"><Icon children={ICONS.phone} /> {facility.phone || 'Ikke oppgitt'}</a>
|
||||||
|
<a href={`mailto:${facility.email}`} className="flex items-center gap-4 truncate"><Icon children={ICONS.mail} /> {facility.email || 'Ikke oppgitt'}</a>
|
||||||
|
<a href={`https://www.google.com/maps/search/?api=1&query=${facility.lat},${facility.lng}`} target="_blank" className="flex items-start gap-4 leading-tight hover:text-orange-600 transition-colors pt-2"><Icon children={ICONS.map} className="mt-1" /> <span>{facility.address}<br/>{facility.city}</span></a>
|
||||||
|
<div className="pt-6 border-t border-gray-50 flex gap-5">
|
||||||
|
{facility.facebook_url && <a href={facility.facebook_url} target="_blank" className="hover:text-orange-600 transition-all"><Icon children={ICONS.facebook} className="w-6 h-6" /></a>}
|
||||||
|
{facility.instagram_url && <a href={facility.instagram_url} target="_blank" className="hover:text-orange-600 transition-all"><Icon children={ICONS.instagram} className="w-6 h-6" /></a>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lg:w-[78%] bg-white p-10 md:p-16 md:rounded-[3rem] shadow-sm border-b md:border-none">
|
||||||
|
{facility.footnote && <div className="mb-8 pb-8 border-b border-gray-50 italic text-gray-400 text-lg leading-relaxed font-serif">{facility.footnote}</div>}
|
||||||
|
<div className="leading-relaxed text-lg md:text-xl text-gray-600" dangerouslySetInnerHTML={{ __html: facility.description }} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="weather" className="bg-white p-0 md:p-12 md:rounded-[3rem] shadow-sm border-b md:border-none overflow-hidden text-center">
|
||||||
|
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-[0.2em] py-8 md:py-0 md:mb-10 flex items-center justify-center gap-3"><Icon children={ICONS.weather} /> Vær for {facility.name}</h3>
|
||||||
|
<div className="w-full flex justify-center px-4 md:px-0">
|
||||||
|
{facility.weather_url ? ( <img src={weatherImg} className="w-full h-auto block max-w-5xl" alt="Vær" /> ) : <p className="text-center py-24 text-gray-300 italic text-sm">Værvarsel ikke tilgjengelig</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="details" className="flex flex-col lg:flex-row gap-4 md:gap-8">
|
||||||
|
<div className="lg:w-[22%] bg-white p-10 md:rounded-[3rem] shadow-sm border-b md:border-none">
|
||||||
|
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter">Andre ressurser</h3>
|
||||||
|
<div className="space-y-2.5 text-[#11280f]">
|
||||||
|
{facility.golfbox_booking_url && <a href={facility.golfbox_booking_url} target="_blank" className="flex justify-between items-center p-4 bg-gray-50 rounded-2xl text-[11px] font-black uppercase hover:bg-orange-600 hover:text-white transition-all"><span><Icon children={ICONS.booking} className="inline mr-3" /> Book Starttid</span><span>→</span></a>}
|
||||||
|
{facility.golfbox_tournament_url && <a href={facility.golfbox_tournament_url} target="_blank" className="flex justify-between items-center p-4 bg-gray-50 rounded-2xl text-[11px] font-black uppercase hover:bg-orange-600 hover:text-white transition-all"><span><Icon children={ICONS.trophy} className="inline mr-3" /> Turneringer</span><span>→</span></a>}
|
||||||
|
{facility.baneguide_url && <a href={facility.baneguide_url} target="_blank" className="flex justify-between items-center p-4 bg-gray-50 rounded-2xl text-[11px] font-black uppercase hover:bg-orange-600 hover:text-white transition-all"><span><Icon children={ICONS.guide} className="inline mr-3" /> Baneguide</span><span>→</span></a>}
|
||||||
|
{facility.flyfoto_url && <a href={facility.flyfoto_url} target="_blank" className="flex justify-between items-center p-4 bg-gray-50 rounded-2xl text-[11px] font-black uppercase hover:bg-orange-600 hover:text-white transition-all"><span><Icon children={ICONS.camera} className="inline mr-3" /> Flyfoto</span><span>→</span></a>}
|
||||||
|
{facility.webcam_url && <a href={facility.webcam_url} target="_blank" className="flex justify-between items-center p-4 bg-gray-50 rounded-2xl text-[11px] font-black uppercase hover:bg-orange-600 hover:text-white transition-all"><span><Icon children={ICONS.webcam} className="inline mr-3" /> Webkamera</span><span>→</span></a>}
|
||||||
|
{shotzoom.map((sz: any, i: number) => (
|
||||||
|
<a key={i} href={sz.shotzoom_url} target="_blank" title="Statistikk fra Shotzoom" className="flex justify-between items-center p-4 bg-gray-50 rounded-2xl text-[11px] font-black uppercase text-orange-600 hover:bg-orange-600 hover:text-white transition-all border border-orange-50 shadow-sm"><span>📊 Statistikk: {sz.shotzoom_beskrivelse?.replace(' ', ' ')}</span><span>→</span></a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:w-[78%] grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8 text-sm font-bold text-gray-700">
|
||||||
|
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm border-b md:border-none">
|
||||||
|
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter text-[#11280f]">Banen</h3>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400 font-medium">Hull:</span><span>{amenities.antall_hull || '--'}</span></div>
|
||||||
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400 font-medium">Lengde:</span><span>{facility.length_meters ? `${facility.length_meters}m` : '--'}</span></div>
|
||||||
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400 font-medium">Sesong:</span><span>{facility.season || '--'}</span></div>
|
||||||
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400 font-medium">Byggeår:</span><span>{facility.established_year || '--'}</span></div>
|
||||||
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400 font-medium">Banetype:</span><span>{facility.banetype || 'Park/Skog'}</span></div>
|
||||||
|
<div className="flex justify-between pb-1"><span className="text-gray-400 font-medium">Arkitekt:</span><span className="text-right truncate ml-4">{facility.architect || '--'}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm border-b md:border-none">
|
||||||
|
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter text-[#11280f]">Fasiliteter</h3>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400 font-medium">Drivingrange:</span><span>{amenities.drivingrange || 'Nei'}</span></div>
|
||||||
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400 font-medium">Nærspill:</span><span>Ja</span></div>
|
||||||
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400 font-medium">Proshop:</span><span className="text-right ml-4" dangerouslySetInnerHTML={{ __html: amenities.proshop ? String(amenities.proshop).replace('Ja', `<span class="${linkClass}">Ja</span>`) : 'Nei' }} /></div>
|
||||||
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400 font-medium">Kølleutleie:</span><span>{amenities.kolleutleie || 'Nei'}</span></div>
|
||||||
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400 font-medium">Simulator:</span><span className="text-right ml-4" dangerouslySetInnerHTML={{ __html: amenities.simulator ? String(amenities.simulator).replace('Ja', `<span class="${linkClass}">Ja</span>`) : 'Nei' }} /></div>
|
||||||
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400 font-medium">Head Pro:</span><span className="text-orange-600 text-right ml-4 font-bold" dangerouslySetInnerHTML={{ __html: amenities.pro || '--' }} /></div>
|
||||||
|
<div className="flex justify-between pb-1"><span className="text-gray-400 font-medium">Kafé:</span><span className="text-orange-600 text-right ml-4 font-bold" dangerouslySetInnerHTML={{ __html: amenities.kafe || '--' }} /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="map" className="space-y-6">
|
||||||
|
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Kart <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
|
||||||
|
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-xl h-[450px] md:h-[650px] border-y-4 md:border-[12px] border-white bg-gray-100"><iframe width="100%" height="100%" style={{ border: 0 }} src={`https://maps.google.com/maps?q=${facility.lat},${facility.lng}&t=k&z=15&ie=UTF8&iwloc=&output=embed`} allowFullScreen /></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{facility.video_url && (
|
||||||
|
<section id="video" className="space-y-6">
|
||||||
|
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Video <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
|
||||||
|
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-2xl aspect-video bg-black border-y-4 md:border-[12px] border-white"><iframe src={facility.video_url} className="w-full h-full" allowFullScreen /></div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section id="prices" className="grid grid-cols-1 lg:grid-cols-2 gap-0 md:gap-8">
|
||||||
|
<div className="bg-white p-10 md:p-14 md:rounded-[3rem] shadow-sm border-b md:border-none">
|
||||||
|
<h3 className="text-2xl font-black mb-10 uppercase tracking-tighter flex items-center gap-4"><span>⛳</span> Gjestespill</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.isArray(facility.greenfee) && facility.greenfee.length > 0 ? (
|
||||||
|
facility.greenfee.map((g: any, i: number) => (
|
||||||
|
<div key={i} className="flex justify-between py-4 border-b border-gray-50 font-bold text-lg"><span className="text-gray-400 font-medium">{g.priskategori}</span><span className="text-[#11280f]">kr {g.pris_voksne || '--'},-</span></div>
|
||||||
|
))
|
||||||
|
) : <p className="text-gray-400 italic py-6">Ingen priser funnet.</p>}
|
||||||
|
</div>
|
||||||
|
<p className="mt-10 text-[10px] text-gray-300 font-black uppercase tracking-widest">Krav: {facility.guest_requirements || 'Klubbhandicap'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-10 md:p-14 md:rounded-[3rem] shadow-sm border-b md:border-none flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-black mb-10 uppercase tracking-tighter flex items-center gap-4"><span>💛</span> Medlemskap</h3>
|
||||||
|
<div className="bg-[#f1f7ed] p-12 md:rounded-[2.5rem] mb-8 text-center border border-[#7ca982]/10">
|
||||||
|
<p className="text-[#7ca982] text-[11px] font-black uppercase mb-3 tracking-widest">{facility.navn_standard_medlemskap || "Standard"}</p>
|
||||||
|
<p className="text-6xl md:text-7xl font-black text-[#11280f] tracking-tighter">kr {facility.standard_medlemskap || '--'},-</p>
|
||||||
|
{facility.standard_medlemskap_kommentarer && <p className="text-[11px] text-gray-400 mt-5 uppercase font-bold italic tracking-tight leading-relaxed">{facility.standard_medlemskap_kommentarer}</p>}
|
||||||
|
</div>
|
||||||
|
{facility.navn_rimeligste_alternativ && (
|
||||||
|
<div className="px-8 py-5 bg-gray-50 rounded-2xl border border-gray-100 flex justify-between items-center text-sm font-bold shadow-sm"><span className="text-gray-400 uppercase text-[10px] tracking-widest">{facility.navn_rimeligste_alternativ}</span><span className="text-[#11280f]">kr {facility.rimeligste_alternativ},-</span></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<a href={facility.medlemskap_url} target="_blank" className="mt-10 block w-full text-center bg-[#11280f] text-white p-7 rounded-2xl font-black uppercase text-xs tracking-widest hover:bg-black transition-all shadow-xl">Se alle alternativer</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="scorecards" className="pt-10 space-y-20 overflow-hidden">
|
||||||
|
<h3 className="text-center text-3xl md:text-5xl font-black uppercase tracking-tighter">Scorekort</h3>
|
||||||
|
<div className="w-full flex flex-col items-center gap-20">
|
||||||
|
{activeCourses.map((c: any) => (
|
||||||
|
<div key={c.id} className="w-full overflow-x-auto md:overflow-visible no-scrollbar">
|
||||||
|
<div className="min-w-[800px] md:min-w-0"><CourseDisplay course={c} /></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showBackToTop && ( <button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className="fixed bottom-8 right-8 w-14 h-14 bg-[#11280f] text-white rounded-full shadow-2xl flex items-center justify-center text-2xl z-[100] hover:scale-110 transition-all border-4 border-white/20">↑</button> )}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
// page.tsx
|
||||||
|
import { API_URL } from "@/config/constants";
|
||||||
|
import FacilityDetailView from "./FacilityDetailView";
|
||||||
|
|
||||||
|
export default async function GolfCoursePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: 'no-store' });
|
||||||
|
const facility = await res.json();
|
||||||
|
|
||||||
|
if (!facility || facility.error) {
|
||||||
|
return <div className="p-20 text-center font-bold text-2xl">Fant ikke golfbanen...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vi sender dataene til den navngitte komponenten
|
||||||
|
return <FacilityDetailView facility={facility} />;
|
||||||
|
}
|
||||||
19
kode_eksport_1/frontend_src_app_layout_tsx.txt
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "TeeOff.no - Din guide til norske golfbaner",
|
||||||
|
description: "Oppdatert banestatus, priser og informasjon om alle norske golfanlegg.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="nb">
|
||||||
|
<body className="antialiased bg-[#f1f7ed]">
|
||||||
|
<Header />
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
kode_eksport_1/frontend_src_app_page_tsx.txt
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import HeroSlider from './HeroSlider';
|
||||||
|
import FacilitySearch from './FacilitySearch';
|
||||||
|
import { API_URL } from '@/config/constants';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
let facilities = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/facilities`, {
|
||||||
|
next: { revalidate: 0 },
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json();
|
||||||
|
console.error("API Error Body:", errorData);
|
||||||
|
throw new Error(`API returnerte status ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
facilities = await res.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Kritisk feil ved henting av data:", error);
|
||||||
|
facilities = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sikrer at vi alltid sender en array til komponentene
|
||||||
|
const safeData = Array.isArray(facilities) ? facilities : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[#f1f7ed]">
|
||||||
|
<HeroSlider facilities={safeData} />
|
||||||
|
<FacilitySearch initialFacilities={safeData} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
kode_eksport_1/frontend_src_components_Header_tsx.txt
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
"use client";
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-[100] bg-white/95 backdrop-blur-md border-b border-gray-100 shadow-sm">
|
||||||
|
<div className="max-w-[1400px] mx-auto px-6 h-20 flex items-center justify-between">
|
||||||
|
|
||||||
|
{/* LOGO */}
|
||||||
|
<Link href="/" className="h-10 md:h-12 transition-transform hover:scale-105 active:scale-95">
|
||||||
|
<img src="/TeeOff-logo-Retina-1.png" alt="TeeOff.no" className="h-full w-auto object-contain" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* DESKTOP NAV */}
|
||||||
|
<nav className="hidden md:flex items-center gap-8 text-[11px] font-black uppercase tracking-widest text-gray-500">
|
||||||
|
<Link href="/" className="hover:text-[#8bc34a]">Hjem</Link>
|
||||||
|
<Link href="/golfbaner" className="hover:text-[#8bc34a]">Finn Bane</Link>
|
||||||
|
<Link href="/medlemskap" className="hover:text-[#8bc34a]">Medlemskap</Link>
|
||||||
|
<Link href="/om-oss" className="hover:text-[#8bc34a]">Om oss</Link>
|
||||||
|
<Link href="/logg-inn" className="px-5 py-2 bg-[#ff5722] text-white rounded-xl hover:bg-[#e64a19] transition-all">Admin</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* HAMBURGER (Mobil) */}
|
||||||
|
<button onClick={() => setIsOpen(!isOpen)} className="md:hidden p-2 text-[#11280f]">
|
||||||
|
<div className="w-6 h-0.5 bg-current mb-1.5 transition-all"></div>
|
||||||
|
<div className="w-6 h-0.5 bg-current mb-1.5"></div>
|
||||||
|
<div className="w-6 h-0.5 bg-current"></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MOBIL MENY OVERLAY */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="md:hidden absolute top-20 left-0 w-full bg-white border-b border-gray-100 p-6 flex flex-col gap-6 shadow-2xl animate-in slide-in-from-top duration-300">
|
||||||
|
<Link onClick={() => setIsOpen(false)} href="/" className="text-lg font-black uppercase text-[#11280f]">Hjem</Link>
|
||||||
|
<Link onClick={() => setIsOpen(false)} href="/golfbaner" className="text-lg font-black uppercase text-[#11280f]">Finn Bane</Link>
|
||||||
|
<Link onClick={() => setIsOpen(false)} href="/medlemskap" className="text-lg font-black uppercase text-[#11280f]">Medlemskap</Link>
|
||||||
|
<Link onClick={() => setIsOpen(false)} href="/logg-inn" className="text-[#ff5722] font-black uppercase">Admin Logg inn</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
kode_eksport_1/frontend_src_config_constants_ts.txt
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// 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<string, string> = {
|
||||||
|
"ukjent": "Ukjent status",
|
||||||
|
"aapen": "Åpen",
|
||||||
|
"aapen_med_vintergreener": "Vintergreener",
|
||||||
|
"stengt": "Stengt",
|
||||||
|
"nedlagt": "Nedlagt",
|
||||||
|
"under_utvikling": "Under utvikling",
|
||||||
|
"aapner_snart": "Åpner snart",
|
||||||
|
"stenger_snart": "Stenger snart"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REGIONS: Record<string, string[]> = {
|
||||||
|
"nord-norge": ["finnmark", "troms", "nordland"],
|
||||||
|
"midt-norge": ["nord-trøndelag", "sør-trøndelag", "trøndelag"],
|
||||||
|
"vestlandet": ["møre og romsdal", "sogn og fjordane", "hordaland", "rogaland", "vestland"],
|
||||||
|
"sørlandet": ["vest-agder", "aust-agder", "agder"],
|
||||||
|
"østlandet": ["telemark", "vestfold", "østfold", "buskerud", "hedmark", "oppland", "oslo", "akershus", "innlandet", "viken"]
|
||||||
|
};
|
||||||
549
struktur1_dump.txt
Normal file
|
|
@ -0,0 +1,549 @@
|
||||||
|
--
|
||||||
|
-- PostgreSQL database dump
|
||||||
|
--
|
||||||
|
|
||||||
|
-- Dumped from database version 15.8 (Debian 15.8-1.pgdg110+1)
|
||||||
|
-- Dumped by pg_dump version 15.8 (Debian 15.8-1.pgdg110+1)
|
||||||
|
|
||||||
|
SET statement_timeout = 0;
|
||||||
|
SET lock_timeout = 0;
|
||||||
|
SET idle_in_transaction_session_timeout = 0;
|
||||||
|
SET client_encoding = 'UTF8';
|
||||||
|
SET standard_conforming_strings = on;
|
||||||
|
SELECT pg_catalog.set_config('search_path', '', false);
|
||||||
|
SET check_function_bodies = false;
|
||||||
|
SET xmloption = content;
|
||||||
|
SET client_min_messages = warning;
|
||||||
|
SET row_security = off;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tiger; Type: SCHEMA; Schema: -; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SCHEMA tiger;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER SCHEMA tiger OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tiger_data; Type: SCHEMA; Schema: -; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SCHEMA tiger_data;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER SCHEMA tiger_data OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: topology; Type: SCHEMA; Schema: -; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SCHEMA topology;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER SCHEMA topology OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: SCHEMA topology; Type: COMMENT; Schema: -; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
COMMENT ON SCHEMA topology IS 'PostGIS Topology schema';
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: fuzzystrmatch; Type: EXTENSION; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS fuzzystrmatch WITH SCHEMA public;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: EXTENSION fuzzystrmatch; Type: COMMENT; Schema: -; Owner:
|
||||||
|
--
|
||||||
|
|
||||||
|
COMMENT ON EXTENSION fuzzystrmatch IS 'determine similarities and distance between strings';
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: postgis; Type: EXTENSION; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis WITH SCHEMA public;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: EXTENSION postgis; Type: COMMENT; Schema: -; Owner:
|
||||||
|
--
|
||||||
|
|
||||||
|
COMMENT ON EXTENSION postgis IS 'PostGIS geometry and geography spatial types and functions';
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: postgis_tiger_geocoder; Type: EXTENSION; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder WITH SCHEMA tiger;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: EXTENSION postgis_tiger_geocoder; Type: COMMENT; Schema: -; Owner:
|
||||||
|
--
|
||||||
|
|
||||||
|
COMMENT ON EXTENSION postgis_tiger_geocoder IS 'PostGIS tiger geocoder and reverse geocoder';
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: postgis_topology; Type: EXTENSION; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis_topology WITH SCHEMA topology;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: EXTENSION postgis_topology; Type: COMMENT; Schema: -; Owner:
|
||||||
|
--
|
||||||
|
|
||||||
|
COMMENT ON EXTENSION postgis_topology IS 'PostGIS topology spatial types and functions';
|
||||||
|
|
||||||
|
|
||||||
|
SET default_tablespace = '';
|
||||||
|
|
||||||
|
SET default_table_access_method = heap;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: courses; Type: TABLE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.courses (
|
||||||
|
id integer NOT NULL,
|
||||||
|
facility_id integer,
|
||||||
|
name character varying(255) NOT NULL,
|
||||||
|
holes integer,
|
||||||
|
par integer,
|
||||||
|
length_meters integer,
|
||||||
|
course_type character varying(255),
|
||||||
|
architect character varying(255),
|
||||||
|
status character varying(255),
|
||||||
|
is_main_course boolean DEFAULT true,
|
||||||
|
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
tee_boxes jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.courses OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: courses_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.courses_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.courses_id_seq OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: courses_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.courses_id_seq OWNED BY public.courses.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facilities; Type: TABLE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.facilities (
|
||||||
|
id integer NOT NULL,
|
||||||
|
name character varying(255) NOT NULL,
|
||||||
|
slug character varying(255) NOT NULL,
|
||||||
|
description text,
|
||||||
|
established_year integer,
|
||||||
|
season character varying(255),
|
||||||
|
address character varying(255),
|
||||||
|
zipcode character varying(50),
|
||||||
|
city character varying(255),
|
||||||
|
county character varying(255),
|
||||||
|
lat double precision,
|
||||||
|
lng double precision,
|
||||||
|
email character varying(255),
|
||||||
|
phone character varying(255),
|
||||||
|
website_url character varying(255),
|
||||||
|
golfbox_booking_url character varying(255),
|
||||||
|
golfbox_tournament_url character varying(255),
|
||||||
|
facebook_url character varying(255),
|
||||||
|
instagram_url character varying(255),
|
||||||
|
weather_url character varying(255),
|
||||||
|
webcam_url character varying(255),
|
||||||
|
golfamore boolean DEFAULT false,
|
||||||
|
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
image_url character varying(500),
|
||||||
|
amenities jsonb,
|
||||||
|
greenfee jsonb,
|
||||||
|
architect text,
|
||||||
|
membership jsonb,
|
||||||
|
vtg jsonb,
|
||||||
|
video_url text,
|
||||||
|
baneguide_url text,
|
||||||
|
logo_url text,
|
||||||
|
flyfoto_url text,
|
||||||
|
guest_requirements text,
|
||||||
|
status_updated_at date,
|
||||||
|
gallery jsonb,
|
||||||
|
faqs jsonb DEFAULT '[]'::jsonb,
|
||||||
|
shotzoom jsonb DEFAULT '[]'::jsonb,
|
||||||
|
front_image_url text,
|
||||||
|
nsg_url text,
|
||||||
|
nsg_description text,
|
||||||
|
nsg_data jsonb DEFAULT '{}'::jsonb,
|
||||||
|
golfamore_data jsonb DEFAULT '{}'::jsonb,
|
||||||
|
ngf_number integer,
|
||||||
|
golfbox_club_id integer,
|
||||||
|
golfbox_booking_id text,
|
||||||
|
facebook_id text,
|
||||||
|
instagram_place_id text,
|
||||||
|
tournament_url text,
|
||||||
|
footnote text,
|
||||||
|
social_links jsonb DEFAULT '[]'::jsonb,
|
||||||
|
webcam_html text,
|
||||||
|
length_meters integer,
|
||||||
|
navn_standard_medlemskap text,
|
||||||
|
standard_medlemskap integer,
|
||||||
|
standard_medlemskap_kommentarer text,
|
||||||
|
navn_rimeligste_alternativ text,
|
||||||
|
rimeligste_alternativ integer,
|
||||||
|
rimeligste_alternativ_kommentarer text,
|
||||||
|
medlemskap_url text,
|
||||||
|
banetype text
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.facilities OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facilities_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.facilities_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.facilities_id_seq OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facilities_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.facilities_id_seq OWNED BY public.facilities.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facility_images; Type: TABLE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.facility_images (
|
||||||
|
id integer NOT NULL,
|
||||||
|
facility_id integer,
|
||||||
|
image_url character varying(255) NOT NULL,
|
||||||
|
display_order integer DEFAULT 0,
|
||||||
|
sort_order integer DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.facility_images OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facility_images_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.facility_images_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.facility_images_id_seq OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facility_images_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.facility_images_id_seq OWNED BY public.facility_images.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: hole_lengths; Type: TABLE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.hole_lengths (
|
||||||
|
id integer NOT NULL,
|
||||||
|
hole_id integer,
|
||||||
|
tee_id integer,
|
||||||
|
length_meters integer
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.hole_lengths OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: hole_lengths_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.hole_lengths_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.hole_lengths_id_seq OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: hole_lengths_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.hole_lengths_id_seq OWNED BY public.hole_lengths.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: holes; Type: TABLE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.holes (
|
||||||
|
id integer NOT NULL,
|
||||||
|
course_id integer,
|
||||||
|
hole_number integer NOT NULL,
|
||||||
|
par integer,
|
||||||
|
hcp_index integer,
|
||||||
|
lengths jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.holes OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: holes_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.holes_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.holes_id_seq OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: holes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.holes_id_seq OWNED BY public.holes.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tees; Type: TABLE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.tees (
|
||||||
|
id integer NOT NULL,
|
||||||
|
course_id integer,
|
||||||
|
name character varying(50) NOT NULL,
|
||||||
|
cr_men numeric(4,1),
|
||||||
|
slope_men integer,
|
||||||
|
cr_women numeric(4,1),
|
||||||
|
slope_women integer
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.tees OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tees_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.tees_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.tees_id_seq OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tees_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.tees_id_seq OWNED BY public.tees.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: courses id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.courses ALTER COLUMN id SET DEFAULT nextval('public.courses_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facilities id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.facilities ALTER COLUMN id SET DEFAULT nextval('public.facilities_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facility_images id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.facility_images ALTER COLUMN id SET DEFAULT nextval('public.facility_images_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: hole_lengths id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.hole_lengths ALTER COLUMN id SET DEFAULT nextval('public.hole_lengths_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: holes id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.holes ALTER COLUMN id SET DEFAULT nextval('public.holes_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tees id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.tees ALTER COLUMN id SET DEFAULT nextval('public.tees_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: courses courses_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.courses
|
||||||
|
ADD CONSTRAINT courses_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facilities facilities_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.facilities
|
||||||
|
ADD CONSTRAINT facilities_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facilities facilities_slug_key; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.facilities
|
||||||
|
ADD CONSTRAINT facilities_slug_key UNIQUE (slug);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facility_images facility_images_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.facility_images
|
||||||
|
ADD CONSTRAINT facility_images_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: hole_lengths hole_lengths_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.hole_lengths
|
||||||
|
ADD CONSTRAINT hole_lengths_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: holes holes_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.holes
|
||||||
|
ADD CONSTRAINT holes_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tees tees_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.tees
|
||||||
|
ADD CONSTRAINT tees_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facilities unique_slug; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.facilities
|
||||||
|
ADD CONSTRAINT unique_slug UNIQUE (slug);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: courses courses_facility_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.courses
|
||||||
|
ADD CONSTRAINT courses_facility_id_fkey FOREIGN KEY (facility_id) REFERENCES public.facilities(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: hole_lengths hole_lengths_hole_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.hole_lengths
|
||||||
|
ADD CONSTRAINT hole_lengths_hole_id_fkey FOREIGN KEY (hole_id) REFERENCES public.holes(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: hole_lengths hole_lengths_tee_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.hole_lengths
|
||||||
|
ADD CONSTRAINT hole_lengths_tee_id_fkey FOREIGN KEY (tee_id) REFERENCES public.tees(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: holes holes_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.holes
|
||||||
|
ADD CONSTRAINT holes_course_id_fkey FOREIGN KEY (course_id) REFERENCES public.courses(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tees tees_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.tees
|
||||||
|
ADD CONSTRAINT tees_course_id_fkey FOREIGN KEY (course_id) REFERENCES public.courses(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- PostgreSQL database dump complete
|
||||||
|
--
|
||||||
|
|
||||||