Før fase to i effektiviseringen
This commit is contained in:
parent
dc7ed19f02
commit
a508db6071
15 changed files with 548 additions and 259 deletions
599
backend/main.py
599
backend/main.py
|
|
@ -22,6 +22,7 @@ import re
|
||||||
import secrets
|
import secrets
|
||||||
import hashlib
|
import hashlib
|
||||||
import smtplib
|
import smtplib
|
||||||
|
import time
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
|
|
@ -86,6 +87,20 @@ INDEXNOW_KEY = os.getenv("INDEXNOW_KEY", "").strip()
|
||||||
INDEXNOW_KEY_LOCATION = os.getenv("INDEXNOW_KEY_LOCATION", "").strip()
|
INDEXNOW_KEY_LOCATION = os.getenv("INDEXNOW_KEY_LOCATION", "").strip()
|
||||||
INDEXNOW_ENDPOINT = os.getenv("INDEXNOW_ENDPOINT", "https://api.indexnow.org/indexnow").strip()
|
INDEXNOW_ENDPOINT = os.getenv("INDEXNOW_ENDPOINT", "https://api.indexnow.org/indexnow").strip()
|
||||||
|
|
||||||
|
PUBLIC_FACILITIES_CACHE_TTLS = {
|
||||||
|
"search": 900,
|
||||||
|
"home": 900,
|
||||||
|
"place": 3600,
|
||||||
|
"membership": 1800,
|
||||||
|
"vtg": 1800,
|
||||||
|
"clubnumbers": 3600,
|
||||||
|
"sitemap": 3600,
|
||||||
|
"aliases": 3600,
|
||||||
|
"default": 300,
|
||||||
|
}
|
||||||
|
PUBLIC_FACILITY_DETAIL_CACHE_TTL_SECONDS = 900
|
||||||
|
PUBLIC_PLACE_PAGE_CACHE_TTL_SECONDS = 3600
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -127,6 +142,62 @@ def get_public_auth_config() -> dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_public_api_caches() -> None:
|
||||||
|
app.state.public_facilities_cache = {}
|
||||||
|
app.state.public_facility_detail_cache = {}
|
||||||
|
app.state.public_place_page_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_public_facilities_cache_ttl(view: str | None) -> int:
|
||||||
|
normalized_view = (view or "").strip().lower()
|
||||||
|
if normalized_view in PUBLIC_FACILITIES_CACHE_TTLS:
|
||||||
|
return PUBLIC_FACILITIES_CACHE_TTLS[normalized_view]
|
||||||
|
return PUBLIC_FACILITIES_CACHE_TTLS["default"]
|
||||||
|
|
||||||
|
|
||||||
|
def read_public_cache_entry(cache_store: dict[str, tuple[float, Any]], cache_key: str) -> Any | None:
|
||||||
|
entry = cache_store.get(cache_key)
|
||||||
|
if not entry:
|
||||||
|
return None
|
||||||
|
|
||||||
|
expires_at, payload = entry
|
||||||
|
if expires_at <= time.monotonic():
|
||||||
|
cache_store.pop(cache_key, None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def write_public_cache_entry(
|
||||||
|
cache_store: dict[str, tuple[float, Any]],
|
||||||
|
cache_key: str,
|
||||||
|
payload: Any,
|
||||||
|
ttl_seconds: int,
|
||||||
|
) -> Any:
|
||||||
|
cache_store[cache_key] = (time.monotonic() + max(1, ttl_seconds), payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def apply_public_cache_headers(response: Response, ttl_seconds: int) -> None:
|
||||||
|
ttl = max(60, int(ttl_seconds))
|
||||||
|
response.headers["Cache-Control"] = f"public, max-age=60, s-maxage={ttl}, stale-while-revalidate=60"
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_public_api_caches(*, include_place_pages: bool = False) -> None:
|
||||||
|
facilities_cache = getattr(app.state, "public_facilities_cache", None)
|
||||||
|
if isinstance(facilities_cache, dict):
|
||||||
|
facilities_cache.clear()
|
||||||
|
|
||||||
|
detail_cache = getattr(app.state, "public_facility_detail_cache", None)
|
||||||
|
if isinstance(detail_cache, dict):
|
||||||
|
detail_cache.clear()
|
||||||
|
|
||||||
|
if include_place_pages:
|
||||||
|
place_page_cache = getattr(app.state, "public_place_page_cache", None)
|
||||||
|
if isinstance(place_page_cache, dict):
|
||||||
|
place_page_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
def get_configured_public_base_url() -> str:
|
def get_configured_public_base_url() -> str:
|
||||||
for env_name in ("PUBLIC_BASE_URL", "NEXT_PUBLIC_SITE_URL"):
|
for env_name in ("PUBLIC_BASE_URL", "NEXT_PUBLIC_SITE_URL"):
|
||||||
configured = os.getenv(env_name, "").strip().rstrip("/")
|
configured = os.getenv(env_name, "").strip().rstrip("/")
|
||||||
|
|
@ -723,6 +794,43 @@ FACILITY_VTG_FIELDS = FACILITY_VTG_CONTENT_FIELDS | FACILITY_VTG_COURSE_FIELDS |
|
||||||
'vtg_content_updated_at',
|
'vtg_content_updated_at',
|
||||||
'vtg_courses_updated_at',
|
'vtg_courses_updated_at',
|
||||||
}
|
}
|
||||||
|
NON_PUBLIC_FACILITY_FIELDS = {
|
||||||
|
'membership_draft',
|
||||||
|
'greenfee_draft',
|
||||||
|
'vtg_draft',
|
||||||
|
'vtg_content_draft',
|
||||||
|
'vtg_courses_draft',
|
||||||
|
'golfpakker_draft',
|
||||||
|
'scrape_method',
|
||||||
|
'scrape_status_url',
|
||||||
|
'scrape_status_selector',
|
||||||
|
'ai_instruction',
|
||||||
|
}
|
||||||
|
|
||||||
|
FACILITY_VIEW_SEARCH_FIELDS = {
|
||||||
|
'id', 'slug', 'name', 'architect', 'description', 'city', 'county', 'banetype',
|
||||||
|
'image_url', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
|
||||||
|
'weather_url', 'lat', 'lng', 'golfamore', 'golfamore_url', 'nsg_url', 'has_golfpakker',
|
||||||
|
'vtg_pris', 'vtg_lenke', 'vtg_beskrivelse', 'footnote', 'footnote_updated_at',
|
||||||
|
'status_updated_at', 'amenities', 'golfamore_data', 'nsg_data', 'vtg_datoer',
|
||||||
|
'course_statuses', 'weather_forecast',
|
||||||
|
}
|
||||||
|
FACILITY_VIEW_PLACE_FIELDS = FACILITY_VIEW_SEARCH_FIELDS | {
|
||||||
|
'greenfee', 'standard_medlemskap', 'total_hole_count', 'hole_par_counts',
|
||||||
|
'shortest_hole_meters', 'longest_hole_meters',
|
||||||
|
}
|
||||||
|
FACILITY_VIEW_MEMBERSHIP_FIELDS = {
|
||||||
|
'id', 'slug', 'name', 'city', 'county', 'medlemskap_url', 'membership_updated_at',
|
||||||
|
'standard_medlemskap_kommentarer', 'navn_standard_medlemskap', 'standard_medlemskap',
|
||||||
|
'navn_rimeligste_alternativ', 'rimeligste_alternativ',
|
||||||
|
}
|
||||||
|
FACILITY_VIEW_VTG_FIELDS = {
|
||||||
|
'id', 'slug', 'name', 'city', 'county', 'lat', 'lng', 'status_updated_at',
|
||||||
|
'vtg_pris', 'vtg_lenke', 'vtg_beskrivelse', 'vtg_datoer', 'vtg_updated_at',
|
||||||
|
}
|
||||||
|
FACILITY_VIEW_CLUBNUMBERS_FIELDS = {'id', 'slug', 'name', 'city', 'county', 'ngf_number'}
|
||||||
|
FACILITY_VIEW_SITEMAP_FIELDS = {'slug', 'status_updated_at', 'vtg_updated_at'}
|
||||||
|
FACILITY_VIEW_ALIASES_FIELDS = {'slug', 'name'}
|
||||||
# --- FUNKSJONER ---
|
# --- FUNKSJONER ---
|
||||||
def format_row(row):
|
def format_row(row):
|
||||||
"""
|
"""
|
||||||
|
|
@ -784,6 +892,24 @@ def format_row(row):
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_public_facility_row(row: Any, *, fields: set[str] | None = None) -> dict[str, Any] | None:
|
||||||
|
formatted = format_row(row)
|
||||||
|
if formatted is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for field in NON_PUBLIC_FACILITY_FIELDS:
|
||||||
|
formatted.pop(field, None)
|
||||||
|
|
||||||
|
if fields is not None:
|
||||||
|
formatted = {
|
||||||
|
key: value
|
||||||
|
for key, value in formatted.items()
|
||||||
|
if key in fields
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
|
||||||
def prepare_vtg_content_draft_payload(value: Any) -> dict[str, Any]:
|
def prepare_vtg_content_draft_payload(value: Any) -> dict[str, Any]:
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -2097,6 +2223,7 @@ async def lifespan(app: FastAPI):
|
||||||
weather_sync_loop(app.state.pool, app.state.weather_sync_stop_event)
|
weather_sync_loop(app.state.pool, app.state.weather_sync_stop_event)
|
||||||
)
|
)
|
||||||
app.state.contact_submission_tracker = {}
|
app.state.contact_submission_tracker = {}
|
||||||
|
initialize_public_api_caches()
|
||||||
print("✅ Database tilkoblet og pool opprettet")
|
print("✅ Database tilkoblet og pool opprettet")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Databasefeil under oppstart: {e}")
|
print(f"❌ Databasefeil under oppstart: {e}")
|
||||||
|
|
@ -2768,12 +2895,108 @@ async def upsert_facility_rating(request: Request, payload: FacilityRatingUpsert
|
||||||
|
|
||||||
# --- DATA ENDPOINTS ---
|
# --- DATA ENDPOINTS ---
|
||||||
|
|
||||||
@app.get("/api/facilities")
|
def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | None]:
|
||||||
async def get_facilities(summary: bool = False):
|
normalized_view = (view or "").strip().lower()
|
||||||
"""Henter alle golfanlegg med aggregert banestatus for forsiden."""
|
|
||||||
async with app.state.pool.acquire() as conn:
|
course_statuses_sql = """
|
||||||
if summary:
|
(
|
||||||
rows = await conn.fetch("""
|
SELECT jsonb_agg(cs) FROM (
|
||||||
|
SELECT id, name, status FROM courses
|
||||||
|
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to'
|
||||||
|
ORDER BY is_main_course DESC, id ASC
|
||||||
|
) cs
|
||||||
|
) as course_statuses
|
||||||
|
"""
|
||||||
|
weather_compact_sql = """
|
||||||
|
(
|
||||||
|
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
|
||||||
|
SELECT
|
||||||
|
day_offset,
|
||||||
|
dry_daylight
|
||||||
|
FROM facility_weather_forecast
|
||||||
|
WHERE facility_id = f.id
|
||||||
|
ORDER BY day_offset ASC
|
||||||
|
) w_data
|
||||||
|
) as weather_forecast
|
||||||
|
"""
|
||||||
|
weather_full_sql = """
|
||||||
|
(
|
||||||
|
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
|
||||||
|
SELECT
|
||||||
|
forecast_date,
|
||||||
|
day_offset,
|
||||||
|
dry_all_day,
|
||||||
|
dry_daylight,
|
||||||
|
precip_mm,
|
||||||
|
precip_probability_max,
|
||||||
|
daylight_precip_mm,
|
||||||
|
daylight_precip_probability_max,
|
||||||
|
confidence,
|
||||||
|
source_updated_at,
|
||||||
|
source_expires_at,
|
||||||
|
calculated_at
|
||||||
|
FROM facility_weather_forecast
|
||||||
|
WHERE facility_id = f.id
|
||||||
|
ORDER BY day_offset ASC
|
||||||
|
) w_data
|
||||||
|
) as weather_forecast
|
||||||
|
"""
|
||||||
|
has_golfpakker_sql = """
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(f.golfpakker) = 'array' AND jsonb_array_length(f.golfpakker) > 0 THEN TRUE
|
||||||
|
WHEN NULLIF(BTRIM(COALESCE(f.golfpakker_url, '')), '') IS NOT NULL THEN TRUE
|
||||||
|
ELSE FALSE
|
||||||
|
END as has_golfpakker
|
||||||
|
"""
|
||||||
|
total_hole_count_sql = """
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM holes h
|
||||||
|
JOIN courses c ON c.id = h.course_id
|
||||||
|
WHERE c.facility_id = f.id
|
||||||
|
) as total_hole_count
|
||||||
|
"""
|
||||||
|
hole_par_counts_sql = """
|
||||||
|
(
|
||||||
|
SELECT jsonb_build_object(
|
||||||
|
'3', COUNT(*) FILTER (WHERE h.par = 3),
|
||||||
|
'4', COUNT(*) FILTER (WHERE h.par = 4),
|
||||||
|
'5', COUNT(*) FILTER (WHERE h.par = 5),
|
||||||
|
'6', COUNT(*) FILTER (WHERE h.par = 6)
|
||||||
|
)
|
||||||
|
FROM holes h
|
||||||
|
JOIN courses c ON c.id = h.course_id
|
||||||
|
WHERE c.facility_id = f.id
|
||||||
|
) as hole_par_counts
|
||||||
|
"""
|
||||||
|
shortest_hole_sql = """
|
||||||
|
(
|
||||||
|
SELECT MIN((length_value.value)::int)
|
||||||
|
FROM holes h
|
||||||
|
JOIN courses c ON c.id = h.course_id
|
||||||
|
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
|
||||||
|
WHERE c.facility_id = f.id
|
||||||
|
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
|
||||||
|
AND length_value.value ~ '^[0-9]+$'
|
||||||
|
AND (length_value.value)::int BETWEEN 30 AND 900
|
||||||
|
) as shortest_hole_meters
|
||||||
|
"""
|
||||||
|
longest_hole_sql = """
|
||||||
|
(
|
||||||
|
SELECT MAX((length_value.value)::int)
|
||||||
|
FROM holes h
|
||||||
|
JOIN courses c ON c.id = h.course_id
|
||||||
|
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
|
||||||
|
WHERE c.facility_id = f.id
|
||||||
|
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
|
||||||
|
AND length_value.value ~ '^[0-9]+$'
|
||||||
|
AND (length_value.value)::int BETWEEN 30 AND 900
|
||||||
|
) as longest_hole_meters
|
||||||
|
"""
|
||||||
|
|
||||||
|
if normalized_view in {"search", "home"}:
|
||||||
|
return (
|
||||||
|
f"""
|
||||||
SELECT
|
SELECT
|
||||||
f.id,
|
f.id,
|
||||||
f.slug,
|
f.slug,
|
||||||
|
|
@ -2794,6 +3017,50 @@ async def get_facilities(summary: bool = False):
|
||||||
f.golfamore,
|
f.golfamore,
|
||||||
f.golfamore_url,
|
f.golfamore_url,
|
||||||
f.nsg_url,
|
f.nsg_url,
|
||||||
|
{has_golfpakker_sql},
|
||||||
|
f.vtg_pris,
|
||||||
|
f.vtg_lenke,
|
||||||
|
f.vtg_beskrivelse,
|
||||||
|
f.amenities,
|
||||||
|
f.golfamore_data,
|
||||||
|
f.nsg_data,
|
||||||
|
f.vtg_datoer,
|
||||||
|
f.footnote,
|
||||||
|
f.footnote_updated_at,
|
||||||
|
f.status_updated_at,
|
||||||
|
{course_statuses_sql},
|
||||||
|
{weather_compact_sql}
|
||||||
|
FROM facilities f
|
||||||
|
WHERE COALESCE(f.is_published, TRUE) = TRUE
|
||||||
|
ORDER BY f.name ASC
|
||||||
|
""",
|
||||||
|
FACILITY_VIEW_SEARCH_FIELDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_view == "place":
|
||||||
|
return (
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
f.id,
|
||||||
|
f.slug,
|
||||||
|
f.name,
|
||||||
|
f.architect,
|
||||||
|
f.description,
|
||||||
|
f.city,
|
||||||
|
f.county,
|
||||||
|
f.banetype,
|
||||||
|
f.image_url,
|
||||||
|
f.phone,
|
||||||
|
f.website_url,
|
||||||
|
f.golfbox_booking_url,
|
||||||
|
f.golfbox_tournament_url,
|
||||||
|
f.weather_url,
|
||||||
|
f.lat,
|
||||||
|
f.lng,
|
||||||
|
f.golfamore,
|
||||||
|
f.golfamore_url,
|
||||||
|
f.nsg_url,
|
||||||
|
{has_golfpakker_sql},
|
||||||
f.greenfee,
|
f.greenfee,
|
||||||
f.standard_medlemskap,
|
f.standard_medlemskap,
|
||||||
f.vtg_pris,
|
f.vtg_pris,
|
||||||
|
|
@ -2806,144 +3073,164 @@ async def get_facilities(summary: bool = False):
|
||||||
f.footnote,
|
f.footnote,
|
||||||
f.footnote_updated_at,
|
f.footnote_updated_at,
|
||||||
f.status_updated_at,
|
f.status_updated_at,
|
||||||
(
|
{course_statuses_sql},
|
||||||
SELECT jsonb_agg(cs) FROM (
|
{total_hole_count_sql},
|
||||||
SELECT id, name, status FROM courses
|
{hole_par_counts_sql},
|
||||||
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to'
|
{shortest_hole_sql},
|
||||||
ORDER BY is_main_course DESC, id ASC
|
{longest_hole_sql},
|
||||||
) cs
|
{weather_compact_sql}
|
||||||
) as course_statuses,
|
|
||||||
(
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM holes h
|
|
||||||
JOIN courses c ON c.id = h.course_id
|
|
||||||
WHERE c.facility_id = f.id
|
|
||||||
) as total_hole_count,
|
|
||||||
(
|
|
||||||
SELECT jsonb_build_object(
|
|
||||||
'3', COUNT(*) FILTER (WHERE h.par = 3),
|
|
||||||
'4', COUNT(*) FILTER (WHERE h.par = 4),
|
|
||||||
'5', COUNT(*) FILTER (WHERE h.par = 5),
|
|
||||||
'6', COUNT(*) FILTER (WHERE h.par = 6)
|
|
||||||
)
|
|
||||||
FROM holes h
|
|
||||||
JOIN courses c ON c.id = h.course_id
|
|
||||||
WHERE c.facility_id = f.id
|
|
||||||
) as hole_par_counts,
|
|
||||||
(
|
|
||||||
SELECT MIN((length_value.value)::int)
|
|
||||||
FROM holes h
|
|
||||||
JOIN courses c ON c.id = h.course_id
|
|
||||||
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
|
|
||||||
WHERE c.facility_id = f.id
|
|
||||||
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
|
|
||||||
AND length_value.value ~ '^[0-9]+$'
|
|
||||||
AND (length_value.value)::int BETWEEN 30 AND 900
|
|
||||||
) as shortest_hole_meters,
|
|
||||||
(
|
|
||||||
SELECT MAX((length_value.value)::int)
|
|
||||||
FROM holes h
|
|
||||||
JOIN courses c ON c.id = h.course_id
|
|
||||||
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
|
|
||||||
WHERE c.facility_id = f.id
|
|
||||||
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
|
|
||||||
AND length_value.value ~ '^[0-9]+$'
|
|
||||||
AND (length_value.value)::int BETWEEN 30 AND 900
|
|
||||||
) as longest_hole_meters,
|
|
||||||
(
|
|
||||||
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
|
|
||||||
SELECT
|
|
||||||
forecast_date,
|
|
||||||
day_offset,
|
|
||||||
dry_all_day,
|
|
||||||
dry_daylight,
|
|
||||||
precip_mm,
|
|
||||||
precip_probability_max,
|
|
||||||
daylight_precip_mm,
|
|
||||||
daylight_precip_probability_max,
|
|
||||||
confidence,
|
|
||||||
source_updated_at,
|
|
||||||
source_expires_at,
|
|
||||||
calculated_at
|
|
||||||
FROM facility_weather_forecast
|
|
||||||
WHERE facility_id = f.id
|
|
||||||
ORDER BY day_offset ASC
|
|
||||||
) w_data
|
|
||||||
) as weather_forecast
|
|
||||||
FROM facilities f
|
FROM facilities f
|
||||||
WHERE COALESCE(f.is_published, TRUE) = TRUE
|
WHERE COALESCE(f.is_published, TRUE) = TRUE
|
||||||
ORDER BY f.name ASC
|
ORDER BY f.name ASC
|
||||||
""")
|
""",
|
||||||
else:
|
FACILITY_VIEW_PLACE_FIELDS,
|
||||||
rows = await conn.fetch("""
|
)
|
||||||
SELECT f.*, (
|
|
||||||
SELECT jsonb_agg(cs) FROM (
|
if normalized_view == "membership":
|
||||||
SELECT id, name, status FROM courses
|
return (
|
||||||
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to'
|
"""
|
||||||
ORDER BY is_main_course DESC, id ASC
|
SELECT
|
||||||
) cs
|
f.id,
|
||||||
) as course_statuses, (
|
f.slug,
|
||||||
SELECT COUNT(*)
|
f.name,
|
||||||
FROM holes h
|
f.city,
|
||||||
JOIN courses c ON c.id = h.course_id
|
f.county,
|
||||||
WHERE c.facility_id = f.id
|
f.medlemskap_url,
|
||||||
) as total_hole_count, (
|
f.membership_updated_at,
|
||||||
SELECT jsonb_build_object(
|
f.standard_medlemskap_kommentarer,
|
||||||
'3', COUNT(*) FILTER (WHERE h.par = 3),
|
f.navn_standard_medlemskap,
|
||||||
'4', COUNT(*) FILTER (WHERE h.par = 4),
|
f.standard_medlemskap,
|
||||||
'5', COUNT(*) FILTER (WHERE h.par = 5),
|
f.navn_rimeligste_alternativ,
|
||||||
'6', COUNT(*) FILTER (WHERE h.par = 6)
|
f.rimeligste_alternativ
|
||||||
)
|
|
||||||
FROM holes h
|
|
||||||
JOIN courses c ON c.id = h.course_id
|
|
||||||
WHERE c.facility_id = f.id
|
|
||||||
) as hole_par_counts, (
|
|
||||||
SELECT MIN((length_value.value)::int)
|
|
||||||
FROM holes h
|
|
||||||
JOIN courses c ON c.id = h.course_id
|
|
||||||
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
|
|
||||||
WHERE c.facility_id = f.id
|
|
||||||
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
|
|
||||||
AND length_value.value ~ '^[0-9]+$'
|
|
||||||
AND (length_value.value)::int BETWEEN 30 AND 900
|
|
||||||
) as shortest_hole_meters, (
|
|
||||||
SELECT MAX((length_value.value)::int)
|
|
||||||
FROM holes h
|
|
||||||
JOIN courses c ON c.id = h.course_id
|
|
||||||
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
|
|
||||||
WHERE c.facility_id = f.id
|
|
||||||
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
|
|
||||||
AND length_value.value ~ '^[0-9]+$'
|
|
||||||
AND (length_value.value)::int BETWEEN 30 AND 900
|
|
||||||
) as longest_hole_meters, (
|
|
||||||
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
|
|
||||||
SELECT
|
|
||||||
forecast_date,
|
|
||||||
day_offset,
|
|
||||||
dry_all_day,
|
|
||||||
dry_daylight,
|
|
||||||
precip_mm,
|
|
||||||
precip_probability_max,
|
|
||||||
daylight_precip_mm,
|
|
||||||
daylight_precip_probability_max,
|
|
||||||
confidence,
|
|
||||||
source_updated_at,
|
|
||||||
source_expires_at,
|
|
||||||
calculated_at
|
|
||||||
FROM facility_weather_forecast
|
|
||||||
WHERE facility_id = f.id
|
|
||||||
ORDER BY day_offset ASC
|
|
||||||
) w_data
|
|
||||||
) as weather_forecast
|
|
||||||
FROM facilities f
|
FROM facilities f
|
||||||
WHERE COALESCE(f.is_published, TRUE) = TRUE
|
WHERE COALESCE(f.is_published, TRUE) = TRUE
|
||||||
ORDER BY f.name ASC
|
ORDER BY f.name ASC
|
||||||
""")
|
""",
|
||||||
return [format_row(row) for row in rows]
|
FACILITY_VIEW_MEMBERSHIP_FIELDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_view == "vtg":
|
||||||
|
return (
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
f.id,
|
||||||
|
f.slug,
|
||||||
|
f.name,
|
||||||
|
f.city,
|
||||||
|
f.county,
|
||||||
|
f.lat,
|
||||||
|
f.lng,
|
||||||
|
f.status_updated_at,
|
||||||
|
f.vtg_pris,
|
||||||
|
f.vtg_lenke,
|
||||||
|
f.vtg_beskrivelse,
|
||||||
|
f.vtg_datoer,
|
||||||
|
f.vtg_updated_at
|
||||||
|
FROM facilities f
|
||||||
|
WHERE COALESCE(f.is_published, TRUE) = TRUE
|
||||||
|
ORDER BY f.name ASC
|
||||||
|
""",
|
||||||
|
FACILITY_VIEW_VTG_FIELDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_view == "clubnumbers":
|
||||||
|
return (
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
f.id,
|
||||||
|
f.slug,
|
||||||
|
f.name,
|
||||||
|
f.city,
|
||||||
|
f.county,
|
||||||
|
f.ngf_number
|
||||||
|
FROM facilities f
|
||||||
|
WHERE COALESCE(f.is_published, TRUE) = TRUE
|
||||||
|
ORDER BY f.name ASC
|
||||||
|
""",
|
||||||
|
FACILITY_VIEW_CLUBNUMBERS_FIELDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_view == "sitemap":
|
||||||
|
return (
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
f.slug,
|
||||||
|
f.status_updated_at,
|
||||||
|
f.vtg_updated_at
|
||||||
|
FROM facilities f
|
||||||
|
WHERE COALESCE(f.is_published, TRUE) = TRUE
|
||||||
|
ORDER BY f.name ASC
|
||||||
|
""",
|
||||||
|
FACILITY_VIEW_SITEMAP_FIELDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_view == "aliases":
|
||||||
|
return (
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
f.slug,
|
||||||
|
f.name
|
||||||
|
FROM facilities f
|
||||||
|
WHERE COALESCE(f.is_published, TRUE) = TRUE
|
||||||
|
ORDER BY f.name ASC
|
||||||
|
""",
|
||||||
|
FACILITY_VIEW_ALIASES_FIELDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
f.*,
|
||||||
|
{course_statuses_sql},
|
||||||
|
{total_hole_count_sql},
|
||||||
|
{hole_par_counts_sql},
|
||||||
|
{shortest_hole_sql},
|
||||||
|
{longest_hole_sql},
|
||||||
|
{weather_full_sql}
|
||||||
|
FROM facilities f
|
||||||
|
WHERE COALESCE(f.is_published, TRUE) = TRUE
|
||||||
|
ORDER BY f.name ASC
|
||||||
|
""",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/facilities")
|
||||||
|
async def get_facilities(
|
||||||
|
response: Response,
|
||||||
|
summary: bool = False,
|
||||||
|
view: str | None = Query(default=None),
|
||||||
|
):
|
||||||
|
"""Henter publiserte golfanlegg i profiler tilpasset offentlig bruk."""
|
||||||
|
resolved_view = "place" if summary and not view else view
|
||||||
|
cache_key = ((resolved_view or "").strip().lower() or "__default__")
|
||||||
|
cache_ttl = get_public_facilities_cache_ttl(resolved_view)
|
||||||
|
facilities_cache: dict[str, tuple[float, Any]] = getattr(app.state, "public_facilities_cache", {})
|
||||||
|
cached_payload = read_public_cache_entry(facilities_cache, cache_key)
|
||||||
|
if cached_payload is not None:
|
||||||
|
apply_public_cache_headers(response, cache_ttl)
|
||||||
|
return cached_payload
|
||||||
|
|
||||||
|
query, fields = build_public_facilities_query(resolved_view)
|
||||||
|
|
||||||
|
async with app.state.pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(query)
|
||||||
|
|
||||||
|
payload = [sanitize_public_facility_row(row, fields=fields) for row in rows]
|
||||||
|
write_public_cache_entry(facilities_cache, cache_key, payload, cache_ttl)
|
||||||
|
apply_public_cache_headers(response, cache_ttl)
|
||||||
|
return payload
|
||||||
|
|
||||||
@app.get("/api/facilities/{slug}")
|
@app.get("/api/facilities/{slug}")
|
||||||
async def get_facility(slug: str):
|
async def get_facility(slug: str, response: Response):
|
||||||
"""Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull."""
|
"""Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull."""
|
||||||
|
normalized_slug = str(slug or "").strip().lower()
|
||||||
|
detail_cache: dict[str, tuple[float, Any]] = getattr(app.state, "public_facility_detail_cache", {})
|
||||||
|
cached_payload = read_public_cache_entry(detail_cache, normalized_slug)
|
||||||
|
if cached_payload is not None:
|
||||||
|
apply_public_cache_headers(response, PUBLIC_FACILITY_DETAIL_CACHE_TTL_SECONDS)
|
||||||
|
return cached_payload
|
||||||
|
|
||||||
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.*, (
|
||||||
|
|
@ -2984,16 +3271,30 @@ async def get_facility(slug: str):
|
||||||
|
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
|
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
|
||||||
|
|
||||||
return format_row(row)
|
payload = sanitize_public_facility_row(row)
|
||||||
|
write_public_cache_entry(
|
||||||
|
detail_cache,
|
||||||
|
normalized_slug,
|
||||||
|
payload,
|
||||||
|
PUBLIC_FACILITY_DETAIL_CACHE_TTL_SECONDS,
|
||||||
|
)
|
||||||
|
apply_public_cache_headers(response, PUBLIC_FACILITY_DETAIL_CACHE_TTL_SECONDS)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/place-pages/{slug}")
|
@app.get("/api/place-pages/{slug}")
|
||||||
async def get_place_page(slug: str):
|
async def get_place_page(slug: str, response: Response):
|
||||||
normalized_slug = str(slug or "").strip().lower()
|
normalized_slug = str(slug or "").strip().lower()
|
||||||
if not normalized_slug:
|
if not normalized_slug:
|
||||||
raise HTTPException(status_code=400, detail="Slug mangler.")
|
raise HTTPException(status_code=400, detail="Slug mangler.")
|
||||||
|
|
||||||
|
place_page_cache: dict[str, tuple[float, Any]] = getattr(app.state, "public_place_page_cache", {})
|
||||||
|
cached_payload = read_public_cache_entry(place_page_cache, normalized_slug)
|
||||||
|
if cached_payload is not None:
|
||||||
|
apply_public_cache_headers(response, PUBLIC_PLACE_PAGE_CACHE_TTL_SECONDS)
|
||||||
|
return cached_payload
|
||||||
|
|
||||||
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 * FROM place_pages WHERE slug = $1",
|
"SELECT * FROM place_pages WHERE slug = $1",
|
||||||
|
|
@ -3008,7 +3309,15 @@ async def get_place_page(slug: str):
|
||||||
"updated_at": None,
|
"updated_at": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
return format_place_page_row(row)
|
payload = format_place_page_row(row)
|
||||||
|
write_public_cache_entry(
|
||||||
|
place_page_cache,
|
||||||
|
normalized_slug,
|
||||||
|
payload,
|
||||||
|
PUBLIC_PLACE_PAGE_CACHE_TTL_SECONDS,
|
||||||
|
)
|
||||||
|
apply_public_cache_headers(response, PUBLIC_PLACE_PAGE_CACHE_TTL_SECONDS)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/facilities")
|
@app.get("/api/admin/facilities")
|
||||||
|
|
@ -3144,6 +3453,7 @@ async def create_admin_facility(request: Request):
|
||||||
"SELECT id, slug, name, is_published FROM facilities WHERE id = $1",
|
"SELECT id, slug, name, is_published FROM facilities WHERE id = $1",
|
||||||
facility_id,
|
facility_id,
|
||||||
)
|
)
|
||||||
|
invalidate_public_api_caches()
|
||||||
|
|
||||||
schedule_facility_indexnow_submission_for_fields(
|
schedule_facility_indexnow_submission_for_fields(
|
||||||
facility_slug,
|
facility_slug,
|
||||||
|
|
@ -3200,6 +3510,7 @@ async def update_admin_place_page(slug: str, request: PlacePageUpsertRequest):
|
||||||
request.factbox_intro_html or "",
|
request.factbox_intro_html or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
invalidate_public_api_caches(include_place_pages=True)
|
||||||
return format_place_page_row(row)
|
return format_place_page_row(row)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -3649,6 +3960,7 @@ async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdat
|
||||||
)
|
)
|
||||||
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", new_status, c.id)
|
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", new_status, c.id)
|
||||||
|
|
||||||
|
invalidate_public_api_caches()
|
||||||
return {"status": "success", "message": f"Skrapeinnstillinger for anlegg ID {facility_id} ble oppdatert."}
|
return {"status": "success", "message": f"Skrapeinnstillinger for anlegg ID {facility_id} ble oppdatert."}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -3665,6 +3977,7 @@ async def update_facility_full(facility_id: int, request: Request):
|
||||||
async with app.state.pool.acquire() as conn:
|
async with app.state.pool.acquire() as conn:
|
||||||
async with conn.transaction():
|
async with conn.transaction():
|
||||||
facility_slug, changed_field_names = await save_facility_full(conn, facility_id, data)
|
facility_slug, changed_field_names = await save_facility_full(conn, facility_id, data)
|
||||||
|
invalidate_public_api_caches()
|
||||||
|
|
||||||
schedule_facility_indexnow_submission_for_fields(
|
schedule_facility_indexnow_submission_for_fields(
|
||||||
facility_slug,
|
facility_slug,
|
||||||
|
|
@ -3687,6 +4000,7 @@ async def delete_facility(facility_id: int):
|
||||||
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
|
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
|
||||||
|
|
||||||
deleted_slug = str(deleted["slug"] or "").strip()
|
deleted_slug = str(deleted["slug"] or "").strip()
|
||||||
|
invalidate_public_api_caches()
|
||||||
schedule_indexnow_submission(
|
schedule_indexnow_submission(
|
||||||
collect_facility_indexnow_urls([deleted_slug], extra_paths=["/golfbaner", "/medlemskap", "/vtg"]),
|
collect_facility_indexnow_urls([deleted_slug], extra_paths=["/golfbaner", "/medlemskap", "/vtg"]),
|
||||||
reason="facility delete",
|
reason="facility delete",
|
||||||
|
|
@ -3830,6 +4144,7 @@ async def approve_membership_bulk(request: BulkApprovalRequest):
|
||||||
approval.rimeligste_alternativ,
|
approval.rimeligste_alternativ,
|
||||||
approval.facility_id)
|
approval.facility_id)
|
||||||
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
||||||
|
invalidate_public_api_caches()
|
||||||
schedule_indexnow_submission(
|
schedule_indexnow_submission(
|
||||||
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/medlemskap", "/golfbaner"]),
|
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/medlemskap", "/golfbaner"]),
|
||||||
reason="membership bulk approval",
|
reason="membership bulk approval",
|
||||||
|
|
@ -3866,6 +4181,7 @@ async def quick_edit_facility(facility_id: int, request: QuickEditRequest):
|
||||||
# F-string her er trygt fordi request.field er sjekket mot allowed_fields-listen
|
# F-string her er trygt fordi request.field er sjekket mot allowed_fields-listen
|
||||||
await conn.execute(f"UPDATE facilities SET {request.field} = $1 WHERE id = $2",
|
await conn.execute(f"UPDATE facilities SET {request.field} = $1 WHERE id = $2",
|
||||||
request.value, facility_id)
|
request.value, facility_id)
|
||||||
|
invalidate_public_api_caches()
|
||||||
schedule_indexnow_submission(
|
schedule_indexnow_submission(
|
||||||
collect_facility_indexnow_urls([facility_slug], extra_paths=["/golfbaner"]),
|
collect_facility_indexnow_urls([facility_slug], extra_paths=["/golfbaner"]),
|
||||||
reason=f"facility quick edit ({request.field})",
|
reason=f"facility quick edit ({request.field})",
|
||||||
|
|
@ -3934,6 +4250,7 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
|
||||||
WHERE id = $2
|
WHERE id = $2
|
||||||
""", json.dumps(approval.greenfee), approval.facility_id)
|
""", json.dumps(approval.greenfee), approval.facility_id)
|
||||||
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
||||||
|
invalidate_public_api_caches()
|
||||||
schedule_indexnow_submission(
|
schedule_indexnow_submission(
|
||||||
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/golfbaner"]),
|
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/golfbaner"]),
|
||||||
reason="greenfee bulk approval",
|
reason="greenfee bulk approval",
|
||||||
|
|
@ -4006,6 +4323,7 @@ async def approve_vtg_content_bulk(request: BulkVtgContentRequest):
|
||||||
approval.facility_id,
|
approval.facility_id,
|
||||||
)
|
)
|
||||||
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
||||||
|
invalidate_public_api_caches()
|
||||||
schedule_indexnow_submission(
|
schedule_indexnow_submission(
|
||||||
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]),
|
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]),
|
||||||
reason="vtg content bulk approval",
|
reason="vtg content bulk approval",
|
||||||
|
|
@ -4035,6 +4353,7 @@ async def approve_vtg_courses_bulk(request: BulkVtgCoursesRequest):
|
||||||
approval.facility_id,
|
approval.facility_id,
|
||||||
)
|
)
|
||||||
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
||||||
|
invalidate_public_api_caches()
|
||||||
schedule_indexnow_submission(
|
schedule_indexnow_submission(
|
||||||
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]),
|
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]),
|
||||||
reason="vtg courses bulk approval",
|
reason="vtg courses bulk approval",
|
||||||
|
|
@ -4068,6 +4387,7 @@ async def approve_vtg_bulk(request: BulkVtgRequest):
|
||||||
approval.facility_id,
|
approval.facility_id,
|
||||||
)
|
)
|
||||||
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
||||||
|
invalidate_public_api_caches()
|
||||||
schedule_indexnow_submission(
|
schedule_indexnow_submission(
|
||||||
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]),
|
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]),
|
||||||
reason="vtg bulk approval",
|
reason="vtg bulk approval",
|
||||||
|
|
@ -4110,6 +4430,7 @@ async def approve_golfpakker_bulk(request: BulkGolfpakkerRequest):
|
||||||
WHERE id = $2
|
WHERE id = $2
|
||||||
""", json.dumps(approval.golfpakker), approval.facility_id)
|
""", json.dumps(approval.golfpakker), approval.facility_id)
|
||||||
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
||||||
|
invalidate_public_api_caches()
|
||||||
schedule_indexnow_submission(
|
schedule_indexnow_submission(
|
||||||
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/golfbaner"]),
|
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/golfbaner"]),
|
||||||
reason="golfpakker bulk approval",
|
reason="golfpakker bulk approval",
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ type Facility = {
|
||||||
golfamore?: boolean | null;
|
golfamore?: boolean | null;
|
||||||
golfamore_url?: string | null;
|
golfamore_url?: string | null;
|
||||||
nsg_url?: string | null;
|
nsg_url?: string | null;
|
||||||
|
has_golfpakker?: boolean | null;
|
||||||
vtg_pris?: number | null;
|
vtg_pris?: number | null;
|
||||||
vtg_lenke?: string | null;
|
vtg_lenke?: string | null;
|
||||||
vtg_beskrivelse?: string | null;
|
vtg_beskrivelse?: string | null;
|
||||||
|
|
@ -75,6 +76,7 @@ type FacilitySearchProps = {
|
||||||
type SpecialFlags = {
|
type SpecialFlags = {
|
||||||
hasGolfamore: boolean;
|
hasGolfamore: boolean;
|
||||||
hasNSG: boolean;
|
hasNSG: boolean;
|
||||||
|
hasGolfPackages: boolean;
|
||||||
hasSimulator: boolean;
|
hasSimulator: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -391,6 +393,7 @@ const matchesSpecialFilter = (specialFilter: string, flags: SpecialFlags) => {
|
||||||
if (!specialFilter) return true;
|
if (!specialFilter) return true;
|
||||||
if (specialFilter === "golfamore") return flags.hasGolfamore;
|
if (specialFilter === "golfamore") return flags.hasGolfamore;
|
||||||
if (specialFilter === "nsg") return flags.hasNSG;
|
if (specialFilter === "nsg") return flags.hasNSG;
|
||||||
|
if (specialFilter === "golfpakke") return flags.hasGolfPackages;
|
||||||
if (specialFilter === "simulator") return flags.hasSimulator;
|
if (specialFilter === "simulator") return flags.hasSimulator;
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
@ -644,6 +647,7 @@ export default function FacilitySearch({
|
||||||
Boolean(facility.golfamore_url) ||
|
Boolean(facility.golfamore_url) ||
|
||||||
Object.keys(golfamoreData).length > 0;
|
Object.keys(golfamoreData).length > 0;
|
||||||
const hasNSG = Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0;
|
const hasNSG = Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0;
|
||||||
|
const hasGolfPackages = facility.has_golfpakker === true;
|
||||||
const hasSimulator = hasTruthyAmenity(amenities.simulator);
|
const hasSimulator = hasTruthyAmenity(amenities.simulator);
|
||||||
const hasDrivingRange = hasTruthyAmenity(amenities.drivingrange);
|
const hasDrivingRange = hasTruthyAmenity(amenities.drivingrange);
|
||||||
const vtgDates = parseJson<unknown[]>(facility.vtg_datoer, []);
|
const vtgDates = parseJson<unknown[]>(facility.vtg_datoer, []);
|
||||||
|
|
@ -676,6 +680,7 @@ export default function FacilitySearch({
|
||||||
|
|
||||||
if (hasGolfamore) searchBlob += " golfamore";
|
if (hasGolfamore) searchBlob += " golfamore";
|
||||||
if (hasNSG) searchBlob += " nsg seniorgolf";
|
if (hasNSG) searchBlob += " nsg seniorgolf";
|
||||||
|
if (hasGolfPackages) searchBlob += " golfpakke golfpakker";
|
||||||
if (hasSimulator) searchBlob += " simulator";
|
if (hasSimulator) searchBlob += " simulator";
|
||||||
if (normalizedStatuses.includes("aapen")) searchBlob += " apen apne";
|
if (normalizedStatuses.includes("aapen")) searchBlob += " apen apne";
|
||||||
if (normalizedStatuses.includes("stengt")) searchBlob += " stengt";
|
if (normalizedStatuses.includes("stengt")) searchBlob += " stengt";
|
||||||
|
|
@ -700,6 +705,7 @@ export default function FacilitySearch({
|
||||||
const matchesSpecial = matchesSpecialFilter(specialFilter, {
|
const matchesSpecial = matchesSpecialFilter(specialFilter, {
|
||||||
hasGolfamore,
|
hasGolfamore,
|
||||||
hasNSG,
|
hasNSG,
|
||||||
|
hasGolfPackages,
|
||||||
hasSimulator,
|
hasSimulator,
|
||||||
});
|
});
|
||||||
const selectedWeatherDayOffset = Number.parseInt(weatherDayFilter, 10);
|
const selectedWeatherDayOffset = Number.parseInt(weatherDayFilter, 10);
|
||||||
|
|
@ -717,6 +723,7 @@ export default function FacilitySearch({
|
||||||
primaryStatus,
|
primaryStatus,
|
||||||
hasGolfamore,
|
hasGolfamore,
|
||||||
hasNSG,
|
hasNSG,
|
||||||
|
hasGolfPackages,
|
||||||
hasSimulator,
|
hasSimulator,
|
||||||
hasDrivingRange,
|
hasDrivingRange,
|
||||||
hasVtg,
|
hasVtg,
|
||||||
|
|
@ -892,10 +899,11 @@ export default function FacilitySearch({
|
||||||
))}
|
))}
|
||||||
</FieldSelect>
|
</FieldSelect>
|
||||||
|
|
||||||
<FieldSelect label="NSG / GOLFAMORE" value={specialFilter} onChange={setSpecialFilter} labelClassName={labelClassName}>
|
<FieldSelect label="NSG / GOLFAMORE / GOLFPAKKE" value={specialFilter} onChange={setSpecialFilter} labelClassName={labelClassName}>
|
||||||
<option value="">Ikke hensyntatt</option>
|
<option value="">Ikke hensyntatt</option>
|
||||||
<option value="golfamore">Golfamore</option>
|
<option value="golfamore">Golfamore</option>
|
||||||
<option value="nsg">Seniorgolf / NSG</option>
|
<option value="nsg">Seniorgolf / NSG</option>
|
||||||
|
<option value="golfpakke">Tilbyr golfpakke</option>
|
||||||
</FieldSelect>
|
</FieldSelect>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ type Facility = {
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
image_url?: string | null;
|
image_url?: string | null;
|
||||||
course_statuses?: CourseStatus[] | null;
|
course_statuses?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hashString = (value: string) => {
|
const hashString = (value: string) => {
|
||||||
|
|
|
||||||
|
|
@ -115,8 +115,8 @@ function buildFacilityAliasMap(facilities: FacilityAliasSource[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getFacilityAliasMap() {
|
async function getFacilityAliasMap() {
|
||||||
const response = await fetch(`${API_URL}/facilities?summary=true`, {
|
const response = await fetch(`${API_URL}/facilities?view=aliases`, {
|
||||||
cache: "no-store",
|
next: { revalidate: 3600 },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ export type FacilityRecord = {
|
||||||
golfamore?: boolean | null;
|
golfamore?: boolean | null;
|
||||||
golfamore_url?: string | null;
|
golfamore_url?: string | null;
|
||||||
nsg_url?: string | null;
|
nsg_url?: string | null;
|
||||||
|
has_golfpakker?: boolean | null;
|
||||||
greenfee?: unknown;
|
greenfee?: unknown;
|
||||||
standard_medlemskap?: number | null;
|
standard_medlemskap?: number | null;
|
||||||
total_hole_count?: number | null;
|
total_hole_count?: number | null;
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ export default async function OpenGraphImage({ params }: OpenGraphImageProps) {
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "flex",
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
background: "rgba(139, 195, 74, 0.92)",
|
background: "rgba(139, 195, 74, 0.92)",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import FacilitySearch from "@/app/FacilitySearch";
|
import FacilitySearch from "@/app/FacilitySearch";
|
||||||
import { API_URL } from "@/config/constants";
|
import type { FacilityRecord } from "@/app/facilityData";
|
||||||
|
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||||
import {
|
import {
|
||||||
createBreadcrumbJsonLd,
|
createBreadcrumbJsonLd,
|
||||||
createCollectionPageJsonLd,
|
createCollectionPageJsonLd,
|
||||||
|
|
@ -7,6 +8,7 @@ import {
|
||||||
createPageMetadata,
|
createPageMetadata,
|
||||||
} from "@/app/seo";
|
} from "@/app/seo";
|
||||||
|
|
||||||
|
export const revalidate = 900;
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const pageTitle = "Golfbaner i Norge";
|
const pageTitle = "Golfbaner i Norge";
|
||||||
|
|
@ -20,25 +22,7 @@ export const metadata = createPageMetadata({
|
||||||
});
|
});
|
||||||
|
|
||||||
export default async function GolfCoursesIndexPage() {
|
export default async function GolfCoursesIndexPage() {
|
||||||
let facilities = [];
|
const safeData = await fetchPublicFacilities<FacilityRecord>("search", revalidate);
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/facilities`, {
|
|
||||||
next: { revalidate: 0 },
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`API returnerte status ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
facilities = await res.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Kritisk feil ved henting av golfbaner:", error);
|
|
||||||
facilities = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeData = Array.isArray(facilities) ? facilities : [];
|
|
||||||
const collectionJsonLd = createCollectionPageJsonLd({
|
const collectionJsonLd = createCollectionPageJsonLd({
|
||||||
name: pageTitle,
|
name: pageTitle,
|
||||||
description: pageDescription,
|
description: pageDescription,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { API_URL } from "@/config/constants";
|
|
||||||
import type { FacilityRecord } from "@/app/facilityData";
|
import type { FacilityRecord } from "@/app/facilityData";
|
||||||
|
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||||
import InfoPageShell from "@/components/InfoPageShell";
|
import InfoPageShell from "@/components/InfoPageShell";
|
||||||
import ClubNumbersTable from "@/components/ClubNumbersTable";
|
import ClubNumbersTable from "@/components/ClubNumbersTable";
|
||||||
import {
|
import {
|
||||||
|
|
@ -18,27 +18,11 @@ export const metadata = createPageMetadata({
|
||||||
path: "/klubbnummer",
|
path: "/klubbnummer",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const revalidate = 3600;
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function ClubNumbersPage() {
|
export default async function ClubNumbersPage() {
|
||||||
let facilities: FacilityRecord[] = [];
|
const facilities = await fetchPublicFacilities<FacilityRecord>("clubnumbers", revalidate);
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/facilities`, {
|
|
||||||
next: { revalidate: 0 },
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`API returnerte status ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
facilities = Array.isArray(data) ? data : [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Kunne ikke hente klubbnummer:", error);
|
|
||||||
facilities = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const collectionJsonLd = createCollectionPageJsonLd({
|
const collectionJsonLd = createCollectionPageJsonLd({
|
||||||
name: pageTitle,
|
name: pageTitle,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { API_URL } from "@/config/constants";
|
|
||||||
import MembershipExplorer, { type MembershipFacility } from "./MembershipExplorer";
|
import MembershipExplorer, { type MembershipFacility } from "./MembershipExplorer";
|
||||||
|
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||||
import {
|
import {
|
||||||
createBreadcrumbJsonLd,
|
createBreadcrumbJsonLd,
|
||||||
createCollectionPageJsonLd,
|
createCollectionPageJsonLd,
|
||||||
createPageMetadata,
|
createPageMetadata,
|
||||||
} from "@/app/seo";
|
} from "@/app/seo";
|
||||||
|
|
||||||
|
export const revalidate = 1800;
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const pageTitle = "Medlemskap i norske golfklubber";
|
const pageTitle = "Medlemskap i norske golfklubber";
|
||||||
|
|
@ -19,24 +20,7 @@ export const metadata = createPageMetadata({
|
||||||
});
|
});
|
||||||
|
|
||||||
export default async function MembershipPage() {
|
export default async function MembershipPage() {
|
||||||
let facilities: MembershipFacility[] = [];
|
const facilities = await fetchPublicFacilities<MembershipFacility>("membership", revalidate);
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/facilities`, {
|
|
||||||
next: { revalidate: 0 },
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`API returnerte status ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
facilities = Array.isArray(data) ? data : [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Kunne ikke hente medlemsdata:", error);
|
|
||||||
facilities = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleFacilities = facilities.filter(
|
const visibleFacilities = facilities.filter(
|
||||||
(facility) =>
|
(facility) =>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import FacilitySearch from "./FacilitySearch";
|
import FacilitySearch from "./FacilitySearch";
|
||||||
import HeroSlider from "./HeroSlider";
|
import HeroSlider from "./HeroSlider";
|
||||||
import { API_URL } from "@/config/constants";
|
import type { FacilityRecord } from "@/app/facilityData";
|
||||||
|
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||||
import { createPageMetadata } from "@/app/seo";
|
import { createPageMetadata } from "@/app/seo";
|
||||||
|
|
||||||
|
export const revalidate = 900;
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const metadata = createPageMetadata({
|
export const metadata = createPageMetadata({
|
||||||
title: "Komplett oversikt over ALLE norske golfbaner",
|
title: "Komplett oversikt over ALLE norske golfbaner",
|
||||||
|
|
@ -14,25 +16,7 @@ export const metadata = createPageMetadata({
|
||||||
const getHeroRotationSeed = () => new Date().toISOString().slice(0, 13);
|
const getHeroRotationSeed = () => new Date().toISOString().slice(0, 13);
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
let facilities = [];
|
const safeData = await fetchPublicFacilities<FacilityRecord>("search", revalidate);
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/facilities`, {
|
|
||||||
next: { revalidate: 0 },
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`API returnerte status ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
facilities = await res.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Kritisk feil ved henting av data:", error);
|
|
||||||
facilities = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeData = Array.isArray(facilities) ? facilities : [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="site-shell min-h-screen">
|
<main className="site-shell min-h-screen">
|
||||||
|
|
|
||||||
47
frontend/src/app/publicFacilities.ts
Normal file
47
frontend/src/app/publicFacilities.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { API_URL } from "@/config/constants";
|
||||||
|
|
||||||
|
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
export async function fetchPublicFacilities<T>(
|
||||||
|
view: string,
|
||||||
|
revalidateSeconds: number,
|
||||||
|
{
|
||||||
|
allowEmpty = false,
|
||||||
|
attempts = 3,
|
||||||
|
}: {
|
||||||
|
allowEmpty?: boolean;
|
||||||
|
attempts?: number;
|
||||||
|
} = {},
|
||||||
|
): Promise<T[]> {
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/facilities?view=${view}`, {
|
||||||
|
next: { revalidate: revalidateSeconds },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API returnerte status ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error("API returnerte ikke en liste");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowEmpty && data.length === 0) {
|
||||||
|
throw new Error("API returnerte tom liste");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T[];
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error("Ukjent feil ved henting av anlegg");
|
||||||
|
if (attempt < attempts) {
|
||||||
|
await delay(attempt * 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? new Error("Kunne ikke hente anlegg");
|
||||||
|
}
|
||||||
|
|
@ -42,7 +42,7 @@ type FacilitySeoRecord = FacilityRecord & {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SITE_NAME = "TeeOff";
|
export const SITE_NAME = "TeeOff";
|
||||||
export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL || "https://nye.teeoff.no").replace(/\/$/, "");
|
export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL || "https://teeoff.no").replace(/\/$/, "");
|
||||||
export const DEFAULT_DESCRIPTION =
|
export const DEFAULT_DESCRIPTION =
|
||||||
"Oppdatert banestatus, priser, Veien til Golf og informasjon om norske golfbaner samlet på ett sted.";
|
"Oppdatert banestatus, priser, Veien til Golf og informasjon om norske golfbaner samlet på ett sted.";
|
||||||
export const DEFAULT_OG_IMAGE = buildAbsoluteUrl(FALLBACK_IMAGE);
|
export const DEFAULT_OG_IMAGE = buildAbsoluteUrl(FALLBACK_IMAGE);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ type SitemapFacility = {
|
||||||
vtg_updated_at?: string | null;
|
vtg_updated_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const revalidate = 3600;
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const staticRoutes: MetadataRoute.Sitemap = [
|
const staticRoutes: MetadataRoute.Sitemap = [
|
||||||
{
|
{
|
||||||
url: buildAbsoluteUrl("/"),
|
url: buildAbsoluteUrl("/"),
|
||||||
|
|
@ -83,7 +86,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
let facilities: SitemapFacility[] = [];
|
let facilities: SitemapFacility[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/facilities`, { cache: "no-store" });
|
const res = await fetch(`${API_URL}/facilities?view=sitemap`, {
|
||||||
|
next: { revalidate },
|
||||||
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
facilities = Array.isArray(data) ? data : [];
|
facilities = Array.isArray(data) ? data : [];
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
createItemListJsonLd,
|
createItemListJsonLd,
|
||||||
createPageMetadata,
|
createPageMetadata,
|
||||||
} from "@/app/seo";
|
} from "@/app/seo";
|
||||||
|
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||||
|
|
||||||
type PlacePageData = {
|
type PlacePageData = {
|
||||||
slug?: string;
|
slug?: string;
|
||||||
|
|
@ -124,27 +125,13 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
let facilities: FacilityRecord[] = [];
|
|
||||||
let placePage: PlacePageData | null = null;
|
let placePage: PlacePageData | null = null;
|
||||||
|
|
||||||
try {
|
const facilities = await fetchPublicFacilities<FacilityRecord>("place", revalidate);
|
||||||
const res = await fetch(`${API_URL}/facilities?summary=1`, {
|
|
||||||
next: { revalidate },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`API returnerte status ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
facilities = await res.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Kritisk feil ved henting av sted-data:", error);
|
|
||||||
facilities = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/place-pages/${slug}`, {
|
const res = await fetch(`${API_URL}/place-pages/${slug}`, {
|
||||||
cache: "no-store",
|
next: { revalidate },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { API_URL } from "@/config/constants";
|
|
||||||
import VtgExplorer from "./VtgExplorer";
|
import VtgExplorer from "./VtgExplorer";
|
||||||
|
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||||
import type { FacilityRecord } from "@/app/facilityData";
|
import type { FacilityRecord } from "@/app/facilityData";
|
||||||
import {
|
import {
|
||||||
createBreadcrumbJsonLd,
|
createBreadcrumbJsonLd,
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
createPageMetadata,
|
createPageMetadata,
|
||||||
} from "@/app/seo";
|
} from "@/app/seo";
|
||||||
|
|
||||||
|
export const revalidate = 1800;
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const pageTitle = "Veien til Golf";
|
const pageTitle = "Veien til Golf";
|
||||||
|
|
@ -20,24 +21,7 @@ export const metadata = createPageMetadata({
|
||||||
});
|
});
|
||||||
|
|
||||||
export default async function VtgPage() {
|
export default async function VtgPage() {
|
||||||
let facilities: FacilityRecord[] = [];
|
const facilities = await fetchPublicFacilities<FacilityRecord>("vtg", revalidate);
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/facilities`, {
|
|
||||||
next: { revalidate: 0 },
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`API returnerte status ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
facilities = Array.isArray(data) ? data : [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Kunne ikke hente VTG-data:", error);
|
|
||||||
facilities = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const collectionJsonLd = createCollectionPageJsonLd({
|
const collectionJsonLd = createCollectionPageJsonLd({
|
||||||
name: pageTitle,
|
name: pageTitle,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue