Nå virker forsiden!
This commit is contained in:
parent
e4bf1702ed
commit
14226321a8
2 changed files with 181 additions and 71 deletions
126
backend/main.py
126
backend/main.py
|
|
@ -5,21 +5,86 @@ import asyncpg
|
||||||
import json
|
import json
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
# --- KONFIGURASJON ---
|
||||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
||||||
|
|
||||||
|
def format_row(row):
|
||||||
|
"""
|
||||||
|
Vasker data fra databasen:
|
||||||
|
1. Konverterer datoer til ISO-format.
|
||||||
|
2. Tvinger tekst-JSON (stringified JSON) over til ekte Python objekter/lister.
|
||||||
|
"""
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
d = dict(row)
|
||||||
|
|
||||||
|
# 1. Håndter dato- og tidsformater for JSON-serialisering
|
||||||
|
for key in ['status_updated_at', 'created_at']:
|
||||||
|
if isinstance(d.get(key), (date, datetime)):
|
||||||
|
d[key] = d[key].isoformat()
|
||||||
|
|
||||||
|
# 2. Definer alle felter som inneholder JSON-data
|
||||||
|
# Disse må parses manuelt hvis de kommer som strenger fra Postgres
|
||||||
|
json_list_fields = [
|
||||||
|
'course_statuses', 'courses', 'gallery', 'greenfee',
|
||||||
|
'faqs', 'shotzoom', 'social_links', 'holes'
|
||||||
|
]
|
||||||
|
json_dict_fields = [
|
||||||
|
'amenities', 'vtg', 'nsg_data', 'golfamore_data'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Vask list-felter
|
||||||
|
for field in json_list_fields:
|
||||||
|
if field in d:
|
||||||
|
val = d[field]
|
||||||
|
if val is None:
|
||||||
|
d[field] = []
|
||||||
|
elif isinstance(val, str):
|
||||||
|
try:
|
||||||
|
d[field] = json.loads(val)
|
||||||
|
except:
|
||||||
|
d[field] = []
|
||||||
|
elif not isinstance(val, list):
|
||||||
|
d[field] = []
|
||||||
|
|
||||||
|
# Vask objekt-felter
|
||||||
|
for field in json_dict_fields:
|
||||||
|
if field in d:
|
||||||
|
val = d[field]
|
||||||
|
if val is None:
|
||||||
|
d[field] = {}
|
||||||
|
elif isinstance(val, str):
|
||||||
|
try:
|
||||||
|
d[field] = json.loads(val)
|
||||||
|
except:
|
||||||
|
d[field] = {}
|
||||||
|
elif not isinstance(val, dict):
|
||||||
|
d[field] = {}
|
||||||
|
|
||||||
|
return d
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
# Opprett database-pool ved start
|
||||||
try:
|
try:
|
||||||
app.state.pool = await asyncpg.create_pool(DB_URL, min_size=5, max_size=20)
|
app.state.pool = await asyncpg.create_pool(
|
||||||
|
DB_URL,
|
||||||
|
min_size=5,
|
||||||
|
max_size=20,
|
||||||
|
command_timeout=60
|
||||||
|
)
|
||||||
print("✅ Database tilkoblet og pool opprettet")
|
print("✅ Database tilkoblet og pool opprettet")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Databasefeil: {e}")
|
print(f"❌ Databasefeil under oppstart: {e}")
|
||||||
raise e
|
raise e
|
||||||
yield
|
yield
|
||||||
|
# Lukk pool ved avslutning
|
||||||
await app.state.pool.close()
|
await app.state.pool.close()
|
||||||
|
|
||||||
app = FastAPI(title="TeeOff API v2.4", lifespan=lifespan)
|
app = FastAPI(title="TeeOff API v3.5", lifespan=lifespan)
|
||||||
|
|
||||||
|
# CORS-oppsett slik at Next.js kan snakke med API-et
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
|
|
@ -27,39 +92,9 @@ app.add_middleware(
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def format_row(row):
|
|
||||||
if row is None: return None
|
|
||||||
d = dict(row)
|
|
||||||
|
|
||||||
# 1. Dato-håndtering
|
|
||||||
for key in ['status_updated_at', 'created_at']:
|
|
||||||
if isinstance(d.get(key), (date, datetime)):
|
|
||||||
d[key] = d[key].isoformat()
|
|
||||||
|
|
||||||
# 2. Garanter riktige datatyper (Vaskeliste)
|
|
||||||
list_fields = ['course_statuses', 'courses', 'gallery', 'greenfee', 'faqs', 'shotzoom', 'social_links', 'holes']
|
|
||||||
dict_fields = ['amenities', 'vtg', 'nsg_data', 'golfamore_data']
|
|
||||||
|
|
||||||
for field in list_fields:
|
|
||||||
if field in d:
|
|
||||||
if d[field] is None:
|
|
||||||
d[field] = []
|
|
||||||
elif isinstance(d[field], str):
|
|
||||||
try: d[field] = json.loads(d[field])
|
|
||||||
except: d[field] = []
|
|
||||||
|
|
||||||
for field in dict_fields:
|
|
||||||
if field in d:
|
|
||||||
if d[field] is None:
|
|
||||||
d[field] = {}
|
|
||||||
elif isinstance(d[field], str):
|
|
||||||
try: d[field] = json.loads(d[field])
|
|
||||||
except: d[field] = {}
|
|
||||||
|
|
||||||
return d
|
|
||||||
|
|
||||||
@app.get("/api/facilities")
|
@app.get("/api/facilities")
|
||||||
async def get_facilities():
|
async def get_facilities():
|
||||||
|
"""Henter alle golfanlegg med aggregert banestatus"""
|
||||||
async with app.state.pool.acquire() as conn:
|
async with app.state.pool.acquire() as conn:
|
||||||
rows = await conn.fetch("""
|
rows = await conn.fetch("""
|
||||||
SELECT f.*, (
|
SELECT f.*, (
|
||||||
|
|
@ -69,12 +104,14 @@ async def get_facilities():
|
||||||
ORDER BY is_main_course DESC, id ASC
|
ORDER BY is_main_course DESC, id ASC
|
||||||
) cs
|
) cs
|
||||||
) as course_statuses
|
) as course_statuses
|
||||||
FROM facilities f ORDER BY f.name ASC
|
FROM facilities f
|
||||||
|
ORDER BY f.name ASC
|
||||||
""")
|
""")
|
||||||
return [format_row(row) for row in rows]
|
return [format_row(row) for row in rows]
|
||||||
|
|
||||||
@app.get("/api/facilities/{slug}")
|
@app.get("/api/facilities/{slug}")
|
||||||
async def get_facility(slug: str):
|
async def get_facility(slug: str):
|
||||||
|
"""Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull"""
|
||||||
async with app.state.pool.acquire() as conn:
|
async with app.state.pool.acquire() as conn:
|
||||||
row = await conn.fetchrow("""
|
row = await conn.fetchrow("""
|
||||||
SELECT f.*, (
|
SELECT f.*, (
|
||||||
|
|
@ -91,5 +128,22 @@ async def get_facility(slug: str):
|
||||||
) as courses
|
) as courses
|
||||||
FROM facilities f WHERE f.slug = $1
|
FROM facilities f WHERE f.slug = $1
|
||||||
""", slug)
|
""", slug)
|
||||||
if not row: raise HTTPException(status_code=404)
|
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
|
||||||
|
|
||||||
return format_row(row)
|
return format_row(row)
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health_check():
|
||||||
|
"""Enkel sjekk for å se at API og DB lever"""
|
||||||
|
try:
|
||||||
|
async with app.state.pool.acquire() as conn:
|
||||||
|
await conn.execute("SELECT 1")
|
||||||
|
return {"status": "healthy", "database": "connected"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "unhealthy", "error": str(e)}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
|
|
@ -1,4 +1,17 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
/**
|
||||||
|
* TEE OFF SYSTEM INSTRUCTIONS - FACILITY CARDS v3.4 (STABLE)
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* REGEL 1: Status-badge SKAL vises øverst til venstre. Bruk STATUS_MAP for tekst.
|
||||||
|
* REGEL 2: DATA-PARSING: Bruk parseJson() for 'course_statuses', 'amenities' og 'nsg_data'
|
||||||
|
* fordi API-et ofte returnerer disse som strenger.
|
||||||
|
* REGEL 3: Avstand-pillen skal ha fargen #2d3319 (Mørk oliven) med hvit tekst.
|
||||||
|
* REGEL 4: NSG (Blå 'N') og Golfamore (Oransje 'G') sirkler skal ha hvit kant (border-2).
|
||||||
|
* REGEL 5: Bunnen: Antall Hull (grønn pill), Banetype (grå pill), og Ikon-sirkler.
|
||||||
|
* REGEL 6: LOSBY-LOGIKK: Sjekk alle baner i arrayen. Hvis én er åpen, vis 'aapen'.
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
import { STATUS_MAP } from "@/config/constants";
|
import { STATUS_MAP } from "@/config/constants";
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
@ -29,17 +42,28 @@ export default function FacilitySearch({ initialFacilities }: { initialFacilitie
|
||||||
|
|
||||||
const processed = useMemo(() => {
|
const processed = useMemo(() => {
|
||||||
if (!Array.isArray(initialFacilities)) return [];
|
if (!Array.isArray(initialFacilities)) return [];
|
||||||
const words = searchQuery.toLowerCase().trim().split(/\s+/).filter(w => w.length > 0);
|
|
||||||
|
|
||||||
return initialFacilities.map(f => {
|
return initialFacilities.map(f => {
|
||||||
const dist = userLocation && f.lat && f.lng ? getDistance(userLocation.lat, userLocation.lng, f.lat, f.lng) : Infinity;
|
// --- ROBUST DATA-PARSING (Håndterer tekst vs objekt fra API) ---
|
||||||
const hasNSG = f.nsg_data && Object.keys(f.nsg_data).length > 0;
|
const parseJson = (val: any, fallback: any) => {
|
||||||
const hasGolfamore = f.golfamore && (f.golfamore_data?.terms || f.golfamore === true);
|
if (!val) return fallback;
|
||||||
|
if (typeof val === 'object') return val;
|
||||||
|
try { return JSON.parse(val); } catch (e) { return fallback; }
|
||||||
|
};
|
||||||
|
|
||||||
const blob = `${f.name} ${f.city} ${f.county} ${hasNSG ? 'nsg seniorgolf' : ''} ${hasGolfamore ? 'golfamore' : ''}`.toLowerCase();
|
const statuses = parseJson(f.course_statuses, []);
|
||||||
|
const amenities = parseJson(f.amenities, {});
|
||||||
|
const nsgData = parseJson(f.nsg_data, {});
|
||||||
|
|
||||||
|
const dist = userLocation && f.lat && f.lng ? getDistance(userLocation.lat, userLocation.lng, f.lat, f.lng) : Infinity;
|
||||||
|
const hasNSG = nsgData && Object.keys(nsgData).length > 0;
|
||||||
|
const hasGolfamore = f.golfamore === true;
|
||||||
|
|
||||||
|
const words = searchQuery.toLowerCase().trim().split(/\s+/).filter(w => w.length > 0);
|
||||||
|
const blob = `${f.name} ${f.city} ${f.county}`.toLowerCase();
|
||||||
const matches = words.every(w => blob.includes(w));
|
const matches = words.every(w => blob.includes(w));
|
||||||
|
|
||||||
return { ...f, dist, hasNSG, hasGolfamore, matches };
|
return { ...f, statuses, amenities, dist, hasNSG, hasGolfamore, matches };
|
||||||
})
|
})
|
||||||
.filter(f => f.matches)
|
.filter(f => f.matches)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
|
@ -56,39 +80,71 @@ export default function FacilitySearch({ initialFacilities }: { initialFacilitie
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input className="w-full p-8 rounded-[2.5rem] shadow-2xl mb-16 text-gray-900 border-none ring-1 ring-black/5 text-2xl outline-none focus:ring-4 focus:ring-[#8bc34a]/20 transition-all bg-white" placeholder='Søk baner, fylke eller "nsg"...' value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
|
<input className="w-full p-8 rounded-[2.5rem] shadow-2xl mb-16 text-gray-900 border-none ring-1 ring-black/5 text-2xl outline-none focus:ring-4 focus:ring-[#8bc34a]/20 transition-all bg-white" placeholder='Søk baner...' value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
|
||||||
{processed.map((f: any) => (
|
{processed.map((f: any) => {
|
||||||
<Link href={`/golfbaner/${f.slug}`} key={f.id} className="bg-white rounded-[3rem] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-gray-100 flex flex-col group relative">
|
// --- STATUS LOGIKK ---
|
||||||
|
const sArr = Array.isArray(f.statuses) ? f.statuses : [];
|
||||||
|
const activeStatus = sArr.find((s:any) => s.status === 'aapen') || sArr[0] || { status: 'ukjent' };
|
||||||
|
const rawStatus = (activeStatus.status || "ukjent").toLowerCase();
|
||||||
|
|
||||||
|
let statusColor = "bg-gray-400";
|
||||||
|
if (rawStatus === 'aapen') statusColor = "bg-[#8bc34a]";
|
||||||
|
else if (rawStatus.includes('vinter') || rawStatus === 'stenger_snart') statusColor = "bg-[#ff5722]";
|
||||||
|
else if (rawStatus === 'aapner_snart') statusColor = "bg-amber-500";
|
||||||
|
else if (rawStatus === 'stengt') statusColor = "bg-red-600";
|
||||||
|
else if (rawStatus === 'nedlagt') statusColor = "bg-black";
|
||||||
|
else if (rawStatus === 'under_utvikling') statusColor = "bg-blue-500";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/golfbaner/${f.slug}`} key={f.id} className="bg-white rounded-[2.5rem] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-gray-100 flex flex-col group relative">
|
||||||
<div className="h-64 relative overflow-hidden bg-gray-100">
|
<div className="h-64 relative overflow-hidden bg-gray-100">
|
||||||
<img src={f.image_url || "/Toppbilde-standard.jpg"} className="w-full h-full object-cover transition duration-1000 group-hover:scale-105" alt={f.name} />
|
<img src={f.image_url || "/Toppbilde-standard.jpg"} className="w-full h-full object-cover transition duration-1000 group-hover:scale-105" alt={f.name} />
|
||||||
<div className="absolute top-6 left-6 flex flex-col gap-2">
|
|
||||||
{(Array.isArray(f.course_statuses) ? f.course_statuses : []).slice(0, 1).map((s: any, idx: number) => {
|
{/* Status Badge */}
|
||||||
const raw = (s.status || "").toLowerCase();
|
<div className={`absolute top-5 left-5 ${statusColor} text-white px-4 py-1.5 rounded-xl text-[10px] font-black uppercase shadow-xl z-20`}>
|
||||||
let color = "bg-gray-500";
|
{STATUS_MAP[rawStatus] || rawStatus}
|
||||||
if (raw === 'aapen') color = "bg-[#8bc34a]";
|
|
||||||
else if (raw.includes('vinter')) color = "bg-emerald-600";
|
|
||||||
else if (raw.includes('snart')) color = "bg-amber-500";
|
|
||||||
else if (raw === 'stengt') color = "bg-red-600";
|
|
||||||
return <div key={idx} className={`${color} text-white px-4 py-2 rounded-xl text-[10px] font-black uppercase shadow-xl`}>{STATUS_MAP[s.status] || s.status}</div>;
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute top-6 right-6 flex gap-2">
|
|
||||||
{f.hasNSG && <div className="w-10 h-10 bg-blue-600 text-white rounded-xl flex items-center justify-center font-black text-xs shadow-2xl">NSG</div>}
|
{/* Avstandspille (Mørk oliven #2d3319) */}
|
||||||
{f.hasGolfamore && <div className="w-10 h-10 bg-[#ff5722] text-white rounded-xl flex items-center justify-center font-black text-xs shadow-2xl">G</div>}
|
{f.dist !== Infinity && (
|
||||||
|
<div className="absolute bottom-5 right-5 bg-[#2d3319] text-white px-4 py-2 rounded-2xl text-[10px] font-black shadow-lg z-20">
|
||||||
|
{Math.round(f.dist)} km unna
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8 flex flex-col flex-grow">
|
||||||
|
<h3 className="font-black text-3xl text-[#11280f] mb-1 group-hover:text-[#8bc34a] transition-colors leading-tight">{f.name}</h3>
|
||||||
|
<p className="text-gray-400 text-[11px] font-bold uppercase tracking-widest mb-8">{f.city} • {f.county}</p>
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Hull-pille */}
|
||||||
|
<span className="bg-[#f1f7ed] text-[#8bc34a] px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest">
|
||||||
|
{f.amenities?.antall_hull || '--'} HULL
|
||||||
|
</span>
|
||||||
|
{/* Banetype-pille */}
|
||||||
|
<span className="bg-gray-50 text-gray-400 px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-gray-100">
|
||||||
|
{f.banetype || 'SKOGSBANE'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sirkel-ikoner (NSG / Golfamore) */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{f.hasNSG && (
|
||||||
|
<div className="w-9 h-9 bg-blue-600 text-white rounded-full flex items-center justify-center font-black text-sm shadow-lg border-2 border-white translate-y-1">N</div>
|
||||||
|
)}
|
||||||
|
{f.hasGolfamore && (
|
||||||
|
<div className="w-9 h-9 bg-[#ff5722] text-white rounded-full flex items-center justify-center font-black text-sm shadow-lg border-2 border-white translate-y-1">G</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-10 flex flex-col flex-grow">
|
|
||||||
<h3 className="font-black text-3xl text-[#11280f] mb-2 group-hover:text-[#8bc34a] transition-colors leading-tight">{f.name}</h3>
|
|
||||||
<p className="text-gray-400 text-sm font-bold uppercase tracking-widest">{f.city} • {f.county}</p>
|
|
||||||
<div className="pt-8 mt-auto border-t border-gray-50 flex items-center gap-3">
|
|
||||||
<span className="bg-[#f1f7ed] text-[#8bc34a] px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest">{f.amenities?.antall_hull || '--'} Hull</span>
|
|
||||||
<span className="bg-gray-50 text-gray-400 px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-gray-100">{f.banetype || 'Park/Skog'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue