Første commit av teeoff
15
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Bruk en lettvekts-versjon av Python
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Sett arbeidsmappen inne i containeren
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Kopier filen med avhengigheter og installer dem
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Kopier selve koden vår
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Kommandoen som starter serveren når containeren starter
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
111
backend/import_gallery.py
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())
|
||||||
118
backend/import_wp.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import asyncio, asyncpg, urllib.request, json, re
|
||||||
|
|
||||||
|
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
||||||
|
WP_API_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100&_embed"
|
||||||
|
|
||||||
|
def decode_html(text):
|
||||||
|
if not text: return ""
|
||||||
|
return str(text).replace('&', '&').replace('&', '&').replace(' ', ' ').strip()
|
||||||
|
|
||||||
|
def parse_int(val):
|
||||||
|
if val is None or val == '': return None
|
||||||
|
try:
|
||||||
|
nums = re.findall(r'\d+', str(val))
|
||||||
|
return int(nums[0]) if nums else None
|
||||||
|
except: return None
|
||||||
|
|
||||||
|
async def run_master_import():
|
||||||
|
print("🚀 Starter MASTER IMPORT v6.0 (ACF Mapped)...")
|
||||||
|
conn = await asyncpg.connect(DB_URL)
|
||||||
|
await conn.execute("TRUNCATE facilities, courses, holes RESTART IDENTITY CASCADE;")
|
||||||
|
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(f"{WP_API_URL}&page={page}", headers={'User-Agent': 'TeeOff-V6'})
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
data = json.loads(response.read().decode())
|
||||||
|
except: break
|
||||||
|
if not data: break
|
||||||
|
|
||||||
|
for post in data:
|
||||||
|
acf = post.get('acf', {})
|
||||||
|
name = decode_html(post.get('title', {}).get('rendered', ''))
|
||||||
|
print(f"📦 Mapper {name}...")
|
||||||
|
|
||||||
|
# 1. Medlemskap (Mappet mot field_6040...)
|
||||||
|
membership = {
|
||||||
|
"url": acf.get('medlemskap_url'),
|
||||||
|
"standard": {
|
||||||
|
"navn": decode_html(acf.get('navn_standard_medlemskap')),
|
||||||
|
"pris": parse_int(acf.get('standard_medlemskap')),
|
||||||
|
"kommentar": decode_html(acf.get('standard_medlemskap_kommentarer'))
|
||||||
|
},
|
||||||
|
"rimeligste": {
|
||||||
|
"navn": decode_html(acf.get('navn_rimeligste_alternativ')),
|
||||||
|
"pris": parse_int(acf.get('rimeligste_alternativ')),
|
||||||
|
"kommentar": decode_html(acf.get('rimeligste_alternativ_kommentarer'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Greenfee (Repeatere)
|
||||||
|
greenfee = {
|
||||||
|
"voksne": acf.get('greenfee_-_voksne') or [],
|
||||||
|
"junior": acf.get('greenfee_-_junior') or [],
|
||||||
|
"golfpakke": decode_html(acf.get('golfpakke')),
|
||||||
|
"rabattert": acf.get('rabattert_greenfee')
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Amenities (Fasiliteter)
|
||||||
|
amenities = {
|
||||||
|
"drivingrange": decode_html(acf.get("drivingrange")),
|
||||||
|
"treningsgreen": decode_html(acf.get("treningsgreen")),
|
||||||
|
"proshop": decode_html(acf.get("proshop")),
|
||||||
|
"kafe": decode_html(acf.get("kafe")),
|
||||||
|
"bilutleie": decode_html(acf.get("bilutleie")),
|
||||||
|
"pro": decode_html(acf.get("pro")),
|
||||||
|
"antall_hull": decode_html(acf.get("antall_hull"))
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Lagre Facility
|
||||||
|
fac_id = await conn.fetchval('''
|
||||||
|
INSERT INTO facilities (
|
||||||
|
name, slug, description, established_year, season, address, city, county,
|
||||||
|
lat, lng, email, phone, website_url, image_url, amenities, greenfee,
|
||||||
|
membership, vtg, status_updated_at, logo_url, video_url, guest_requirements,
|
||||||
|
faqs, shotzoom, translations
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb,
|
||||||
|
$16::jsonb, $17::jsonb, $18::jsonb, TO_DATE(NULLIF($19, ''), 'YYYYMMDD'), $20, $21, $22, $23::jsonb, $24::jsonb, $25::jsonb)
|
||||||
|
RETURNING id
|
||||||
|
''', name, post['slug'], decode_html(acf.get('beskrivelse')), parse_int(acf.get('byggear')), acf.get('sesong'),
|
||||||
|
acf.get('gateadresse'), acf.get('postnummer_og_poststed'), acf.get('fylke'),
|
||||||
|
float(acf.get('banekart', {}).get('lat', 0)) or None, float(acf.get('banekart', {}).get('lng', 0)) or None,
|
||||||
|
acf.get('e-post'), acf.get('telefon'), acf.get('hjemmeside'),
|
||||||
|
post.get('_embedded', {}).get('wp:featuredmedia', [{}])[0].get('source_url'),
|
||||||
|
json.dumps(amenities), json.dumps(greenfee), json.dumps(membership), json.dumps(acf.get('vtg') or {}),
|
||||||
|
acf.get('dato_for_oppdatert_status'), acf.get('logo'),
|
||||||
|
f"https://youtube.com/embed/{acf.get('videopresentasjon_youtube')}" if acf.get('videopresentasjon_youtube') else None,
|
||||||
|
decode_html(acf.get('krav_til_gjestespillere')), json.dumps(acf.get('faq') or []), json.dumps(acf.get('shotzoom') or []), json.dumps({}))
|
||||||
|
|
||||||
|
# 5. Baner og Hull (Bruker ACF-felt for Hovedbane og Bane 2)
|
||||||
|
for suffix in ['', '_bane_to']:
|
||||||
|
course_name = acf.get('navn_pa_hovedbane' if suffix == '' else 'navn_pa_sekundar_bane') or ('Hovedbane' if suffix == '' else 'Bane 2')
|
||||||
|
status = acf.get('banestatus' if suffix == '' else 'banestatus_sekundar_bane')
|
||||||
|
|
||||||
|
# Sjekk om det i det hele tatt finnes data for denne banen
|
||||||
|
if suffix == '_bane_to' and (status == 'finnes_ingen_bane_to' or not parse_int(acf.get('hull_1_par_bane_to'))):
|
||||||
|
continue
|
||||||
|
|
||||||
|
course_id = await conn.fetchval('''
|
||||||
|
INSERT INTO courses (facility_id, name, status, par, length_meters, is_main_course, tee_boxes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) RETURNING id
|
||||||
|
''', fac_id, course_name, status, parse_int(acf.get('totalt_par' if suffix == '' else 'totalt_par_bane_to')),
|
||||||
|
parse_int(acf.get('lengde' if suffix == '' else 'lengst_totalt_bane_to')), (suffix == ''),
|
||||||
|
json.dumps({"herrer": acf.get(f"utslag_herrer{suffix}"), "damer": acf.get(f"utslag_damer{suffix}")}))
|
||||||
|
|
||||||
|
for h_num in range(1, 19):
|
||||||
|
p = parse_int(acf.get(f'hull_{h_num}_par{suffix}'))
|
||||||
|
if p:
|
||||||
|
idx = parse_int(acf.get(f'hull_{h_num}_index{suffix}'))
|
||||||
|
lengths = {k: parse_int(acf.get(f'{k}_hull_{h_num}{suffix}')) for k in ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest']}
|
||||||
|
await conn.execute('INSERT INTO holes (course_id, hole_number, par, hcp_index, lengths) VALUES ($1, $2, $3, $4, $5::jsonb)',
|
||||||
|
course_id, h_num, p, idx, json.dumps(lengths))
|
||||||
|
page += 1
|
||||||
|
await conn.close()
|
||||||
|
print("✅ Ferdig! All data er nå korrekt importert.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(run_master_import())
|
||||||
69
backend/main.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
import asyncpg
|
||||||
|
import json
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
app.state.pool = await asyncpg.create_pool(DB_URL, min_size=5, max_size=20)
|
||||||
|
yield
|
||||||
|
await app.state.pool.close()
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||||
|
|
||||||
|
def format_row(row):
|
||||||
|
if row is None: return None
|
||||||
|
d = dict(row)
|
||||||
|
for key in ['status_updated_at', 'created_at']:
|
||||||
|
if isinstance(d.get(key), (date, datetime)): d[key] = d[key].isoformat()
|
||||||
|
|
||||||
|
# JSONB vask - Mapper direkte mot kolonnene i struktur_dump.txt
|
||||||
|
json_fields = ['amenities', 'greenfee', 'membership', 'vtg', 'faqs', 'shotzoom', 'translations', 'course_statuses', 'courses', 'gallery', 'nsg_data', 'golfamore_data']
|
||||||
|
for field in json_fields:
|
||||||
|
if field in d:
|
||||||
|
if isinstance(d[field], str):
|
||||||
|
try: d[field] = json.loads(d[field])
|
||||||
|
except: pass
|
||||||
|
if d[field] is None: d[field] = [] if field in ['courses', 'faqs', 'gallery'] else {}
|
||||||
|
return d
|
||||||
|
|
||||||
|
@app.get("/api/facilities")
|
||||||
|
async def get_facilities():
|
||||||
|
async with app.state.pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch("""
|
||||||
|
SELECT f.*, (
|
||||||
|
SELECT jsonb_agg(cs) FROM (
|
||||||
|
SELECT name, status FROM courses
|
||||||
|
WHERE facility_id = f.id AND status NOT IN ('finnes_ingen_bane_to', 'nedlagt')
|
||||||
|
ORDER BY is_main_course DESC, id ASC
|
||||||
|
) cs
|
||||||
|
) as course_statuses
|
||||||
|
FROM facilities f ORDER BY f.name ASC
|
||||||
|
""")
|
||||||
|
return [format_row(row) for row in rows]
|
||||||
|
|
||||||
|
@app.get("/api/facilities/{slug}")
|
||||||
|
async def get_facility(slug: str):
|
||||||
|
async with app.state.pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("""
|
||||||
|
SELECT f.*, (
|
||||||
|
SELECT jsonb_agg(c_data) FROM (
|
||||||
|
SELECT c.*, (
|
||||||
|
SELECT jsonb_agg(h_data ORDER BY h_data.hole_number ASC)
|
||||||
|
FROM (SELECT * FROM holes WHERE course_id = c.id) h_data
|
||||||
|
) as holes
|
||||||
|
FROM courses c
|
||||||
|
WHERE c.facility_id = f.id
|
||||||
|
AND (c.is_main_course = true OR (c.status NOT IN ('finnes_ingen_bane_to', 'nedlagt', 'ukjent')))
|
||||||
|
ORDER BY c.is_main_course DESC, c.id ASC
|
||||||
|
) c_data
|
||||||
|
) as courses
|
||||||
|
FROM facilities f WHERE f.slug = $1
|
||||||
|
""", slug)
|
||||||
|
if not row: raise HTTPException(status_code=404)
|
||||||
|
return format_row(row)
|
||||||
5
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
asyncpg
|
||||||
|
httpx
|
||||||
|
beautifulsoup4
|
||||||
100
backend/scrape_golfamore.py
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())
|
||||||
110
backend/scrape_golfamore1.2.py
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
backend/scrape_golfamore1.3.py
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())
|
||||||
96
backend/scrape_nsg_3.py
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())
|
||||||
36
docker-compose.yml
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgis/postgis:15-3.4
|
||||||
|
container_name: teeoff_db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: teeoff_admin
|
||||||
|
POSTGRES_PASSWORD: teeoff_secret_password
|
||||||
|
POSTGRES_DB: teeoff
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- teeoff_db_data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: ./backend
|
||||||
|
container_name: teeoff_api
|
||||||
|
ports:
|
||||||
|
- "8001:8000"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
container_name: teeoff_frontend
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
teeoff_db_data:
|
||||||
41
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
14
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Kopier package.json og installer avhengigheter
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Kopier resten av koden
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Vi starter serveren i "dev"-modus (utviklingsmodus).
|
||||||
|
# Dette gjør at vi kan se endringer live mens vi koder!
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
36
frontend/README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
18
frontend/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
7
frontend/next.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
6592
frontend/package-lock.json
generated
Normal file
26
frontend/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "16.1.6",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.6",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
frontend/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
BIN
frontend/public/TeeOff-logo-Retina-1.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
frontend/public/Toppbilde-standard.jpg
Normal file
|
After Width: | Height: | Size: 208 KiB |
1
frontend/public/file.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
frontend/public/globe.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1 KiB |
1
frontend/public/next.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/vercel.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
frontend/public/window.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
94
frontend/src/app/FacilitySearch.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
"use client";
|
||||||
|
import { STATUS_MAP } from "@/config/constants";
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
function getDistance(lat1: number, lon1: number, lat2: number, lon2: number) {
|
||||||
|
try {
|
||||||
|
const R = 6371;
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||||
|
const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon/2) * Math.sin(dLon/2);
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
} catch (e) { return Infinity; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FacilitySearch({ initialFacilities }: { initialFacilities: any[] }) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [userLocation, setUserLocation] = useState<{ lat: number, lng: number } | null>(null);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
if ("geolocation" in navigator) {
|
||||||
|
navigator.geolocation.getCurrentPosition(p => setUserLocation({ lat: p.coords.latitude, lng: p.coords.longitude }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const processed = useMemo(() => {
|
||||||
|
if (!mounted || !Array.isArray(initialFacilities)) return [];
|
||||||
|
const words = searchQuery.toLowerCase().trim().split(/\s+/).filter(w => w.length > 0);
|
||||||
|
|
||||||
|
return initialFacilities.map(f => {
|
||||||
|
// Skuddsikker status-vask
|
||||||
|
const raw = Array.isArray(f.course_statuses) ? f.course_statuses : [];
|
||||||
|
const statuses = raw.filter((s: any) => s && s.status && s.status !== 'finnes_ingen_bane_to' && s.name !== 'Bane 2');
|
||||||
|
|
||||||
|
const dist = userLocation && f.lat && f.lng ? getDistance(userLocation.lat, userLocation.lng, f.lat, f.lng) : Infinity;
|
||||||
|
const searchBlob = `${f.name} ${f.city} ${f.county} ${statuses.map((s:any) => s.name + s.status).join(" ")}`.toLowerCase();
|
||||||
|
const matches = words.every(w => searchBlob.includes(w));
|
||||||
|
|
||||||
|
return { ...f, dist, statuses, matches };
|
||||||
|
})
|
||||||
|
.filter(f => f.matches)
|
||||||
|
.sort((a, b) => (userLocation && a.dist !== b.dist ? a.dist - b.dist : (a.name || "").localeCompare(b.name || "", 'nb')));
|
||||||
|
}, [searchQuery, initialFacilities, userLocation, mounted]);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto p-6 -mt-8 relative z-40">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<span className="text-[10px] uppercase font-black text-[#7ca982] bg-white px-4 py-1.5 rounded-full shadow-md border border-[#f1f7ed]">
|
||||||
|
{userLocation ? "GPS AKTIV" : "SORTERER ALFABETISK"} • {processed.length} BANER
|
||||||
|
</span>
|
||||||
|
</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)} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{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">
|
||||||
|
<div className="h-48 relative bg-gray-200 overflow-hidden">
|
||||||
|
<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} />
|
||||||
|
{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-4 left-4 flex flex-col items-start gap-2 z-20">
|
||||||
|
{f.statuses.map((s: any, idx: number) => {
|
||||||
|
const raw = (s.status || "ukjent").toLowerCase();
|
||||||
|
let color = "bg-gray-500 text-white";
|
||||||
|
if (raw.includes('aapen') && !raw.includes('vinter')) color = "bg-green-600 text-white";
|
||||||
|
else if (raw.includes('vinter')) color = "bg-emerald-400 text-gray-900";
|
||||||
|
else if (raw.includes('snart')) color = "bg-yellow-500 text-gray-900";
|
||||||
|
else if (raw.includes('stengt')) color = "bg-red-600 text-white";
|
||||||
|
return <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="p-8 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>
|
||||||
|
<p className="text-gray-400 text-sm font-bold uppercase tracking-widest">{f.city}{f.county ? ` • ${f.county}` : ''}</p>
|
||||||
|
<div className="pt-6 border-t border-gray-50 flex justify-between items-center mt-auto">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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>
|
||||||
|
{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>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
frontend/src/app/HeroSlider.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
"use client";
|
||||||
|
import { FALLBACK_IMAGE } from "@/config/constants";
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function HeroSlider({ facilities }: { facilities: any[] }) {
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [sliderItems, setSliderItems] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Array.isArray(facilities)) return;
|
||||||
|
|
||||||
|
const filtered = facilities.filter(f => {
|
||||||
|
// Robust status-sjekk
|
||||||
|
const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : [];
|
||||||
|
const interesting = ['aapen', 'aapen_med_vintergreener', 'aapner_snart', 'stenger_snart', 'stengt'];
|
||||||
|
const hasStatus = statuses.some((s: any) => s && s.status && interesting.includes(s.status));
|
||||||
|
|
||||||
|
// Bilde-sjekk
|
||||||
|
const img = f.front_image_url || f.image_url || "";
|
||||||
|
const isReal = img && !img.includes('Toppbilde-standard.jpg') && String(img).startsWith('http');
|
||||||
|
|
||||||
|
return hasStatus && isReal;
|
||||||
|
});
|
||||||
|
|
||||||
|
setSliderItems(filtered.sort(() => 0.5 - Math.random()).slice(0, 5));
|
||||||
|
}, [facilities]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sliderItems.length <= 1) return;
|
||||||
|
const t = setInterval(() => setCurrentIndex(p => (p + 1) % sliderItems.length), 6000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [sliderItems]);
|
||||||
|
|
||||||
|
if (sliderItems.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-[55vh] md:h-[65vh] w-full bg-[#11280f] overflow-hidden">
|
||||||
|
{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 className="absolute inset-0 bg-black/40 z-10" />
|
||||||
|
<img src={f.front_image_url || f.image_url} alt={f.name} className="w-full h-full object-cover" />
|
||||||
|
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center text-center px-4">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
frontend/src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
26
frontend/src/app/globals.css
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
196
frontend/src/app/golfbaner/[slug]/CourseDisplay.tsx
Normal file
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants";
|
||||||
|
import Link from 'next/link';
|
||||||
|
import CourseDisplay from './CourseDisplay';
|
||||||
|
|
||||||
|
const formatTel = (phone: string) => {
|
||||||
|
if (!phone) return "";
|
||||||
|
const clean = phone.replace(/\s+/g, '').replace('+', '00');
|
||||||
|
return `00${clean.replace(/^00/, '')}`; // Sikrer 0047 format
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
|
const activeCourses = (facility.courses || []).filter((c: any) => c.holes && c.holes.length > 0);
|
||||||
|
const amenities = facility.amenities || {};
|
||||||
|
const nsg = facility.nsg_data || {};
|
||||||
|
|
||||||
|
const weatherImg = facility.weather_url?.replace("/graf/dag/", "/innhold/").replace(/\/$/, "") + "/meteogram.svg";
|
||||||
|
const googleMapsUrl = `https://www.google.com/maps/search/?api=1&query=${facility.lat},${facility.lng}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[#f1f7ed] pb-20">
|
||||||
|
|
||||||
|
{/* HERO SEKSJON */}
|
||||||
|
<div className="h-[50vh] min-h-[500px] relative flex flex-col justify-end">
|
||||||
|
<img src={facility.image_url || FALLBACK_IMAGE} alt="" className="absolute inset-0 w-full h-full object-cover z-0" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-[#11280f]/90 via-transparent to-black/20 z-10" />
|
||||||
|
|
||||||
|
<div className="relative z-20 max-w-[1400px] mx-auto px-6 w-full pb-12">
|
||||||
|
<div className="flex flex-col md:flex-row items-end justify-between gap-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-6xl md:text-8xl font-black text-white mb-2 tracking-tighter drop-shadow-2xl">{facility.name}</h1>
|
||||||
|
<p className="text-[#7ca982] uppercase tracking-[0.4em] font-black text-xs md:text-sm mb-6">{facility.city} • {facility.county}</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{activeCourses.map((c: any) => (
|
||||||
|
<span key={c.id} className="px-4 py-2 rounded-lg text-[10px] font-black uppercase bg-[#7ca982] text-white shadow-xl">
|
||||||
|
{activeCourses.length === 1 ? STATUS_MAP[c.status] || c.status : `${c.name}: ${STATUS_MAP[c.status] || c.status}`}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DE 5 IKON-KNAPPENE (Gjeninnført nøyaktig) */}
|
||||||
|
<div className="flex gap-2 bg-white/10 backdrop-blur-md p-2 rounded-2xl border border-white/20 shadow-2xl">
|
||||||
|
{facility.website_url && <a href={facility.website_url} target="_blank" className="w-12 h-12 bg-white hover:bg-[#7ca982] hover:text-white text-[#11280f] rounded-xl flex items-center justify-center transition-all shadow-md text-xl">🏠</a>}
|
||||||
|
{facility.golfbox_booking_url && <a href={facility.golfbox_booking_url} target="_blank" className="w-12 h-12 bg-white hover:bg-[#7ca982] hover:text-white text-[#11280f] rounded-xl flex items-center justify-center transition-all shadow-md text-xl">🕒</a>}
|
||||||
|
{facility.golfbox_tournament_url && <a href={facility.golfbox_tournament_url} target="_blank" className="w-12 h-12 bg-white hover:bg-[#7ca982] hover:text-white text-[#11280f] rounded-xl flex items-center justify-center transition-all shadow-md text-xl">🏆</a>}
|
||||||
|
<a href={googleMapsUrl} target="_blank" className="w-12 h-12 bg-white hover:bg-[#7ca982] hover:text-white text-[#11280f] rounded-xl flex items-center justify-center transition-all shadow-md text-xl">📍</a>
|
||||||
|
{facility.weather_url && <a href={facility.weather_url} target="_blank" className="w-12 h-12 bg-white hover:bg-[#7ca982] hover:text-white text-[#11280f] rounded-xl flex items-center justify-center transition-all shadow-md text-xl">☁️</a>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-[1400px] mx-auto px-6 mt-12 grid grid-cols-12 gap-8 items-start">
|
||||||
|
|
||||||
|
{/* SIDEBAR */}
|
||||||
|
<div className="col-span-12 lg:col-span-3 space-y-6 order-2 lg:order-1">
|
||||||
|
<Link href="/" className="flex items-center justify-center bg-white p-5 rounded-2xl shadow-sm font-black text-[#7ca982] border border-gray-100 uppercase text-[10px] tracking-widest hover:bg-[#7ca982] hover:text-white transition-all">← Tilbake til kartet</Link>
|
||||||
|
|
||||||
|
<div className="bg-white p-8 rounded-[2rem] shadow-sm border border-gray-100 font-bold text-sm text-gray-700 space-y-6">
|
||||||
|
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-widest">Kontakt & Adresse</h3>
|
||||||
|
<a href={facility.website_url} target="_blank" className="block text-[#7ca982] hover:underline">🌐 Besøk nettsiden</a>
|
||||||
|
<a href={`tel:${formatTel(facility.phone)}`} className="block">📞 {facility.phone}</a>
|
||||||
|
<a href={`mailto:${facility.email}`} className="block truncate">✉️ {facility.email}</a>
|
||||||
|
<div className="pt-4 border-t border-gray-50">
|
||||||
|
<a href={googleMapsUrl} target="_blank" className="block hover:text-[#7ca982]">🏠 {facility.address || facility.city}</a>
|
||||||
|
<Link href={`/?q=${facility.county}`} className="text-[#7ca982] text-[9px] uppercase font-black hover:underline mt-4 block">Se alle baner i {facility.county} →</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* VÆR-WIDGET */}
|
||||||
|
{facility.weather_url && (
|
||||||
|
<div className="bg-white p-4 rounded-[2rem] shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<h3 className="text-[9px] font-black text-gray-300 uppercase tracking-widest mb-4 ml-4">Værvarsel (48t)</h3>
|
||||||
|
<img src={weatherImg} className="w-full scale-110" alt="Vær" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ANDRE RESSURSER */}
|
||||||
|
<div className="bg-white p-8 rounded-[2rem] shadow-sm border border-gray-100">
|
||||||
|
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-6">Andre ressurser</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{facility.flyfoto_url && <a href={facility.flyfoto_url} target="_blank" className="flex items-center justify-between p-4 bg-gray-50 rounded-xl text-[10px] font-black uppercase text-gray-500 hover:bg-[#7ca982] hover:text-white transition-all"><span>🚁 Flyfoto</span><span>→</span></a>}
|
||||||
|
{facility.webcam_url && <a href={facility.webcam_url} target="_blank" className="flex items-center justify-between p-4 bg-gray-50 rounded-xl text-[10px] font-black uppercase text-gray-500 hover:bg-[#7ca982] hover:text-white transition-all"><span>📹 Webkamera</span><span>→</span></a>}
|
||||||
|
{facility.baneguide_url && <a href={facility.baneguide_url} target="_blank" className="flex items-center justify-between p-4 bg-gray-50 rounded-xl text-[10px] font-black uppercase text-gray-500 hover:bg-[#7ca982] hover:text-white transition-all"><span>📖 Baneguide</span><span>→</span></a>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HOVEDINNHOLD */}
|
||||||
|
<div className="col-span-12 lg:col-span-9 space-y-12 order-1 lg:order-2">
|
||||||
|
<div className="bg-white p-10 md:p-16 rounded-[3rem] shadow-sm border border-gray-100 leading-relaxed text-xl text-gray-600" dangerouslySetInnerHTML={{ __html: facility.description }} />
|
||||||
|
|
||||||
|
{/* BANEDETALJER + ANDRE TILBUD (Nøyaktig som bildet) */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 font-bold text-sm text-gray-700">
|
||||||
|
<div className="bg-white p-10 rounded-[2.5rem] shadow-sm border border-gray-100">
|
||||||
|
<h3 className="text-2xl font-black mb-8 text-[#11280f] uppercase tracking-tighter">Banen</h3>
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b border-gray-50"><td className="py-4 text-gray-400">Hull:</td><td className="py-4 text-right">{amenities.antall_hull || '--'}</td></tr>
|
||||||
|
<tr className="border-b border-gray-50"><td className="py-4 text-gray-400">Banetype:</td><td className="py-4 text-right">{facility.banetype || 'Park/Skog'}</td></tr>
|
||||||
|
<tr className="border-b border-gray-50"><td className="py-4 text-gray-400">Lengde:</td><td className="py-4 text-right">{facility.length || '--'} meter</td></tr>
|
||||||
|
<tr className="border-b border-gray-50"><td className="py-4 text-gray-400">Sesong:</td><td className="py-4 text-right">{facility.season}</td></tr>
|
||||||
|
<tr className="border-b border-gray-50"><td className="py-4 text-gray-400">Byggeår:</td><td className="py-4 text-right">{facility.established_year || '--'}</td></tr>
|
||||||
|
<tr><td className="py-4 text-gray-400">Arkitekt:</td><td className="py-4 text-right">{facility.architect || '--'}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-10 rounded-[2.5rem] shadow-sm border border-gray-100">
|
||||||
|
<h3 className="text-2xl font-black mb-8 text-[#11280f] uppercase tracking-tighter">Andre Tilbud</h3>
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b border-gray-50"><td className="py-4 text-gray-400">Drivingrange:</td><td className="py-4 text-right">{amenities.drivingrange ? "Ja" : "Nei"}</td></tr>
|
||||||
|
<tr className="border-b border-gray-50"><td className="py-4 text-gray-400">Nærspill:</td><td className="py-4 text-right">Ja ✓</td></tr>
|
||||||
|
<tr className="border-b border-gray-50"><td className="py-4 text-gray-400">Head Pro:</td><td className="py-4 text-right text-orange-500" dangerouslySetInnerHTML={{ __html: amenities.pro }} /></tr>
|
||||||
|
<tr className="border-b border-gray-50"><td className="py-4 text-gray-400">Proshop:</td><td className="py-4 text-right">Ja</td></tr>
|
||||||
|
<tr className="border-b border-gray-50"><td className="py-4 text-gray-400">Kafé:</td><td className="py-4 text-right text-orange-500" dangerouslySetInnerHTML={{ __html: amenities.kafe }} /></tr>
|
||||||
|
<tr><td className="py-4 text-gray-400">Bilutleie:</td><td className="py-4 text-right">Ja</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KART OG VIDEO */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div className="rounded-[3rem] overflow-hidden shadow-2xl h-96 border-8 border-white bg-gray-100">
|
||||||
|
<iframe width="100%" height="100%" frameBorder="0" src={`https://maps.google.com/maps?q=${facility.lat},${facility.lng}&t=k&z=15&ie=UTF8&iwloc=&output=embed`} />
|
||||||
|
</div>
|
||||||
|
{facility.video_url && (
|
||||||
|
<div className="rounded-[3rem] overflow-hidden shadow-2xl aspect-video bg-black border-8 border-white">
|
||||||
|
<iframe src={facility.video_url} className="w-full h-full" allowFullScreen></iframe>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SCOREKORT */}
|
||||||
|
<div className="space-y-12">
|
||||||
|
{activeCourses.map((c: any) => <CourseDisplay key={c.id} course={c} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SLOPING TABELLER */}
|
||||||
|
<div className="bg-white p-8 rounded-[2.5rem] shadow-sm border border-gray-100">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<h3 className="text-xl font-black uppercase">Slopetabeller</h3>
|
||||||
|
<span className="text-[9px] font-black text-gray-400 bg-gray-50 px-3 py-1 rounded-full uppercase">Gyldighet: {facility.gyldig_til_og_med || 'Ukjent'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||||
|
{['herrer', 'damer'].map(gender => (
|
||||||
|
<div key={gender} className="overflow-x-auto">
|
||||||
|
<h4 className="text-[10px] font-black text-[#7ca982] uppercase mb-4 tracking-[0.2em]">{gender}</h4>
|
||||||
|
<table className="w-full text-xs font-bold">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-300 uppercase"><th className="pb-4">Utslag</th><th className="pb-4 text-center">CR</th><th className="pb-4 text-right">Slope</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(activeCourses[0]?.tee_boxes?.[gender] || []).map((tee: any, i: number) => (
|
||||||
|
<tr key={i} className="border-t border-gray-50">
|
||||||
|
<td className="py-4 text-gray-800">{tee.navn_utslag || tee.navn_utslag_damer}</td>
|
||||||
|
<td className="py-4 text-center text-[#7ca982]">{tee.baneverdi || tee.baneverdi_damer}</td>
|
||||||
|
<td className="py-4 text-right text-gray-500">{tee.slopeverdi || tee.slopeverdi_damer}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GJESTESPILL & MEDLEMSKAP */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div className="bg-white p-10 rounded-[2.5rem] shadow-sm border border-gray-100">
|
||||||
|
<h3 className="text-xl font-black mb-6 uppercase">⛳ Gjestespill</h3>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[9px] font-black text-gray-300 uppercase mb-3 tracking-widest">Priser</h4>
|
||||||
|
{facility.greenfee?.voksne?.map((g: any, i: number) => (
|
||||||
|
<div key={i} className="flex justify-between py-3 border-b border-gray-50 font-bold text-sm">
|
||||||
|
<span className="text-gray-500">{g.priskategori}</span>
|
||||||
|
<span>kr {g.pris_voksne},-</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{facility.greenfee?.junior?.map((g: any, i: number) => (
|
||||||
|
<div key={i} className="flex justify-between py-3 border-b border-gray-50 font-bold text-sm">
|
||||||
|
<span className="text-gray-400">{g.priskategori_junior} (Junior)</span>
|
||||||
|
<span className="text-gray-400">kr {g.pris_junior},-</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-8 text-[10px] text-gray-400 font-black uppercase">Krav: {facility.guest_requirements}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-10 rounded-[2.5rem] shadow-sm border border-gray-100 flex flex-col">
|
||||||
|
<h3 className="text-xl font-black mb-6 uppercase">🤝 Medlemskap</h3>
|
||||||
|
<div className="flex-grow space-y-6">
|
||||||
|
<div className="p-8 bg-[#f1f7ed] rounded-[2rem]">
|
||||||
|
<p className="text-[#7ca982] text-[10px] font-black uppercase mb-1">{facility.membership?.standard?.navn || "Standard"}</p>
|
||||||
|
<p className="text-5xl font-black text-gray-800 tracking-tighter">kr {facility.membership?.standard?.pris || '--'},-</p>
|
||||||
|
</div>
|
||||||
|
{facility.membership?.rimeligste?.pris && (
|
||||||
|
<div className="p-6 bg-gray-50 rounded-2xl border border-gray-100">
|
||||||
|
<p className="text-gray-400 text-[9px] font-black uppercase">{facility.membership.rimeligste.navn}</p>
|
||||||
|
<p className="text-2xl font-black text-gray-600">kr {facility.membership.rimeligste.pris},-</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<a href={facility.membership?.url} target="_blank" className="mt-8 block text-center bg-gray-900 text-white p-5 rounded-2xl font-black uppercase text-xs hover:bg-black transition">Se alle alternativer</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
frontend/src/app/golfbaner/[slug]/page.tsx
Normal file
|
|
@ -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} />;
|
||||||
|
}
|
||||||
34
frontend/src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Create Next App",
|
||||||
|
description: "Generated by create next app",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
frontend/src/app/page.tsx
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/src/config/constants.ts
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"]
|
||||||
|
};
|
||||||
0
frontend/src/struktur_dump.txt
Normal file
34
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
51
init.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
-- Aktiver utvidelsen for GPS og kartdata
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
|
|
||||||
|
-- 1. Tabell for selve golfanlegget/klubben
|
||||||
|
CREATE TABLE facilities (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
slug VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
established_year INTEGER,
|
||||||
|
season VARCHAR(100),
|
||||||
|
coordinates GEOMETRY(Point, 4326),
|
||||||
|
has_driving_range BOOLEAN DEFAULT FALSE,
|
||||||
|
has_short_game_area BOOLEAN DEFAULT FALSE,
|
||||||
|
rentals_clubs BOOLEAN DEFAULT FALSE,
|
||||||
|
rentals_buggy BOOLEAN DEFAULT FALSE,
|
||||||
|
website_url VARCHAR(255),
|
||||||
|
golfbox_tournament_url VARCHAR(255),
|
||||||
|
pro_url VARCHAR(255),
|
||||||
|
proshop_url VARCHAR(255),
|
||||||
|
cafe_url VARCHAR(255),
|
||||||
|
nsg_agreement BOOLEAN DEFAULT FALSE,
|
||||||
|
cooperating_clubs TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Tabell for selve golfbanene (knyttet til anlegget)
|
||||||
|
CREATE TABLE courses (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
facility_id INTEGER REFERENCES facilities(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
holes INTEGER NOT NULL,
|
||||||
|
par INTEGER,
|
||||||
|
length_meters INTEGER,
|
||||||
|
course_type VARCHAR(100),
|
||||||
|
architect VARCHAR(255),
|
||||||
|
course_guide_url VARCHAR(255),
|
||||||
|
status VARCHAR(50) DEFAULT 'Ukjent',
|
||||||
|
status_updated_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. Tabell for bildeslideren
|
||||||
|
CREATE TABLE facility_images (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
facility_id INTEGER REFERENCES facilities(id) ON DELETE CASCADE,
|
||||||
|
image_url VARCHAR(255) NOT NULL,
|
||||||
|
display_order INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
76
schema.sql
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
-- Slett gamle tabeller slik at vi starter med helt blanke ark
|
||||||
|
DROP TABLE IF EXISTS hole_lengths CASCADE;
|
||||||
|
DROP TABLE IF EXISTS holes CASCADE;
|
||||||
|
DROP TABLE IF EXISTS tees CASCADE;
|
||||||
|
DROP TABLE IF EXISTS courses CASCADE;
|
||||||
|
DROP TABLE IF EXISTS facilities CASCADE;
|
||||||
|
|
||||||
|
-- 1. Tabellen for ANLEGG (Fasilitet)
|
||||||
|
CREATE TABLE facilities (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
slug VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
established_year INTEGER,
|
||||||
|
season VARCHAR(255),
|
||||||
|
address VARCHAR(255),
|
||||||
|
zipcode VARCHAR(50),
|
||||||
|
city VARCHAR(255),
|
||||||
|
county VARCHAR(255),
|
||||||
|
lat DOUBLE PRECISION,
|
||||||
|
lng DOUBLE PRECISION,
|
||||||
|
email VARCHAR(255),
|
||||||
|
phone VARCHAR(255),
|
||||||
|
website_url VARCHAR(255),
|
||||||
|
golfbox_booking_url VARCHAR(255),
|
||||||
|
golfbox_tournament_url VARCHAR(255),
|
||||||
|
facebook_url VARCHAR(255),
|
||||||
|
instagram_url VARCHAR(255),
|
||||||
|
weather_url VARCHAR(255),
|
||||||
|
webcam_url VARCHAR(255),
|
||||||
|
golfamore BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Tabellen for BANER (Tilhører et anlegg)
|
||||||
|
CREATE TABLE courses (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
facility_id INTEGER REFERENCES facilities(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
holes INTEGER,
|
||||||
|
par INTEGER,
|
||||||
|
length_meters INTEGER,
|
||||||
|
course_type VARCHAR(255),
|
||||||
|
architect VARCHAR(255),
|
||||||
|
status VARCHAR(255),
|
||||||
|
is_main_course BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. Tabellen for UTSLAG (Tilhører en bane)
|
||||||
|
CREATE TABLE tees (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
course_id INTEGER REFERENCES courses(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(50) NOT NULL, -- F.eks. '64', 'Gul', 'Rød'
|
||||||
|
cr_men NUMERIC(4, 1),
|
||||||
|
slope_men INTEGER,
|
||||||
|
cr_women NUMERIC(4, 1),
|
||||||
|
slope_women INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. Tabellen for HULL (Tilhører en bane)
|
||||||
|
CREATE TABLE holes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
course_id INTEGER REFERENCES courses(id) ON DELETE CASCADE,
|
||||||
|
hole_number INTEGER NOT NULL,
|
||||||
|
par INTEGER,
|
||||||
|
hcp_index INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 5. Tabellen for LENGDER (Knytter et hull til et spesifikt utslag)
|
||||||
|
CREATE TABLE hole_lengths (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
hole_id INTEGER REFERENCES holes(id) ON DELETE CASCADE,
|
||||||
|
tee_id INTEGER REFERENCES tees(id) ON DELETE CASCADE,
|
||||||
|
length_meters INTEGER
|
||||||
|
);
|
||||||
32
seed.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
-- Legg inn golfanlegget
|
||||||
|
INSERT INTO facilities (
|
||||||
|
name, slug, description, established_year, season,
|
||||||
|
has_driving_range, has_short_game_area, rentals_clubs, rentals_buggy,
|
||||||
|
website_url, golfbox_tournament_url, pro_url, proshop_url, cafe_url
|
||||||
|
) VALUES (
|
||||||
|
'Vestfold Golfklubb',
|
||||||
|
'vestfold-golfklubb',
|
||||||
|
'Vestfold Golfklubb, stiftet 1958, er en av landets eldste 18 hulls baner. Banen fikk 18 hull i 1976, og er siden blitt bygget om flere ganger.',
|
||||||
|
1958,
|
||||||
|
'April-oktober',
|
||||||
|
true, true, true, true,
|
||||||
|
'https://vgk.no/',
|
||||||
|
'https://www.golfbox.no/portal/golf_info/gbtourframe.asp?language=1044#/customer/324/schedule',
|
||||||
|
'https://vgk.no/headpro/',
|
||||||
|
'https://www.vgk.no/gjest/golfshop/',
|
||||||
|
'https://vgk.no/golfcafeen-luna-rossa/'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Legg inn Hovedbanen (Peker på facility_id 1)
|
||||||
|
INSERT INTO courses (
|
||||||
|
facility_id, name, holes, par, length_meters, course_type, architect, course_guide_url, status
|
||||||
|
) VALUES (
|
||||||
|
1, 'Hovedbanen', 18, 72, 6420, 'Park/skogsbane', 'Fred Smith / Jeremy Turner', 'https://vgk.no/baneguide-glfr/', 'Åpen'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Legg inn Korthullsbanen (Peker på facility_id 1)
|
||||||
|
INSERT INTO courses (
|
||||||
|
facility_id, name, holes, par, length_meters, course_type, architect, status
|
||||||
|
) VALUES (
|
||||||
|
1, 'Korthullsbanen', 9, 64, 3094, 'Park/skogsbane', 'Fred Smith / Jeremy Turner', 'Åpen'
|
||||||
|
);
|
||||||
516
struktur_dump.txt
Normal file
|
|
@ -0,0 +1,516 @@
|
||||||
|
--
|
||||||
|
-- PostgreSQL database dump
|
||||||
|
--
|
||||||
|
|
||||||
|
-- Dumped from database version 15.8 (Debian 15.8-1.pgdg110+1)
|
||||||
|
-- Dumped by pg_dump version 15.8 (Debian 15.8-1.pgdg110+1)
|
||||||
|
|
||||||
|
SET statement_timeout = 0;
|
||||||
|
SET lock_timeout = 0;
|
||||||
|
SET idle_in_transaction_session_timeout = 0;
|
||||||
|
SET client_encoding = 'UTF8';
|
||||||
|
SET standard_conforming_strings = on;
|
||||||
|
SELECT pg_catalog.set_config('search_path', '', false);
|
||||||
|
SET check_function_bodies = false;
|
||||||
|
SET xmloption = content;
|
||||||
|
SET client_min_messages = warning;
|
||||||
|
SET row_security = off;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tiger; Type: SCHEMA; Schema: -; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SCHEMA tiger;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER SCHEMA tiger OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tiger_data; Type: SCHEMA; Schema: -; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SCHEMA tiger_data;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER SCHEMA tiger_data OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: topology; Type: SCHEMA; Schema: -; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SCHEMA topology;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER SCHEMA topology OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: SCHEMA topology; Type: COMMENT; Schema: -; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
COMMENT ON SCHEMA topology IS 'PostGIS Topology schema';
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: fuzzystrmatch; Type: EXTENSION; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS fuzzystrmatch WITH SCHEMA public;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: EXTENSION fuzzystrmatch; Type: COMMENT; Schema: -; Owner:
|
||||||
|
--
|
||||||
|
|
||||||
|
COMMENT ON EXTENSION fuzzystrmatch IS 'determine similarities and distance between strings';
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: postgis; Type: EXTENSION; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis WITH SCHEMA public;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: EXTENSION postgis; Type: COMMENT; Schema: -; Owner:
|
||||||
|
--
|
||||||
|
|
||||||
|
COMMENT ON EXTENSION postgis IS 'PostGIS geometry and geography spatial types and functions';
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: postgis_tiger_geocoder; Type: EXTENSION; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder WITH SCHEMA tiger;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: EXTENSION postgis_tiger_geocoder; Type: COMMENT; Schema: -; Owner:
|
||||||
|
--
|
||||||
|
|
||||||
|
COMMENT ON EXTENSION postgis_tiger_geocoder IS 'PostGIS tiger geocoder and reverse geocoder';
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: postgis_topology; Type: EXTENSION; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis_topology WITH SCHEMA topology;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: EXTENSION postgis_topology; Type: COMMENT; Schema: -; Owner:
|
||||||
|
--
|
||||||
|
|
||||||
|
COMMENT ON EXTENSION postgis_topology IS 'PostGIS topology spatial types and functions';
|
||||||
|
|
||||||
|
|
||||||
|
SET default_tablespace = '';
|
||||||
|
|
||||||
|
SET default_table_access_method = heap;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: courses; Type: TABLE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.courses (
|
||||||
|
id integer NOT NULL,
|
||||||
|
facility_id integer,
|
||||||
|
name character varying(255) NOT NULL,
|
||||||
|
holes integer,
|
||||||
|
par integer,
|
||||||
|
length_meters integer,
|
||||||
|
course_type character varying(255),
|
||||||
|
architect character varying(255),
|
||||||
|
status character varying(255),
|
||||||
|
is_main_course boolean DEFAULT true,
|
||||||
|
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
tee_boxes jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.courses OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: courses_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.courses_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.courses_id_seq OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: courses_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.courses_id_seq OWNED BY public.courses.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facilities; Type: TABLE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.facilities (
|
||||||
|
id integer NOT NULL,
|
||||||
|
name character varying(255) NOT NULL,
|
||||||
|
slug character varying(255) NOT NULL,
|
||||||
|
description text,
|
||||||
|
established_year integer,
|
||||||
|
season character varying(255),
|
||||||
|
address character varying(255),
|
||||||
|
zipcode character varying(50),
|
||||||
|
city character varying(255),
|
||||||
|
county character varying(255),
|
||||||
|
lat double precision,
|
||||||
|
lng double precision,
|
||||||
|
email character varying(255),
|
||||||
|
phone character varying(255),
|
||||||
|
website_url character varying(255),
|
||||||
|
golfbox_booking_url character varying(255),
|
||||||
|
golfbox_tournament_url character varying(255),
|
||||||
|
facebook_url character varying(255),
|
||||||
|
instagram_url character varying(255),
|
||||||
|
weather_url character varying(255),
|
||||||
|
webcam_url character varying(255),
|
||||||
|
golfamore boolean DEFAULT false,
|
||||||
|
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
image_url character varying(500),
|
||||||
|
amenities jsonb,
|
||||||
|
greenfee jsonb,
|
||||||
|
architect text,
|
||||||
|
membership jsonb,
|
||||||
|
vtg jsonb,
|
||||||
|
video_url text,
|
||||||
|
baneguide_url text,
|
||||||
|
logo_url text,
|
||||||
|
flyfoto_url text,
|
||||||
|
guest_requirements text,
|
||||||
|
status_updated_at text,
|
||||||
|
gallery jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.facilities OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facilities_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.facilities_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.facilities_id_seq OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facilities_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.facilities_id_seq OWNED BY public.facilities.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facility_images; Type: TABLE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.facility_images (
|
||||||
|
id integer NOT NULL,
|
||||||
|
facility_id integer,
|
||||||
|
image_url character varying(255) NOT NULL,
|
||||||
|
display_order integer DEFAULT 0,
|
||||||
|
sort_order integer DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.facility_images OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facility_images_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.facility_images_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.facility_images_id_seq OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facility_images_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.facility_images_id_seq OWNED BY public.facility_images.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: hole_lengths; Type: TABLE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.hole_lengths (
|
||||||
|
id integer NOT NULL,
|
||||||
|
hole_id integer,
|
||||||
|
tee_id integer,
|
||||||
|
length_meters integer
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.hole_lengths OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: hole_lengths_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.hole_lengths_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.hole_lengths_id_seq OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: hole_lengths_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.hole_lengths_id_seq OWNED BY public.hole_lengths.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: holes; Type: TABLE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.holes (
|
||||||
|
id integer NOT NULL,
|
||||||
|
course_id integer,
|
||||||
|
hole_number integer NOT NULL,
|
||||||
|
par integer,
|
||||||
|
hcp_index integer,
|
||||||
|
lengths jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.holes OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: holes_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.holes_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.holes_id_seq OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: holes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.holes_id_seq OWNED BY public.holes.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tees; Type: TABLE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.tees (
|
||||||
|
id integer NOT NULL,
|
||||||
|
course_id integer,
|
||||||
|
name character varying(50) NOT NULL,
|
||||||
|
cr_men numeric(4,1),
|
||||||
|
slope_men integer,
|
||||||
|
cr_women numeric(4,1),
|
||||||
|
slope_women integer
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.tees OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tees_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SEQUENCE public.tees_id_seq
|
||||||
|
AS integer
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.tees_id_seq OWNER TO teeoff_admin;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tees_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER SEQUENCE public.tees_id_seq OWNED BY public.tees.id;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: courses id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.courses ALTER COLUMN id SET DEFAULT nextval('public.courses_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facilities id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.facilities ALTER COLUMN id SET DEFAULT nextval('public.facilities_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facility_images id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.facility_images ALTER COLUMN id SET DEFAULT nextval('public.facility_images_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: hole_lengths id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.hole_lengths ALTER COLUMN id SET DEFAULT nextval('public.hole_lengths_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: holes id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.holes ALTER COLUMN id SET DEFAULT nextval('public.holes_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tees id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.tees ALTER COLUMN id SET DEFAULT nextval('public.tees_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: courses courses_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.courses
|
||||||
|
ADD CONSTRAINT courses_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facilities facilities_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.facilities
|
||||||
|
ADD CONSTRAINT facilities_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facilities facilities_slug_key; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.facilities
|
||||||
|
ADD CONSTRAINT facilities_slug_key UNIQUE (slug);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: facility_images facility_images_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.facility_images
|
||||||
|
ADD CONSTRAINT facility_images_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: hole_lengths hole_lengths_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.hole_lengths
|
||||||
|
ADD CONSTRAINT hole_lengths_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: holes holes_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.holes
|
||||||
|
ADD CONSTRAINT holes_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tees tees_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.tees
|
||||||
|
ADD CONSTRAINT tees_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: courses courses_facility_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.courses
|
||||||
|
ADD CONSTRAINT courses_facility_id_fkey FOREIGN KEY (facility_id) REFERENCES public.facilities(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: hole_lengths hole_lengths_hole_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.hole_lengths
|
||||||
|
ADD CONSTRAINT hole_lengths_hole_id_fkey FOREIGN KEY (hole_id) REFERENCES public.holes(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: hole_lengths hole_lengths_tee_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.hole_lengths
|
||||||
|
ADD CONSTRAINT hole_lengths_tee_id_fkey FOREIGN KEY (tee_id) REFERENCES public.tees(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: holes holes_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.holes
|
||||||
|
ADD CONSTRAINT holes_course_id_fkey FOREIGN KEY (course_id) REFERENCES public.courses(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: tees tees_course_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.tees
|
||||||
|
ADD CONSTRAINT tees_course_id_fkey FOREIGN KEY (course_id) REFERENCES public.courses(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- PostgreSQL database dump complete
|
||||||
|
--
|
||||||
|
|
||||||
4869
teeoff_backup.sql
Normal file
5747
teeoff_backup_1.sql
Normal file
6625
teeoff_backup_2.sql
Normal file
7
update_golfbox.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Legg til den nye kolonnen for booking-lenke
|
||||||
|
ALTER TABLE facilities ADD COLUMN golfbox_booking_url VARCHAR(500);
|
||||||
|
|
||||||
|
-- Oppdater Vestfold Golfklubb med riktig lenke
|
||||||
|
UPDATE facilities
|
||||||
|
SET golfbox_booking_url = 'http://www.golfbox.no/site/system/redirect.asp?locale=nb_NO&rUrl=%2Fsite%2Fressources%2Fbooking%2Fgrid.asp%3FRessource_GUID%3D%7BEBA1D2F6-0876-4961-85AD-92280BD264AB%7D'
|
||||||
|
WHERE slug = 'vestfold-golfklubb';
|
||||||