Nå virker forsiden!

This commit is contained in:
Erol 2026-02-27 09:35:30 +01:00
parent e4bf1702ed
commit 14226321a8
2 changed files with 181 additions and 71 deletions

View file

@ -5,21 +5,86 @@ import asyncpg
import json
from datetime import date, datetime
# --- KONFIGURASJON ---
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
def format_row(row):
"""
Vasker data fra databasen:
1. Konverterer datoer til ISO-format.
2. Tvinger tekst-JSON (stringified JSON) over til ekte Python objekter/lister.
"""
if row is None:
return None
d = dict(row)
# 1. Håndter dato- og tidsformater for JSON-serialisering
for key in ['status_updated_at', 'created_at']:
if isinstance(d.get(key), (date, datetime)):
d[key] = d[key].isoformat()
# 2. Definer alle felter som inneholder JSON-data
# Disse må parses manuelt hvis de kommer som strenger fra Postgres
json_list_fields = [
'course_statuses', 'courses', 'gallery', 'greenfee',
'faqs', 'shotzoom', 'social_links', 'holes'
]
json_dict_fields = [
'amenities', 'vtg', 'nsg_data', 'golfamore_data'
]
# Vask list-felter
for field in json_list_fields:
if field in d:
val = d[field]
if val is None:
d[field] = []
elif isinstance(val, str):
try:
d[field] = json.loads(val)
except:
d[field] = []
elif not isinstance(val, list):
d[field] = []
# Vask objekt-felter
for field in json_dict_fields:
if field in d:
val = d[field]
if val is None:
d[field] = {}
elif isinstance(val, str):
try:
d[field] = json.loads(val)
except:
d[field] = {}
elif not isinstance(val, dict):
d[field] = {}
return d
@asynccontextmanager
async def lifespan(app: FastAPI):
# Opprett database-pool ved start
try:
app.state.pool = await asyncpg.create_pool(DB_URL, min_size=5, max_size=20)
app.state.pool = await asyncpg.create_pool(
DB_URL,
min_size=5,
max_size=20,
command_timeout=60
)
print("✅ Database tilkoblet og pool opprettet")
except Exception as e:
print(f"❌ Databasefeil: {e}")
print(f"❌ Databasefeil under oppstart: {e}")
raise e
yield
# Lukk pool ved avslutning
await app.state.pool.close()
app = FastAPI(title="TeeOff API v2.4", lifespan=lifespan)
app = FastAPI(title="TeeOff API v3.5", lifespan=lifespan)
# CORS-oppsett slik at Next.js kan snakke med API-et
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@ -27,39 +92,9 @@ app.add_middleware(
allow_headers=["*"],
)
def format_row(row):
if row is None: return None
d = dict(row)
# 1. Dato-håndtering
for key in ['status_updated_at', 'created_at']:
if isinstance(d.get(key), (date, datetime)):
d[key] = d[key].isoformat()
# 2. Garanter riktige datatyper (Vaskeliste)
list_fields = ['course_statuses', 'courses', 'gallery', 'greenfee', 'faqs', 'shotzoom', 'social_links', 'holes']
dict_fields = ['amenities', 'vtg', 'nsg_data', 'golfamore_data']
for field in list_fields:
if field in d:
if d[field] is None:
d[field] = []
elif isinstance(d[field], str):
try: d[field] = json.loads(d[field])
except: d[field] = []
for field in dict_fields:
if field in d:
if d[field] is None:
d[field] = {}
elif isinstance(d[field], str):
try: d[field] = json.loads(d[field])
except: d[field] = {}
return d
@app.get("/api/facilities")
async def get_facilities():
"""Henter alle golfanlegg med aggregert banestatus"""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT f.*, (
@ -69,12 +104,14 @@ async def get_facilities():
ORDER BY is_main_course DESC, id ASC
) cs
) as course_statuses
FROM facilities f ORDER BY f.name ASC
FROM facilities f
ORDER BY f.name ASC
""")
return [format_row(row) for row in rows]
@app.get("/api/facilities/{slug}")
async def get_facility(slug: str):
"""Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull"""
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT f.*, (
@ -91,5 +128,22 @@ async def get_facility(slug: str):
) as courses
FROM facilities f WHERE f.slug = $1
""", slug)
if not row: raise HTTPException(status_code=404)
return format_row(row)
if not row:
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
return format_row(row)
@app.get("/api/health")
async def health_check():
"""Enkel sjekk for å se at API og DB lever"""
try:
async with app.state.pool.acquire() as conn:
await conn.execute("SELECT 1")
return {"status": "healthy", "database": "connected"}
except Exception as e:
return {"status": "unhealthy", "error": str(e)}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View file

@ -1,4 +1,17 @@
"use client";
/**
* TEE OFF SYSTEM INSTRUCTIONS - FACILITY CARDS v3.4 (STABLE)
* ---------------------------------------------------------------------------
* REGEL 1: Status-badge SKAL vises øverst til venstre. Bruk STATUS_MAP for tekst.
* REGEL 2: DATA-PARSING: Bruk parseJson() for 'course_statuses', 'amenities' og 'nsg_data'
* fordi API-et ofte returnerer disse som strenger.
* REGEL 3: Avstand-pillen skal ha fargen #2d3319 (Mørk oliven) med hvit tekst.
* REGEL 4: NSG (Blå 'N') og Golfamore (Oransje 'G') sirkler skal ha hvit kant (border-2).
* REGEL 5: Bunnen: Antall Hull (grønn pill), Banetype (grå pill), og Ikon-sirkler.
* REGEL 6: LOSBY-LOGIKK: Sjekk alle baner i arrayen. Hvis én er åpen, vis 'aapen'.
* ---------------------------------------------------------------------------
*/
import { STATUS_MAP } from "@/config/constants";
import { useState, useEffect, useMemo } from 'react';
import Link from 'next/link';
@ -29,17 +42,28 @@ export default function FacilitySearch({ initialFacilities }: { initialFacilitie
const processed = useMemo(() => {
if (!Array.isArray(initialFacilities)) return [];
const words = searchQuery.toLowerCase().trim().split(/\s+/).filter(w => w.length > 0);
return initialFacilities.map(f => {
// --- ROBUST DATA-PARSING (Håndterer tekst vs objekt fra API) ---
const parseJson = (val: any, fallback: any) => {
if (!val) return fallback;
if (typeof val === 'object') return val;
try { return JSON.parse(val); } catch (e) { return fallback; }
};
const statuses = parseJson(f.course_statuses, []);
const amenities = parseJson(f.amenities, {});
const nsgData = parseJson(f.nsg_data, {});
const dist = userLocation && f.lat && f.lng ? getDistance(userLocation.lat, userLocation.lng, f.lat, f.lng) : Infinity;
const hasNSG = f.nsg_data && Object.keys(f.nsg_data).length > 0;
const hasGolfamore = f.golfamore && (f.golfamore_data?.terms || f.golfamore === true);
const hasNSG = nsgData && Object.keys(nsgData).length > 0;
const hasGolfamore = f.golfamore === true;
const blob = `${f.name} ${f.city} ${f.county} ${hasNSG ? 'nsg seniorgolf' : ''} ${hasGolfamore ? 'golfamore' : ''}`.toLowerCase();
const words = searchQuery.toLowerCase().trim().split(/\s+/).filter(w => w.length > 0);
const blob = `${f.name} ${f.city} ${f.county}`.toLowerCase();
const matches = words.every(w => blob.includes(w));
return { ...f, dist, hasNSG, hasGolfamore, matches };
return { ...f, statuses, amenities, dist, hasNSG, hasGolfamore, matches };
})
.filter(f => f.matches)
.sort((a, b) => {
@ -56,39 +80,71 @@ export default function FacilitySearch({ initialFacilities }: { initialFacilitie
</button>
</div>
<input className="w-full p-8 rounded-[2.5rem] shadow-2xl mb-16 text-gray-900 border-none ring-1 ring-black/5 text-2xl outline-none focus:ring-4 focus:ring-[#8bc34a]/20 transition-all bg-white" placeholder='Søk baner, fylke eller "nsg"...' value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
<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">
{processed.map((f: any) => (
<Link href={`/golfbaner/${f.slug}`} key={f.id} className="bg-white rounded-[3rem] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-gray-100 flex flex-col group relative">
<div className="h-64 relative overflow-hidden bg-gray-100">
<img src={f.image_url || "/Toppbilde-standard.jpg"} className="w-full h-full object-cover transition duration-1000 group-hover:scale-105" alt={f.name} />
<div className="absolute top-6 left-6 flex flex-col gap-2">
{(Array.isArray(f.course_statuses) ? f.course_statuses : []).slice(0, 1).map((s: any, idx: number) => {
const raw = (s.status || "").toLowerCase();
let color = "bg-gray-500";
if (raw === 'aapen') color = "bg-[#8bc34a]";
else if (raw.includes('vinter')) color = "bg-emerald-600";
else if (raw.includes('snart')) color = "bg-amber-500";
else if (raw === 'stengt') color = "bg-red-600";
return <div key={idx} className={`${color} text-white px-4 py-2 rounded-xl text-[10px] font-black uppercase shadow-xl`}>{STATUS_MAP[s.status] || s.status}</div>;
})}
{processed.map((f: any) => {
// --- STATUS LOGIKK ---
const sArr = Array.isArray(f.statuses) ? f.statuses : [];
const activeStatus = sArr.find((s:any) => s.status === 'aapen') || sArr[0] || { status: 'ukjent' };
const rawStatus = (activeStatus.status || "ukjent").toLowerCase();
let statusColor = "bg-gray-400";
if (rawStatus === 'aapen') statusColor = "bg-[#8bc34a]";
else if (rawStatus.includes('vinter') || rawStatus === 'stenger_snart') statusColor = "bg-[#ff5722]";
else if (rawStatus === 'aapner_snart') statusColor = "bg-amber-500";
else if (rawStatus === 'stengt') statusColor = "bg-red-600";
else if (rawStatus === 'nedlagt') statusColor = "bg-black";
else if (rawStatus === 'under_utvikling') statusColor = "bg-blue-500";
return (
<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">
<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} />
{/* Status Badge */}
<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`}>
{STATUS_MAP[rawStatus] || rawStatus}
</div>
{/* Avstandspille (Mørk oliven #2d3319) */}
{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 className="absolute top-6 right-6 flex gap-2">
{f.hasNSG && <div className="w-10 h-10 bg-blue-600 text-white rounded-xl flex items-center justify-center font-black text-xs shadow-2xl">NSG</div>}
{f.hasGolfamore && <div className="w-10 h-10 bg-[#ff5722] text-white rounded-xl flex items-center justify-center font-black text-xs shadow-2xl">G</div>}
<div 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>
</div>
<div className="p-10 flex flex-col flex-grow">
<h3 className="font-black text-3xl text-[#11280f] mb-2 group-hover:text-[#8bc34a] transition-colors leading-tight">{f.name}</h3>
<p className="text-gray-400 text-sm font-bold uppercase tracking-widest">{f.city} {f.county}</p>
<div className="pt-8 mt-auto border-t border-gray-50 flex items-center gap-3">
<span className="bg-[#f1f7ed] text-[#8bc34a] px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest">{f.amenities?.antall_hull || '--'} Hull</span>
<span className="bg-gray-50 text-gray-400 px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-gray-100">{f.banetype || 'Park/Skog'}</span>
</div>
</div>
</Link>
))}
</Link>
);
})}
</div>
</div>
);