Før endring i skraping
This commit is contained in:
parent
1ff8ee2c26
commit
20e694cda5
24 changed files with 1466 additions and 103 deletions
BIN
2026-05-05 19.13.57 teeoff.no 745d5e8e5e2a.jpg
Normal file
BIN
2026-05-05 19.13.57 teeoff.no 745d5e8e5e2a.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
|
|
@ -137,7 +137,7 @@ async def run_master_import():
|
||||||
c_name = acf.get('navn_pa_hovedbane' if suffix == '' else 'navn_pa_sekundar_bane') or ('Hovedbanen' if suffix == '' else 'Bane 2')
|
c_name = acf.get('navn_pa_hovedbane' if suffix == '' else 'navn_pa_sekundar_bane') or ('Hovedbanen' if suffix == '' else 'Bane 2')
|
||||||
status = acf.get('banestatus' if suffix == '' else 'banestatus_sekundar_bane')
|
status = acf.get('banestatus' if suffix == '' else 'banestatus_sekundar_bane')
|
||||||
if suffix == '_bane_to' and (status == 'finnes_ingen_bane_to' or not parse_int(acf.get('hull_1_par_bane_to'))): continue
|
if suffix == '_bane_to' and (status == 'finnes_ingen_bane_to' or not parse_int(acf.get('hull_1_par_bane_to'))): continue
|
||||||
course_id = await conn.fetchval('INSERT INTO courses (facility_id, name, status, par, is_main_course, tee_boxes, architect) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id', fac_id, c_name, status, parse_int(acf.get('totalt_par' if suffix == '' else 'totalt_par_bane_to')), (suffix == ''), json.dumps({"herrer": acf.get(f"utslag_herrer{suffix}"), "damer": acf.get(f"utslag_damer{suffix}")}), decode_html(acf.get('arkitekt')))
|
course_id = await conn.fetchval('INSERT INTO courses (facility_id, name, status, par, physical_hole_count, is_main_course, tee_boxes, architect) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id', fac_id, c_name, status, parse_int(acf.get('totalt_par' if suffix == '' else 'totalt_par_bane_to')), parse_int(acf.get('antall_hull')) if suffix == '' else None, (suffix == ''), json.dumps({"herrer": acf.get(f"utslag_herrer{suffix}"), "damer": acf.get(f"utslag_damer{suffix}")}), decode_html(acf.get('arkitekt')))
|
||||||
curr_len = 0
|
curr_len = 0
|
||||||
for h_num in range(1, 19):
|
for h_num in range(1, 19):
|
||||||
p = parse_int(acf.get(f'hull_{h_num}_par{suffix}'))
|
p = parse_int(acf.get(f'hull_{h_num}_par{suffix}'))
|
||||||
|
|
|
||||||
218
backend/main.py
218
backend/main.py
|
|
@ -90,6 +90,11 @@ FACILITY_RATING_NOTIFICATION_TO_EMAIL = os.getenv(
|
||||||
INDEXNOW_KEY = os.getenv("INDEXNOW_KEY", "").strip()
|
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()
|
||||||
|
FRONTEND_REVALIDATE_URL = os.getenv(
|
||||||
|
"FRONTEND_REVALIDATE_URL",
|
||||||
|
"http://frontend:3000/internal/revalidate-public",
|
||||||
|
).strip()
|
||||||
|
FRONTEND_REVALIDATE_SECRET = os.getenv("FRONTEND_REVALIDATE_SECRET", PUBLIC_SESSION_SECRET).strip()
|
||||||
|
|
||||||
PUBLIC_FACILITIES_CACHE_TTLS = {
|
PUBLIC_FACILITIES_CACHE_TTLS = {
|
||||||
"search": 900,
|
"search": 900,
|
||||||
|
|
@ -189,14 +194,45 @@ def apply_public_cache_headers(response: Response, ttl_seconds: int) -> None:
|
||||||
response.headers["Cache-Control"] = f"public, max-age=60, s-maxage={ttl}, stale-while-revalidate=60"
|
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, include_site_pages: bool = False) -> None:
|
async def trigger_frontend_public_revalidation(
|
||||||
facilities_cache = getattr(app.state, "public_facilities_cache", None)
|
*,
|
||||||
if isinstance(facilities_cache, dict):
|
include_facilities: bool,
|
||||||
facilities_cache.clear()
|
include_place_pages: bool = False,
|
||||||
|
include_site_pages: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if not FRONTEND_REVALIDATE_URL or not FRONTEND_REVALIDATE_SECRET:
|
||||||
|
return
|
||||||
|
|
||||||
detail_cache = getattr(app.state, "public_facility_detail_cache", None)
|
try:
|
||||||
if isinstance(detail_cache, dict):
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
detail_cache.clear()
|
response = await client.post(
|
||||||
|
FRONTEND_REVALIDATE_URL,
|
||||||
|
headers={"x-teeoff-revalidate-secret": FRONTEND_REVALIDATE_SECRET},
|
||||||
|
json={
|
||||||
|
"includeFacilities": include_facilities,
|
||||||
|
"includePlacePages": include_place_pages,
|
||||||
|
"includeSitePages": include_site_pages,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Advarsel: kunne ikke revalidere frontend-cache ({exc})")
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_public_api_caches(
|
||||||
|
*,
|
||||||
|
include_facilities: bool = True,
|
||||||
|
include_place_pages: bool = False,
|
||||||
|
include_site_pages: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if include_facilities:
|
||||||
|
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:
|
if include_place_pages:
|
||||||
place_page_cache = getattr(app.state, "public_place_page_cache", None)
|
place_page_cache = getattr(app.state, "public_place_page_cache", None)
|
||||||
|
|
@ -208,6 +244,19 @@ def invalidate_public_api_caches(*, include_place_pages: bool = False, include_s
|
||||||
if isinstance(site_page_cache, dict):
|
if isinstance(site_page_cache, dict):
|
||||||
site_page_cache.clear()
|
site_page_cache.clear()
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
return
|
||||||
|
|
||||||
|
loop.create_task(
|
||||||
|
trigger_frontend_public_revalidation(
|
||||||
|
include_facilities=include_facilities,
|
||||||
|
include_place_pages=include_place_pages,
|
||||||
|
include_site_pages=include_site_pages,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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"):
|
||||||
|
|
@ -960,8 +1009,8 @@ FACILITY_VIEW_SEARCH_FIELDS = {
|
||||||
'weather_url', 'lat', 'lng', 'golfamore', 'golfamore_url', 'nsg_url', 'has_golfpakker',
|
'weather_url', 'lat', 'lng', 'golfamore', 'golfamore_url', 'nsg_url', 'has_golfpakker',
|
||||||
'vtg_pris', 'vtg_lenke', 'vtg_beskrivelse', 'camper_parking', 'meta_title', 'meta_description',
|
'vtg_pris', 'vtg_lenke', 'vtg_beskrivelse', 'camper_parking', 'meta_title', 'meta_description',
|
||||||
'footnote', 'footnote_updated_at',
|
'footnote', 'footnote_updated_at',
|
||||||
'status_updated_at', 'amenities', 'golfamore_data', 'nsg_data', 'vtg_datoer',
|
'status_updated_at', 'amenities', 'golfamore_data', 'nsg_data', 'vtg_datoer', 'total_hole_count',
|
||||||
'course_statuses', 'weather_forecast',
|
'main_physical_hole_count', 'total_physical_hole_count', 'course_statuses', 'weather_forecast',
|
||||||
}
|
}
|
||||||
FACILITY_VIEW_PLACE_FIELDS = FACILITY_VIEW_SEARCH_FIELDS | {
|
FACILITY_VIEW_PLACE_FIELDS = FACILITY_VIEW_SEARCH_FIELDS | {
|
||||||
'has_golfpakker',
|
'has_golfpakker',
|
||||||
|
|
@ -1819,6 +1868,10 @@ async def ensure_public_query_indexes(conn) -> None:
|
||||||
CREATE INDEX IF NOT EXISTS courses_facility_id_main_idx
|
CREATE INDEX IF NOT EXISTS courses_facility_id_main_idx
|
||||||
ON courses (facility_id, is_main_course DESC, id ASC)
|
ON courses (facility_id, is_main_course DESC, id ASC)
|
||||||
""")
|
""")
|
||||||
|
await conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS courses_facility_id_visible_idx
|
||||||
|
ON courses (facility_id, is_visible, is_main_course DESC, id ASC)
|
||||||
|
""")
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
CREATE INDEX IF NOT EXISTS holes_course_id_idx
|
CREATE INDEX IF NOT EXISTS holes_course_id_idx
|
||||||
ON holes (course_id)
|
ON holes (course_id)
|
||||||
|
|
@ -1855,6 +1908,14 @@ async def get_table_columns(conn, table_name: str, schema_name: str = "public")
|
||||||
|
|
||||||
|
|
||||||
async def ensure_scorecard_tables(conn) -> None:
|
async def ensure_scorecard_tables(conn) -> None:
|
||||||
|
await conn.execute("ALTER TABLE courses ADD COLUMN IF NOT EXISTS is_visible BOOLEAN NOT NULL DEFAULT TRUE")
|
||||||
|
await conn.execute("ALTER TABLE courses ADD COLUMN IF NOT EXISTS physical_hole_count INTEGER")
|
||||||
|
await conn.execute("ALTER TABLE courses ADD COLUMN IF NOT EXISTS include_in_physical_hole_total BOOLEAN NOT NULL DEFAULT TRUE")
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE courses
|
||||||
|
SET include_in_physical_hole_total = TRUE
|
||||||
|
WHERE include_in_physical_hole_total IS NULL
|
||||||
|
""")
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS tees (
|
CREATE TABLE IF NOT EXISTS tees (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
|
|
@ -1884,6 +1945,15 @@ async def ensure_scorecard_tables(conn) -> None:
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS hole_lengths_hole_tee_uidx
|
CREATE UNIQUE INDEX IF NOT EXISTS hole_lengths_hole_tee_uidx
|
||||||
ON hole_lengths (hole_id, tee_id)
|
ON hole_lengths (hole_id, tee_id)
|
||||||
""")
|
""")
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE courses c
|
||||||
|
SET physical_hole_count = NULLIF(SUBSTRING(COALESCE(f.amenities->>'antall_hull', '') FROM '([0-9]+)'), '')::INTEGER
|
||||||
|
FROM facilities f
|
||||||
|
WHERE c.facility_id = f.id
|
||||||
|
AND c.is_main_course = TRUE
|
||||||
|
AND c.physical_hole_count IS NULL
|
||||||
|
AND COALESCE(f.amenities->>'antall_hull', '') <> ''
|
||||||
|
""")
|
||||||
|
|
||||||
course_columns = await get_table_columns(conn, "courses")
|
course_columns = await get_table_columns(conn, "courses")
|
||||||
hole_columns = await get_table_columns(conn, "holes")
|
hole_columns = await get_table_columns(conn, "holes")
|
||||||
|
|
@ -2494,6 +2564,7 @@ async def build_facility_course_payloads(
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM courses
|
FROM courses
|
||||||
WHERE facility_id = $1
|
WHERE facility_id = $1
|
||||||
|
AND COALESCE(is_visible, TRUE) = TRUE
|
||||||
AND (is_main_course = TRUE OR (status NOT IN ('finnes_ingen_bane_to', 'ukjent')))
|
AND (is_main_course = TRUE OR (status NOT IN ('finnes_ingen_bane_to', 'ukjent')))
|
||||||
ORDER BY is_main_course DESC, id ASC
|
ORDER BY is_main_course DESC, id ASC
|
||||||
""",
|
""",
|
||||||
|
|
@ -2650,6 +2721,8 @@ async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tu
|
||||||
for course in submitted_courses:
|
for course in submitted_courses:
|
||||||
normalized_course = dict(course)
|
normalized_course = dict(course)
|
||||||
normalized_course['is_main_course'] = bool(course.get('is_main_course'))
|
normalized_course['is_main_course'] = bool(course.get('is_main_course'))
|
||||||
|
normalized_course['is_visible'] = course.get('is_visible') is not False
|
||||||
|
normalized_course['include_in_physical_hole_total'] = course.get('include_in_physical_hole_total') is not False
|
||||||
normalized_courses.append(normalized_course)
|
normalized_courses.append(normalized_course)
|
||||||
|
|
||||||
if normalized_courses:
|
if normalized_courses:
|
||||||
|
|
@ -2670,6 +2743,8 @@ async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tu
|
||||||
holes = [hole for hole in (course.get('holes') or []) if hole]
|
holes = [hole for hole in (course.get('holes') or []) if hole]
|
||||||
tees = [tee for tee in (course.get('tees') or []) if tee]
|
tees = [tee for tee in (course.get('tees') or []) if tee]
|
||||||
hole_count = len(holes) or None
|
hole_count = len(holes) or None
|
||||||
|
physical_hole_count = parse_optional_int(course.get('physical_hole_count'))
|
||||||
|
include_in_physical_hole_total = course.get('include_in_physical_hole_total') is not False
|
||||||
course_par = parse_optional_int(course.get('par'))
|
course_par = parse_optional_int(course.get('par'))
|
||||||
submitted_course_length_meters = parse_optional_int(course.get('length_meters'))
|
submitted_course_length_meters = parse_optional_int(course.get('length_meters'))
|
||||||
|
|
||||||
|
|
@ -2684,27 +2759,51 @@ async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tu
|
||||||
valid_until = None
|
valid_until = None
|
||||||
|
|
||||||
if course_id:
|
if course_id:
|
||||||
await conn.execute("""
|
if "is_visible" in course_columns:
|
||||||
UPDATE courses
|
await conn.execute("""
|
||||||
SET name=$1, holes=$2, par=$3, length_meters=$4, architect=$5,
|
UPDATE courses
|
||||||
status=$6, is_main_course=$7, slope_valid_until=$8
|
SET name=$1, holes=$2, physical_hole_count=$3, par=$4, length_meters=$5, architect=$6,
|
||||||
WHERE id=$9 AND facility_id=$10
|
status=$7, is_main_course=$8, slope_valid_until=$9, is_visible=$10, include_in_physical_hole_total=$11
|
||||||
""",
|
WHERE id=$12 AND facility_id=$13
|
||||||
course.get('name'), hole_count, course_par, submitted_course_length_meters,
|
""",
|
||||||
course.get('architect'), course.get('status'), course.get('is_main_course'),
|
course.get('name'), hole_count, physical_hole_count, course_par, submitted_course_length_meters,
|
||||||
valid_until, course_id, facility_id)
|
course.get('architect'), course.get('status'), course.get('is_main_course'),
|
||||||
|
valid_until, course.get('is_visible'), include_in_physical_hole_total, course_id, facility_id)
|
||||||
|
else:
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE courses
|
||||||
|
SET name=$1, holes=$2, physical_hole_count=$3, par=$4, length_meters=$5, architect=$6,
|
||||||
|
status=$7, is_main_course=$8, slope_valid_until=$9, include_in_physical_hole_total=$10
|
||||||
|
WHERE id=$11 AND facility_id=$12
|
||||||
|
""",
|
||||||
|
course.get('name'), hole_count, physical_hole_count, course_par, submitted_course_length_meters,
|
||||||
|
course.get('architect'), course.get('status'), course.get('is_main_course'),
|
||||||
|
valid_until, include_in_physical_hole_total, course_id, facility_id)
|
||||||
else:
|
else:
|
||||||
course_id = await conn.fetchval("""
|
if "is_visible" in course_columns:
|
||||||
INSERT INTO courses (
|
course_id = await conn.fetchval("""
|
||||||
facility_id, name, holes, par, length_meters, architect,
|
INSERT INTO courses (
|
||||||
status, is_main_course, slope_valid_until
|
facility_id, name, holes, physical_hole_count, par, length_meters, architect,
|
||||||
)
|
status, is_main_course, slope_valid_until, is_visible, include_in_physical_hole_total
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
)
|
||||||
RETURNING id
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
""",
|
RETURNING id
|
||||||
facility_id, course.get('name'), hole_count, course_par, submitted_course_length_meters,
|
""",
|
||||||
course.get('architect'), course.get('status'), course.get('is_main_course'),
|
facility_id, course.get('name'), hole_count, physical_hole_count, course_par, submitted_course_length_meters,
|
||||||
valid_until)
|
course.get('architect'), course.get('status'), course.get('is_main_course'),
|
||||||
|
valid_until, course.get('is_visible'), include_in_physical_hole_total)
|
||||||
|
else:
|
||||||
|
course_id = await conn.fetchval("""
|
||||||
|
INSERT INTO courses (
|
||||||
|
facility_id, name, holes, physical_hole_count, par, length_meters, architect,
|
||||||
|
status, is_main_course, slope_valid_until, include_in_physical_hole_total
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
facility_id, course.get('name'), hole_count, physical_hole_count, course_par, submitted_course_length_meters,
|
||||||
|
course.get('architect'), course.get('status'), course.get('is_main_course'),
|
||||||
|
valid_until, include_in_physical_hole_total)
|
||||||
|
|
||||||
retained_course_ids.append(int(course_id))
|
retained_course_ids.append(int(course_id))
|
||||||
|
|
||||||
|
|
@ -4435,6 +4534,13 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM facilities
|
FROM facilities
|
||||||
WHERE is_published IS DISTINCT FROM FALSE
|
WHERE is_published IS DISTINCT FROM FALSE
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM courses c
|
||||||
|
WHERE c.facility_id = facilities.id
|
||||||
|
AND COALESCE(c.is_visible, TRUE) = TRUE
|
||||||
|
AND c.status != 'finnes_ingen_bane_to'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
course_statuses_cte = """
|
course_statuses_cte = """
|
||||||
|
|
@ -4452,6 +4558,7 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
|
||||||
FROM courses c
|
FROM courses c
|
||||||
JOIN published_facilities pf ON pf.id = c.facility_id
|
JOIN published_facilities pf ON pf.id = c.facility_id
|
||||||
WHERE c.status != 'finnes_ingen_bane_to'
|
WHERE c.status != 'finnes_ingen_bane_to'
|
||||||
|
AND COALESCE(c.is_visible, TRUE) = TRUE
|
||||||
GROUP BY c.facility_id
|
GROUP BY c.facility_id
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
@ -4511,6 +4618,30 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
|
||||||
FROM courses c
|
FROM courses c
|
||||||
JOIN holes h ON h.course_id = c.id
|
JOIN holes h ON h.course_id = c.id
|
||||||
JOIN published_facilities pf ON pf.id = c.facility_id
|
JOIN published_facilities pf ON pf.id = c.facility_id
|
||||||
|
WHERE COALESCE(c.is_visible, TRUE) = TRUE
|
||||||
|
GROUP BY c.facility_id
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
main_course_meta_cte = """
|
||||||
|
main_course_meta AS (
|
||||||
|
SELECT DISTINCT ON (c.facility_id)
|
||||||
|
c.facility_id,
|
||||||
|
COALESCE(c.physical_hole_count, c.holes) AS main_physical_hole_count
|
||||||
|
FROM courses c
|
||||||
|
JOIN published_facilities pf ON pf.id = c.facility_id
|
||||||
|
WHERE COALESCE(c.is_visible, TRUE) = TRUE
|
||||||
|
ORDER BY c.facility_id ASC, c.is_main_course DESC, c.id ASC
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
physical_hole_totals_cte = """
|
||||||
|
physical_hole_totals AS (
|
||||||
|
SELECT
|
||||||
|
c.facility_id,
|
||||||
|
SUM(COALESCE(c.physical_hole_count, c.holes)) AS total_physical_hole_count
|
||||||
|
FROM courses c
|
||||||
|
JOIN published_facilities pf ON pf.id = c.facility_id
|
||||||
|
WHERE COALESCE(c.include_in_physical_hole_total, TRUE) = TRUE
|
||||||
|
AND COALESCE(c.physical_hole_count, c.holes) IS NOT NULL
|
||||||
GROUP BY c.facility_id
|
GROUP BY c.facility_id
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
@ -4526,6 +4657,7 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
|
||||||
JOIN courses c ON c.id = t.course_id AND c.id = h.course_id
|
JOIN courses c ON c.id = t.course_id AND c.id = h.course_id
|
||||||
JOIN published_facilities pf ON pf.id = c.facility_id
|
JOIN published_facilities pf ON pf.id = c.facility_id
|
||||||
WHERE hl.length_meters BETWEEN 30 AND 900
|
WHERE hl.length_meters BETWEEN 30 AND 900
|
||||||
|
AND COALESCE(c.is_visible, TRUE) = TRUE
|
||||||
GROUP BY c.facility_id
|
GROUP BY c.facility_id
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
@ -4543,7 +4675,10 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
|
||||||
WITH
|
WITH
|
||||||
{published_facilities_cte},
|
{published_facilities_cte},
|
||||||
{course_statuses_cte},
|
{course_statuses_cte},
|
||||||
{weather_compact_cte}
|
{weather_compact_cte},
|
||||||
|
{main_course_meta_cte},
|
||||||
|
{physical_hole_totals_cte},
|
||||||
|
{hole_counts_cte}
|
||||||
SELECT
|
SELECT
|
||||||
pf.id,
|
pf.id,
|
||||||
pf.slug,
|
pf.slug,
|
||||||
|
|
@ -4579,9 +4714,15 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
|
||||||
pf.footnote_updated_at,
|
pf.footnote_updated_at,
|
||||||
pf.status_updated_at,
|
pf.status_updated_at,
|
||||||
COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses,
|
COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses,
|
||||||
|
COALESCE(hc.total_hole_count, 0) AS total_hole_count,
|
||||||
|
mcm.main_physical_hole_count,
|
||||||
|
pht.total_physical_hole_count,
|
||||||
COALESCE(wc.weather_forecast, '[]'::jsonb) AS weather_forecast
|
COALESCE(wc.weather_forecast, '[]'::jsonb) AS weather_forecast
|
||||||
FROM published_facilities pf
|
FROM published_facilities pf
|
||||||
LEFT JOIN course_statuses cs ON cs.facility_id = pf.id
|
LEFT JOIN course_statuses cs ON cs.facility_id = pf.id
|
||||||
|
LEFT JOIN hole_counts hc ON hc.facility_id = pf.id
|
||||||
|
LEFT JOIN main_course_meta mcm ON mcm.facility_id = pf.id
|
||||||
|
LEFT JOIN physical_hole_totals pht ON pht.facility_id = pf.id
|
||||||
LEFT JOIN weather_compact wc ON wc.facility_id = pf.id
|
LEFT JOIN weather_compact wc ON wc.facility_id = pf.id
|
||||||
ORDER BY pf.name ASC
|
ORDER BY pf.name ASC
|
||||||
""",
|
""",
|
||||||
|
|
@ -4595,6 +4736,8 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
|
||||||
{published_facilities_cte},
|
{published_facilities_cte},
|
||||||
{course_statuses_cte},
|
{course_statuses_cte},
|
||||||
{weather_compact_cte},
|
{weather_compact_cte},
|
||||||
|
{main_course_meta_cte},
|
||||||
|
{physical_hole_totals_cte},
|
||||||
{hole_counts_cte},
|
{hole_counts_cte},
|
||||||
{hole_lengths_cte}
|
{hole_lengths_cte}
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -4635,6 +4778,8 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
|
||||||
pf.status_updated_at,
|
pf.status_updated_at,
|
||||||
COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses,
|
COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses,
|
||||||
COALESCE(hc.total_hole_count, 0) AS total_hole_count,
|
COALESCE(hc.total_hole_count, 0) AS total_hole_count,
|
||||||
|
mcm.main_physical_hole_count,
|
||||||
|
pht.total_physical_hole_count,
|
||||||
COALESCE(hc.hole_par_counts, jsonb_build_object('3', 0, '4', 0, '5', 0, '6', 0)) AS hole_par_counts,
|
COALESCE(hc.hole_par_counts, jsonb_build_object('3', 0, '4', 0, '5', 0, '6', 0)) AS hole_par_counts,
|
||||||
hl.shortest_hole_meters,
|
hl.shortest_hole_meters,
|
||||||
hl.longest_hole_meters,
|
hl.longest_hole_meters,
|
||||||
|
|
@ -4643,6 +4788,8 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
|
||||||
LEFT JOIN course_statuses cs ON cs.facility_id = pf.id
|
LEFT JOIN course_statuses cs ON cs.facility_id = pf.id
|
||||||
LEFT JOIN weather_compact wc ON wc.facility_id = pf.id
|
LEFT JOIN weather_compact wc ON wc.facility_id = pf.id
|
||||||
LEFT JOIN hole_counts hc ON hc.facility_id = pf.id
|
LEFT JOIN hole_counts hc ON hc.facility_id = pf.id
|
||||||
|
LEFT JOIN main_course_meta mcm ON mcm.facility_id = pf.id
|
||||||
|
LEFT JOIN physical_hole_totals pht ON pht.facility_id = pf.id
|
||||||
LEFT JOIN hole_lengths hl ON hl.facility_id = pf.id
|
LEFT JOIN hole_lengths hl ON hl.facility_id = pf.id
|
||||||
ORDER BY pf.name ASC
|
ORDER BY pf.name ASC
|
||||||
""",
|
""",
|
||||||
|
|
@ -4746,12 +4893,16 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
|
||||||
{published_facilities_cte},
|
{published_facilities_cte},
|
||||||
{course_statuses_cte},
|
{course_statuses_cte},
|
||||||
{weather_full_cte},
|
{weather_full_cte},
|
||||||
|
{main_course_meta_cte},
|
||||||
|
{physical_hole_totals_cte},
|
||||||
{hole_counts_cte},
|
{hole_counts_cte},
|
||||||
{hole_lengths_cte}
|
{hole_lengths_cte}
|
||||||
SELECT
|
SELECT
|
||||||
pf.*,
|
pf.*,
|
||||||
COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses,
|
COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses,
|
||||||
COALESCE(hc.total_hole_count, 0) AS total_hole_count,
|
COALESCE(hc.total_hole_count, 0) AS total_hole_count,
|
||||||
|
mcm.main_physical_hole_count,
|
||||||
|
pht.total_physical_hole_count,
|
||||||
COALESCE(hc.hole_par_counts, jsonb_build_object('3', 0, '4', 0, '5', 0, '6', 0)) AS hole_par_counts,
|
COALESCE(hc.hole_par_counts, jsonb_build_object('3', 0, '4', 0, '5', 0, '6', 0)) AS hole_par_counts,
|
||||||
hl.shortest_hole_meters,
|
hl.shortest_hole_meters,
|
||||||
hl.longest_hole_meters,
|
hl.longest_hole_meters,
|
||||||
|
|
@ -4759,6 +4910,8 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
|
||||||
FROM published_facilities pf
|
FROM published_facilities pf
|
||||||
LEFT JOIN course_statuses cs ON cs.facility_id = pf.id
|
LEFT JOIN course_statuses cs ON cs.facility_id = pf.id
|
||||||
LEFT JOIN hole_counts hc ON hc.facility_id = pf.id
|
LEFT JOIN hole_counts hc ON hc.facility_id = pf.id
|
||||||
|
LEFT JOIN main_course_meta mcm ON mcm.facility_id = pf.id
|
||||||
|
LEFT JOIN physical_hole_totals pht ON pht.facility_id = pf.id
|
||||||
LEFT JOIN hole_lengths hl ON hl.facility_id = pf.id
|
LEFT JOIN hole_lengths hl ON hl.facility_id = pf.id
|
||||||
LEFT JOIN weather_full wf ON wf.facility_id = pf.id
|
LEFT JOIN weather_full wf ON wf.facility_id = pf.id
|
||||||
ORDER BY pf.name ASC
|
ORDER BY pf.name ASC
|
||||||
|
|
@ -5176,7 +5329,7 @@ async def update_admin_site_page(page_key: str, request: SitePageUpsertRequest):
|
||||||
|
|
||||||
config = SITE_PAGE_CONFIGS.get(normalized_key) or {}
|
config = SITE_PAGE_CONFIGS.get(normalized_key) or {}
|
||||||
path = str(config.get("path") or "").strip()
|
path = str(config.get("path") or "").strip()
|
||||||
invalidate_public_api_caches(include_site_pages=True)
|
invalidate_public_api_caches(include_facilities=False, include_site_pages=True)
|
||||||
if path:
|
if path:
|
||||||
schedule_indexnow_submission(
|
schedule_indexnow_submission(
|
||||||
collect_page_indexnow_urls([path]),
|
collect_page_indexnow_urls([path]),
|
||||||
|
|
@ -5209,7 +5362,7 @@ async def update_admin_place_page(slug: str, request: PlacePageUpsertRequest):
|
||||||
normalize_optional_text(request.meta_description),
|
normalize_optional_text(request.meta_description),
|
||||||
)
|
)
|
||||||
|
|
||||||
invalidate_public_api_caches(include_place_pages=True)
|
invalidate_public_api_caches(include_facilities=False, include_place_pages=True)
|
||||||
schedule_indexnow_submission(
|
schedule_indexnow_submission(
|
||||||
collect_page_indexnow_urls([f"/sted/{normalized_slug}"]),
|
collect_page_indexnow_urls([f"/sted/{normalized_slug}"]),
|
||||||
reason="admin place page upsert",
|
reason="admin place page upsert",
|
||||||
|
|
@ -5271,6 +5424,7 @@ async def update_admin_site_page_seo(page_key: str, request: SitePageSeoUpsertRe
|
||||||
"meninger": "/meninger",
|
"meninger": "/meninger",
|
||||||
"simulatorer": "/simulatorer",
|
"simulatorer": "/simulatorer",
|
||||||
}
|
}
|
||||||
|
invalidate_public_api_caches(include_facilities=False, include_site_pages=True)
|
||||||
schedule_indexnow_submission(
|
schedule_indexnow_submission(
|
||||||
collect_page_indexnow_urls([page_path_map[normalized_key]]),
|
collect_page_indexnow_urls([page_path_map[normalized_key]]),
|
||||||
reason="admin site page seo upsert",
|
reason="admin site page seo upsert",
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ services:
|
||||||
command: npm start
|
command: npm start
|
||||||
environment:
|
environment:
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
|
||||||
|
PUBLIC_SESSION_SECRET: ${PUBLIC_SESSION_SECRET}
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend/public:/app/public
|
- ./frontend/public:/app/public
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||||
import { getPublicCourseDisplayName, type EnrichedFacility } from "@/app/facilityData";
|
import { getFacilityVisibleHoleValue, getPublicCourseDisplayName, type EnrichedFacility } from "@/app/facilityData";
|
||||||
|
|
||||||
type SortMethod = "updated" | "dist" | "alpha";
|
type SortMethod = "updated" | "dist" | "alpha";
|
||||||
type Variant = "home" | "catalog";
|
type Variant = "home" | "catalog";
|
||||||
|
|
@ -42,6 +42,7 @@ type Facility = {
|
||||||
footnote?: string | null;
|
footnote?: string | null;
|
||||||
footnote_updated_at?: string | null;
|
footnote_updated_at?: string | null;
|
||||||
status_updated_at?: string | null;
|
status_updated_at?: string | null;
|
||||||
|
total_hole_count?: number | null;
|
||||||
amenities?: unknown;
|
amenities?: unknown;
|
||||||
golfamore_data?: unknown;
|
golfamore_data?: unknown;
|
||||||
nsg_data?: unknown;
|
nsg_data?: unknown;
|
||||||
|
|
@ -378,14 +379,15 @@ const getAreaLabel = (value: string, countyOptions: Array<{ slug: string; label:
|
||||||
};
|
};
|
||||||
|
|
||||||
const matchesHoleFilter = (holeValue: string, filterValue: string) => {
|
const matchesHoleFilter = (holeValue: string, filterValue: string) => {
|
||||||
const normalizedHole = normalizeText(holeValue);
|
|
||||||
if (!filterValue) return true;
|
if (!filterValue) return true;
|
||||||
if (filterValue === "18-plus") return normalizedHole.includes("18");
|
return normalizeText(holeValue) === normalizeText(filterValue);
|
||||||
if (filterValue === "18") return normalizedHole === "18";
|
};
|
||||||
if (filterValue === "9") return normalizedHole === "9" || normalizedHole === "9 9";
|
|
||||||
if (filterValue === "6-12") return normalizedHole === "6" || normalizedHole === "12";
|
const getHoleFilterLabel = (value: string) => {
|
||||||
if (filterValue === "under-utvikling") return normalizedHole.includes("utvikling");
|
const trimmedValue = String(value || "").trim();
|
||||||
return true;
|
if (!trimmedValue) return "";
|
||||||
|
if (/^\d+$/.test(trimmedValue)) return `${trimmedValue} hull`;
|
||||||
|
return trimmedValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
const matchesSpecialFilter = (specialFilter: string, flags: SpecialFlags) => {
|
const matchesSpecialFilter = (specialFilter: string, flags: SpecialFlags) => {
|
||||||
|
|
@ -638,7 +640,7 @@ export default function FacilitySearch({
|
||||||
|
|
||||||
const countySlug = slugify(facility.county || "");
|
const countySlug = slugify(facility.county || "");
|
||||||
const regions = getFacilityRegions(facility.county || "");
|
const regions = getFacilityRegions(facility.county || "");
|
||||||
const holeValue = String(amenities.antall_hull || "").trim();
|
const holeValue = getFacilityVisibleHoleValue(facility);
|
||||||
const primaryStatus = getPrimaryStatus(statuses);
|
const primaryStatus = getPrimaryStatus(statuses);
|
||||||
const normalizedStatuses = statuses.map((status) => normalizeStatus(status.status));
|
const normalizedStatuses = statuses.map((status) => normalizeStatus(status.status));
|
||||||
const hasGolfamore =
|
const hasGolfamore =
|
||||||
|
|
@ -771,6 +773,35 @@ export default function FacilitySearch({
|
||||||
weatherDayFilter,
|
weatherDayFilter,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const holeFilterOptions = useMemo(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const options = (Array.isArray(initialFacilities) ? initialFacilities : [])
|
||||||
|
.map((facility) => getFacilityVisibleHoleValue(facility).trim())
|
||||||
|
.filter((value) => {
|
||||||
|
if (!value) return false;
|
||||||
|
const normalizedValue = normalizeText(value);
|
||||||
|
if (!normalizedValue || seen.has(normalizedValue)) return false;
|
||||||
|
seen.add(normalizedValue);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aIsNumeric = /^\d+$/.test(a);
|
||||||
|
const bIsNumeric = /^\d+$/.test(b);
|
||||||
|
|
||||||
|
if (aIsNumeric && bIsNumeric) {
|
||||||
|
return Number(a) - Number(b);
|
||||||
|
}
|
||||||
|
if (aIsNumeric) return -1;
|
||||||
|
if (bIsNumeric) return 1;
|
||||||
|
return a.localeCompare(b, "nb");
|
||||||
|
});
|
||||||
|
|
||||||
|
return options.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: getHoleFilterLabel(value),
|
||||||
|
}));
|
||||||
|
}, [initialFacilities]);
|
||||||
|
|
||||||
const filtersCount = [
|
const filtersCount = [
|
||||||
areaFilter,
|
areaFilter,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
|
@ -878,11 +909,11 @@ export default function FacilitySearch({
|
||||||
|
|
||||||
<FieldSelect label="Antall hull" value={holeFilter} onChange={setHoleFilter} labelClassName={labelClassName}>
|
<FieldSelect label="Antall hull" value={holeFilter} onChange={setHoleFilter} labelClassName={labelClassName}>
|
||||||
<option value="">Alle antall hull</option>
|
<option value="">Alle antall hull</option>
|
||||||
<option value="18-plus">18 hull eller mer</option>
|
{holeFilterOptions.map((option) => (
|
||||||
<option value="18">Nøyaktig 18 hull</option>
|
<option key={option.value} value={option.value}>
|
||||||
<option value="9">9 hull</option>
|
{option.label}
|
||||||
<option value="6-12">6 eller 12 hull</option>
|
</option>
|
||||||
<option value="under-utvikling">Under utvikling</option>
|
))}
|
||||||
</FieldSelect>
|
</FieldSelect>
|
||||||
|
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
|
|
|
||||||
|
|
@ -607,7 +607,12 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
...initialData,
|
...initialData,
|
||||||
is_published: isCreateMode ? Boolean(initialData?.is_published) : initialData?.is_published !== false,
|
is_published: isCreateMode ? Boolean(initialData?.is_published) : initialData?.is_published !== false,
|
||||||
courses: Array.isArray(initialData?.courses) ? initialData.courses : [],
|
courses: Array.isArray(initialData?.courses)
|
||||||
|
? initialData.courses.map((course: any) => ({
|
||||||
|
...course,
|
||||||
|
is_visible: course?.is_visible !== false,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
videos: normalizeFacilityVideos(initialData?.videos),
|
videos: normalizeFacilityVideos(initialData?.videos),
|
||||||
});
|
});
|
||||||
const [activeTab, setActiveTab] = useState('generelt');
|
const [activeTab, setActiveTab] = useState('generelt');
|
||||||
|
|
@ -657,10 +662,13 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
_clientId: `course-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
_clientId: `course-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
name: `Ny bane ${existingCourses.length + 1}`,
|
name: `Ny bane ${existingCourses.length + 1}`,
|
||||||
status: 'ukjent',
|
status: 'ukjent',
|
||||||
|
physical_hole_count: '',
|
||||||
|
include_in_physical_hole_total: true,
|
||||||
par: '',
|
par: '',
|
||||||
length_meters: '',
|
length_meters: '',
|
||||||
architect: '',
|
architect: '',
|
||||||
is_main_course: existingCourses.length === 0,
|
is_main_course: existingCourses.length === 0,
|
||||||
|
is_visible: true,
|
||||||
slope_valid_until: '',
|
slope_valid_until: '',
|
||||||
tees: [],
|
tees: [],
|
||||||
holes: Array.from({ length: 18 }, (_, index) => ({
|
holes: Array.from({ length: 18 }, (_, index) => ({
|
||||||
|
|
@ -1466,8 +1474,8 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
<AccordionSection
|
<AccordionSection
|
||||||
key={course.id || course._clientId || cIdx}
|
key={course.id || course._clientId || cIdx}
|
||||||
title={course.name || `Bane ${cIdx + 1}`}
|
title={course.name || `Bane ${cIdx + 1}`}
|
||||||
subtitle={course.is_main_course ? 'Hovedbane' : 'Sekundærbane'}
|
subtitle={course.is_visible === false ? 'Skjult bane' : course.is_main_course ? 'Hovedbane' : 'Sekundærbane'}
|
||||||
badge={`${course.holes?.length || 0} hull`}
|
badge={`${Number(course.physical_hole_count) > 0 ? Number(course.physical_hole_count) : (course.holes?.length || 0)} hull`}
|
||||||
>
|
>
|
||||||
<div className="mb-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 border-b-2 border-gray-200 pb-4">
|
<div className="mb-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 border-b-2 border-gray-200 pb-4">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
|
@ -1481,8 +1489,23 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-black uppercase tracking-widest text-[#11280f]">Hovedbane</span>
|
<span className="text-xs font-black uppercase tracking-widest text-[#11280f]">Hovedbane</span>
|
||||||
</label>
|
</label>
|
||||||
<span className={`px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest ${course.is_main_course ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-300 text-gray-700'}`}>
|
<label className="inline-flex items-center gap-3 rounded-xl bg-white px-4 py-2 shadow-sm">
|
||||||
{course.is_main_course ? 'Hovedbane' : 'Sekundærbane'}
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={course.is_visible !== false}
|
||||||
|
onChange={e => {
|
||||||
|
updateCourses((courses) => {
|
||||||
|
const nextCourses = [...courses];
|
||||||
|
nextCourses[cIdx] = { ...course, is_visible: e.target.checked };
|
||||||
|
return nextCourses;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 accent-[#8bc34a]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-black uppercase tracking-widest text-[#11280f]">Vis offentlig</span>
|
||||||
|
</label>
|
||||||
|
<span className={`px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest ${course.is_visible === false ? 'bg-[#3d4b40] text-white shadow-md' : course.is_main_course ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-300 text-gray-700'}`}>
|
||||||
|
{course.is_visible === false ? 'Skjult' : course.is_main_course ? 'Hovedbane' : 'Sekundærbane'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => handleRemoveCourse(cIdx)} className="btn btn-md btn-danger w-full md:w-auto">Slett bane</button>
|
<button onClick={() => handleRemoveCourse(cIdx)} className="btn btn-md btn-danger w-full md:w-auto">Slett bane</button>
|
||||||
|
|
@ -1526,6 +1549,36 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
});
|
});
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 mb-6">
|
||||||
|
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Fysiske Hull</label>
|
||||||
|
<input type="number" min="1" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.physical_hole_count ?? ""} onChange={e => {
|
||||||
|
updateCourses((courses) => {
|
||||||
|
const nextCourses = [...courses];
|
||||||
|
nextCourses[cIdx] = {...course, physical_hole_count: e.target.value === "" ? "" : Number(e.target.value)};
|
||||||
|
return nextCourses;
|
||||||
|
});
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 mb-6 md:col-span-2">
|
||||||
|
<label className="inline-flex items-start gap-3 rounded-2xl border-2 border-gray-300 bg-white p-4 shadow-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={course.include_in_physical_hole_total !== false}
|
||||||
|
onChange={e => {
|
||||||
|
updateCourses((courses) => {
|
||||||
|
const nextCourses = [...courses];
|
||||||
|
nextCourses[cIdx] = {...course, include_in_physical_hole_total: e.target.checked};
|
||||||
|
return nextCourses;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="mt-1 h-4 w-4 accent-[#8bc34a]"
|
||||||
|
/>
|
||||||
|
<span className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-black uppercase tracking-widest text-gray-600">Tell med i anleggets totale hulltall</span>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Skru av for baner som ellers vil gi dobbeltelling i anleggets totale hulltall.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
<div className="flex flex-col gap-2 mb-6">
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Utløpsdato Slope</label>
|
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Utløpsdato Slope</label>
|
||||||
<input type="date" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.slope_valid_until ? course.slope_valid_until.split('T')[0] : ""} onChange={e => {
|
<input type="date" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.slope_valid_until ? course.slope_valid_until.split('T')[0] : ""} onChange={e => {
|
||||||
|
|
|
||||||
92
frontend/src/app/api/admin/revalidate-public/route.ts
Normal file
92
frontend/src/app/api/admin/revalidate-public/route.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { revalidatePath, revalidateTag } from "next/cache";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { PUBLIC_FACILITIES_CACHE_TAG } from "@/app/publicFacilities";
|
||||||
|
import { SITE_PAGE_CACHE_TAG } from "@/app/sitePages";
|
||||||
|
import { SITE_PAGE_SEO_CACHE_TAG } from "@/app/pageSeo";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type RevalidatePayload = {
|
||||||
|
includeFacilities?: boolean;
|
||||||
|
includePlacePages?: boolean;
|
||||||
|
includeSitePages?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FACILITY_PAGE_PATHS = ["/", "/golfbaner", "/medlemskap", "/vtg"];
|
||||||
|
const SITE_PAGE_PATHS = ["/golfbaner", "/vtg", "/medlemskap", "/banebesok", "/meninger", "/simulatorer"];
|
||||||
|
|
||||||
|
function getExpectedSecret(): string {
|
||||||
|
return String(process.env.PUBLIC_SESSION_SECRET || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthorized(request: Request): boolean {
|
||||||
|
const expectedSecret = getExpectedSecret();
|
||||||
|
if (!expectedSecret) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suppliedSecret = String(request.headers.get("x-teeoff-revalidate-secret") || "").trim();
|
||||||
|
return suppliedSecret === expectedSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function revalidateFacilityPages(includePlacePages: boolean): void {
|
||||||
|
revalidateTag(PUBLIC_FACILITIES_CACHE_TAG, "max");
|
||||||
|
|
||||||
|
for (const path of FACILITY_PAGE_PATHS) {
|
||||||
|
revalidatePath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/golfbaner/[slug]", "page");
|
||||||
|
revalidatePath("/sted/[slug]", "page");
|
||||||
|
|
||||||
|
if (includePlacePages) {
|
||||||
|
revalidatePath("/sted/[slug]", "page");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revalidateSitePageContent(): void {
|
||||||
|
revalidateTag(SITE_PAGE_CACHE_TAG, "max");
|
||||||
|
revalidateTag(SITE_PAGE_SEO_CACHE_TAG, "max");
|
||||||
|
|
||||||
|
for (const path of SITE_PAGE_PATHS) {
|
||||||
|
revalidatePath(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revalidatePlacePagesOnly(): void {
|
||||||
|
revalidatePath("/sted/[slug]", "page");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!isAuthorized(request)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: RevalidatePayload = {};
|
||||||
|
try {
|
||||||
|
payload = (await request.json()) as RevalidatePayload;
|
||||||
|
} catch {
|
||||||
|
payload = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const includeFacilities = payload.includeFacilities !== false;
|
||||||
|
const includePlacePages = payload.includePlacePages === true;
|
||||||
|
const includeSitePages = payload.includeSitePages === true;
|
||||||
|
|
||||||
|
if (includeFacilities) {
|
||||||
|
revalidateFacilityPages(includePlacePages);
|
||||||
|
} else if (includePlacePages) {
|
||||||
|
revalidatePlacePagesOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeSitePages) {
|
||||||
|
revalidateSitePageContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
revalidated: true,
|
||||||
|
includeFacilities,
|
||||||
|
includePlacePages,
|
||||||
|
includeSitePages,
|
||||||
|
});
|
||||||
|
}
|
||||||
122
frontend/src/app/api/search/menu/route.ts
Normal file
122
frontend/src/app/api/search/menu/route.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { FacilityRecord } from "@/app/facilityData";
|
||||||
|
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||||
|
import type { EditorialArticle } from "@/content/editorialArticles";
|
||||||
|
import { getCourseVisits, getOpinionArticles } from "@/content/courseVisits";
|
||||||
|
import { buildMenuSearchHref, normalizeMenuSearchText, type MenuSearchItem } from "@/lib/menuSearch";
|
||||||
|
|
||||||
|
const REVALIDATE_SECONDS = 900;
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
function buildFacilitySearchItem(facility: FacilityRecord): MenuSearchItem | null {
|
||||||
|
const slug = String(facility?.slug || "").trim();
|
||||||
|
const title = String(facility?.name || "").trim();
|
||||||
|
if (!slug || !title) return null;
|
||||||
|
|
||||||
|
const subtitle = [facility.city, facility.county].map((value) => String(value || "").trim()).filter(Boolean).join(", ");
|
||||||
|
const searchTerms = [
|
||||||
|
title,
|
||||||
|
slug.replace(/-/g, " "),
|
||||||
|
facility.city,
|
||||||
|
facility.county,
|
||||||
|
facility.banetype,
|
||||||
|
"golfbane",
|
||||||
|
"golfbaner",
|
||||||
|
"baneprofil",
|
||||||
|
]
|
||||||
|
.map((value) => String(value || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
href: `/golfbaner/${slug}`,
|
||||||
|
kind: "facility",
|
||||||
|
label: "Bane",
|
||||||
|
subtitle: subtitle || undefined,
|
||||||
|
titleText: normalizeMenuSearchText(title),
|
||||||
|
searchText: normalizeMenuSearchText(searchTerms),
|
||||||
|
priority: 120,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArticleSearchItem(article: EditorialArticle): MenuSearchItem | null {
|
||||||
|
const slug = String(article?.slug || "").trim();
|
||||||
|
const title = String(article?.title || "").trim();
|
||||||
|
if (!slug || !title) return null;
|
||||||
|
|
||||||
|
const label = article.section === "meninger" ? "Meninger" : "Banebesøk";
|
||||||
|
const subtitle =
|
||||||
|
String(article.facilityName || "").trim() ||
|
||||||
|
String(article.locationLabel || "").trim() ||
|
||||||
|
undefined;
|
||||||
|
const searchTerms = [
|
||||||
|
title,
|
||||||
|
slug.replace(/-/g, " "),
|
||||||
|
article.facilityName,
|
||||||
|
article.locationLabel,
|
||||||
|
article.eyebrow,
|
||||||
|
label,
|
||||||
|
"artikkel",
|
||||||
|
]
|
||||||
|
.map((value) => String(value || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
href: buildMenuSearchHref(article.section, slug),
|
||||||
|
kind: "article",
|
||||||
|
label,
|
||||||
|
subtitle,
|
||||||
|
section: article.section,
|
||||||
|
titleText: normalizeMenuSearchText(title),
|
||||||
|
searchText: normalizeMenuSearchText(searchTerms),
|
||||||
|
priority: article.section === "banebesok" ? 72 : 64,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const [facilitiesResult, courseVisitsResult, opinionsResult] = await Promise.allSettled([
|
||||||
|
fetchPublicFacilities<FacilityRecord>("search", REVALIDATE_SECONDS, {
|
||||||
|
allowEmpty: true,
|
||||||
|
}),
|
||||||
|
getCourseVisits(),
|
||||||
|
getOpinionArticles(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const items = new Map<string, MenuSearchItem>();
|
||||||
|
|
||||||
|
if (facilitiesResult.status === "fulfilled") {
|
||||||
|
for (const facility of facilitiesResult.value) {
|
||||||
|
const item = buildFacilitySearchItem(facility);
|
||||||
|
if (item) items.set(item.href, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (courseVisitsResult.status === "fulfilled") {
|
||||||
|
for (const article of courseVisitsResult.value) {
|
||||||
|
const item = buildArticleSearchItem(article);
|
||||||
|
if (item) items.set(item.href, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opinionsResult.status === "fulfilled") {
|
||||||
|
for (const article of opinionsResult.value) {
|
||||||
|
const item = buildArticleSearchItem(article);
|
||||||
|
if (item) items.set(item.href, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
items: Array.from(items.values()),
|
||||||
|
});
|
||||||
|
|
||||||
|
response.headers.set(
|
||||||
|
"Cache-Control",
|
||||||
|
`public, max-age=0, s-maxage=${REVALIDATE_SECONDS}, stale-while-revalidate=86400`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { STATUS_MAP } from "@/config/constants";
|
||||||
export type CourseStatus = {
|
export type CourseStatus = {
|
||||||
status?: string;
|
status?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
is_visible?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FacilityRecord = {
|
export type FacilityRecord = {
|
||||||
|
|
@ -36,6 +37,8 @@ export type FacilityRecord = {
|
||||||
greenfee?: unknown;
|
greenfee?: unknown;
|
||||||
standard_medlemskap?: number | null;
|
standard_medlemskap?: number | null;
|
||||||
total_hole_count?: number | null;
|
total_hole_count?: number | null;
|
||||||
|
main_physical_hole_count?: number | null;
|
||||||
|
total_physical_hole_count?: number | null;
|
||||||
hole_par_counts?: unknown;
|
hole_par_counts?: unknown;
|
||||||
shortest_hole_meters?: number | null;
|
shortest_hole_meters?: number | null;
|
||||||
longest_hole_meters?: number | null;
|
longest_hole_meters?: number | null;
|
||||||
|
|
@ -259,6 +262,58 @@ export const getPrimaryStatus = (statuses: Array<{ status?: string }>) => {
|
||||||
return "ukjent";
|
return "ukjent";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getFacilityVisibleHoleCount = (
|
||||||
|
facility: Pick<FacilityRecord, "total_physical_hole_count" | "total_hole_count" | "amenities">
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
typeof facility.total_physical_hole_count === "number" &&
|
||||||
|
Number.isFinite(facility.total_physical_hole_count) &&
|
||||||
|
facility.total_physical_hole_count > 0
|
||||||
|
) {
|
||||||
|
return facility.total_physical_hole_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof facility.total_hole_count === "number" && Number.isFinite(facility.total_hole_count) && facility.total_hole_count > 0) {
|
||||||
|
return facility.total_hole_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amenities = parseJson<Record<string, unknown>>(facility.amenities, {});
|
||||||
|
const holeCategory = getHoleCategory(amenities.antall_hull);
|
||||||
|
if (holeCategory === "27+") return 27;
|
||||||
|
if (holeCategory === "18") return 18;
|
||||||
|
if (holeCategory === "9") return 9;
|
||||||
|
if (holeCategory === "6") return 6;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFacilityVisibleHoleValue = (
|
||||||
|
facility: Pick<FacilityRecord, "main_physical_hole_count" | "total_physical_hole_count" | "total_hole_count" | "amenities">
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
typeof facility.total_physical_hole_count === "number" &&
|
||||||
|
Number.isFinite(facility.total_physical_hole_count) &&
|
||||||
|
facility.total_physical_hole_count > 0
|
||||||
|
) {
|
||||||
|
return String(facility.total_physical_hole_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof facility.main_physical_hole_count === "number" &&
|
||||||
|
Number.isFinite(facility.main_physical_hole_count) &&
|
||||||
|
facility.main_physical_hole_count > 0
|
||||||
|
) {
|
||||||
|
return String(facility.main_physical_hole_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleHoleCount = getFacilityVisibleHoleCount(facility);
|
||||||
|
if (visibleHoleCount > 0) {
|
||||||
|
return String(visibleHoleCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const amenities = parseJson<Record<string, unknown>>(facility.amenities, {});
|
||||||
|
return String(amenities.antall_hull || "").trim();
|
||||||
|
};
|
||||||
|
|
||||||
export const getStatusLabel = (status: string) => STATUS_MAP[status] || "Ukjent status";
|
export const getStatusLabel = (status: string) => STATUS_MAP[status] || "Ukjent status";
|
||||||
|
|
||||||
export const formatUpdatedDate = (value: string | null | undefined) => {
|
export const formatUpdatedDate = (value: string | null | undefined) => {
|
||||||
|
|
@ -304,7 +359,7 @@ export const enrichFacilities = (
|
||||||
Array.isArray(rawStatuses) && rawStatuses.length > 0
|
Array.isArray(rawStatuses) && rawStatuses.length > 0
|
||||||
? rawStatuses
|
? rawStatuses
|
||||||
: [{ status: "ukjent", name: "" }];
|
: [{ status: "ukjent", name: "" }];
|
||||||
const holeValue = String(amenities.antall_hull || "").trim();
|
const holeValue = getFacilityVisibleHoleValue(facility);
|
||||||
const countySlug = slugify(facility.county || "");
|
const countySlug = slugify(facility.county || "");
|
||||||
const regions = getFacilityRegions(facility.county || "");
|
const regions = getFacilityRegions(facility.county || "");
|
||||||
const updatedTsRaw = facility.status_updated_at ? new Date(facility.status_updated_at).getTime() : 0;
|
const updatedTsRaw = facility.status_updated_at ? new Date(facility.status_updated_at).getTime() : 0;
|
||||||
|
|
@ -528,6 +583,61 @@ const getHoleCategory = (value: unknown) => {
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getFacilityPlaceHoleCategory = (
|
||||||
|
facility: Pick<FacilityRecord, "amenities" | "main_physical_hole_count" | "total_physical_hole_count" | "total_hole_count">
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
typeof facility.total_physical_hole_count === "number" &&
|
||||||
|
Number.isFinite(facility.total_physical_hole_count) &&
|
||||||
|
facility.total_physical_hole_count > 0
|
||||||
|
) {
|
||||||
|
return getHoleCategory(String(facility.total_physical_hole_count));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof facility.main_physical_hole_count === "number" &&
|
||||||
|
Number.isFinite(facility.main_physical_hole_count) &&
|
||||||
|
facility.main_physical_hole_count > 0
|
||||||
|
) {
|
||||||
|
return getHoleCategory(String(facility.main_physical_hole_count));
|
||||||
|
}
|
||||||
|
|
||||||
|
const amenities = parseJson<Record<string, unknown>>(facility.amenities, {});
|
||||||
|
const amenityCategory = getHoleCategory(amenities.antall_hull);
|
||||||
|
if (amenityCategory) {
|
||||||
|
return amenityCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getHoleCategory(getFacilityVisibleHoleValue(facility));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFacilityPlaceHoleCount = (
|
||||||
|
facility: Pick<FacilityRecord, "amenities" | "main_physical_hole_count" | "total_physical_hole_count" | "total_hole_count">
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
typeof facility.total_physical_hole_count === "number" &&
|
||||||
|
Number.isFinite(facility.total_physical_hole_count) &&
|
||||||
|
facility.total_physical_hole_count > 0
|
||||||
|
) {
|
||||||
|
return facility.total_physical_hole_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof facility.main_physical_hole_count === "number" &&
|
||||||
|
Number.isFinite(facility.main_physical_hole_count) &&
|
||||||
|
facility.main_physical_hole_count > 0
|
||||||
|
) {
|
||||||
|
return facility.main_physical_hole_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const holeCategory = getFacilityPlaceHoleCategory(facility);
|
||||||
|
if (holeCategory === "27+") return 27;
|
||||||
|
if (holeCategory === "18") return 18;
|
||||||
|
if (holeCategory === "9") return 9;
|
||||||
|
if (holeCategory === "6") return 6;
|
||||||
|
return getFacilityVisibleHoleCount(facility);
|
||||||
|
};
|
||||||
|
|
||||||
const getPrimetimeGreenfee = (facility: FacilityRecord) => {
|
const getPrimetimeGreenfee = (facility: FacilityRecord) => {
|
||||||
const rows = parseJson<Array<Record<string, unknown>>>(facility.greenfee, []);
|
const rows = parseJson<Array<Record<string, unknown>>>(facility.greenfee, []);
|
||||||
if (!Array.isArray(rows) || rows.length === 0) return null;
|
if (!Array.isArray(rows) || rows.length === 0) return null;
|
||||||
|
|
@ -626,19 +736,7 @@ const getPrimetimeGreenfee = (facility: FacilityRecord) => {
|
||||||
const bestPrice = Math.max(...selectionPool.map((candidate) => candidate.price));
|
const bestPrice = Math.max(...selectionPool.map((candidate) => candidate.price));
|
||||||
const bestPriceCandidates = selectionPool.filter((candidate) => candidate.price === bestPrice);
|
const bestPriceCandidates = selectionPool.filter((candidate) => candidate.price === bestPrice);
|
||||||
const bestExplicitGuestCandidates = bestPriceCandidates.filter((candidate) => candidate.isExplicitGuest);
|
const bestExplicitGuestCandidates = bestPriceCandidates.filter((candidate) => candidate.isExplicitGuest);
|
||||||
const amenities = parseJson<Record<string, unknown>>(facility.amenities, {});
|
const totalHoleCount = getFacilityVisibleHoleCount(facility);
|
||||||
const totalHoleCount = (() => {
|
|
||||||
if (typeof facility.total_hole_count === "number" && Number.isFinite(facility.total_hole_count)) {
|
|
||||||
return facility.total_hole_count;
|
|
||||||
}
|
|
||||||
|
|
||||||
const holeCategory = getHoleCategory(amenities.antall_hull);
|
|
||||||
if (holeCategory === "27+") return 27;
|
|
||||||
if (holeCategory === "18") return 18;
|
|
||||||
if (holeCategory === "9") return 9;
|
|
||||||
if (holeCategory === "6") return 6;
|
|
||||||
return 0;
|
|
||||||
})();
|
|
||||||
const finalPriceCandidate =
|
const finalPriceCandidate =
|
||||||
bestExplicitGuestCandidates.length > 0 ? bestExplicitGuestCandidates[0] : bestPriceCandidates[0];
|
bestExplicitGuestCandidates.length > 0 ? bestExplicitGuestCandidates[0] : bestPriceCandidates[0];
|
||||||
const selectedIsPartialRound = finalPriceCandidate?.isPartialRound === true;
|
const selectedIsPartialRound = finalPriceCandidate?.isPartialRound === true;
|
||||||
|
|
@ -727,14 +825,14 @@ export const buildPlaceStats = (facilities: EnrichedFacility[]): PlaceStats => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
facilityCount: relevantFacilities.length,
|
facilityCount: relevantFacilities.length,
|
||||||
totalGolfHoles: relevantFacilities.reduce((sum, facility) => sum + (Number(facility.total_hole_count) || 0), 0),
|
totalGolfHoles: relevantFacilities.reduce((sum, facility) => sum + getFacilityPlaceHoleCount(facility), 0),
|
||||||
totalCourseCount: courseTotals.total,
|
totalCourseCount: courseTotals.total,
|
||||||
openCourseCount: courseTotals.open,
|
openCourseCount: courseTotals.open,
|
||||||
openNowCount: relevantFacilities.filter((facility) => OPEN_NOW_STATUSES.has(normalizeStatus(facility.primaryStatus))).length,
|
openNowCount: relevantFacilities.filter((facility) => OPEN_NOW_STATUSES.has(normalizeStatus(facility.primaryStatus))).length,
|
||||||
hole18Count: relevantFacilities.filter((facility) => getHoleCategory(parseJson<Record<string, unknown>>(facility.amenities, {}).antall_hull) === "18").length,
|
hole18Count: relevantFacilities.filter((facility) => getFacilityPlaceHoleCategory(facility) === "18").length,
|
||||||
hole9Count: relevantFacilities.filter((facility) => getHoleCategory(parseJson<Record<string, unknown>>(facility.amenities, {}).antall_hull) === "9").length,
|
hole9Count: relevantFacilities.filter((facility) => getFacilityPlaceHoleCategory(facility) === "9").length,
|
||||||
hole6Count: relevantFacilities.filter((facility) => getHoleCategory(parseJson<Record<string, unknown>>(facility.amenities, {}).antall_hull) === "6").length,
|
hole6Count: relevantFacilities.filter((facility) => getFacilityPlaceHoleCategory(facility) === "6").length,
|
||||||
hole27PlusCount: relevantFacilities.filter((facility) => getHoleCategory(parseJson<Record<string, unknown>>(facility.amenities, {}).antall_hull) === "27+").length,
|
hole27PlusCount: relevantFacilities.filter((facility) => getFacilityPlaceHoleCategory(facility) === "27+").length,
|
||||||
par3HoleCount: holeParTotals.par3,
|
par3HoleCount: holeParTotals.par3,
|
||||||
par4HoleCount: holeParTotals.par4,
|
par4HoleCount: holeParTotals.par4,
|
||||||
par5HoleCount: holeParTotals.par5,
|
par5HoleCount: holeParTotals.par5,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,19 @@ const getHoleLength = (hole: any, teeKey: string) => {
|
||||||
return typeof value === "number" || typeof value === "string" ? value : null;
|
return typeof value === "number" || typeof value === "string" ? value : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toNumber = (value: unknown) => {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isFinite(value) ? value : NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const normalized = value.replace(",", ".").trim();
|
||||||
|
return normalized ? Number(normalized) : NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NaN;
|
||||||
|
};
|
||||||
|
|
||||||
export default function CourseDisplay({ course, courseDisplayName = "" }: { course: any; courseDisplayName?: string }) {
|
export default function CourseDisplay({ course, courseDisplayName = "" }: { course: any; courseDisplayName?: string }) {
|
||||||
const [hcp, setHcp] = useState("15.0");
|
const [hcp, setHcp] = useState("15.0");
|
||||||
const [gender, setGender] = useState<Gender>('herrer');
|
const [gender, setGender] = useState<Gender>('herrer');
|
||||||
|
|
@ -78,30 +91,51 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour
|
||||||
const selectedColumn = activeColumns.find((column) => column.teeKey === selectedTeeKey) || activeColumns[0] || null;
|
const selectedColumn = activeColumns.find((column) => column.teeKey === selectedTeeKey) || activeColumns[0] || null;
|
||||||
const activeTee = selectedColumn?.tee || null;
|
const activeTee = selectedColumn?.tee || null;
|
||||||
const selectedTeeLabel = selectedColumn?.label || "Valgt utslag";
|
const selectedTeeLabel = selectedColumn?.label || "Valgt utslag";
|
||||||
|
const coursePar = allHoles.reduce((sum: number, hole: any) => sum + (Number(hole?.par) || 0), 0) || Number(course.par) || 0;
|
||||||
|
const strokeIndexOrder: number[] = Array.from(
|
||||||
|
new Set<number>(
|
||||||
|
allHoles
|
||||||
|
.map((hole: any) => Number(hole?.hcp_index))
|
||||||
|
.filter((value: number) => Number.isInteger(value) && value > 0)
|
||||||
|
)
|
||||||
|
).sort((a, b) => a - b);
|
||||||
|
const strokeRankByIndex = new Map<number, number>(
|
||||||
|
strokeIndexOrder.map((value: number, index: number) => [value, index + 1] as const)
|
||||||
|
);
|
||||||
|
const strokesPerCycle = strokeIndexOrder.length || allHoles.length || 18;
|
||||||
|
|
||||||
let playingHandicap = 0;
|
let playingHandicap = 0;
|
||||||
if (activeTee && hcp) {
|
if (activeTee && hcp) {
|
||||||
const exactHcp = Number(hcp.replace(',', '.'));
|
const exactHcp = toNumber(hcp);
|
||||||
const slope = Number(
|
const slope = Number(
|
||||||
gender === 'damer'
|
gender === 'damer'
|
||||||
? activeTee.slope_women || activeTee.slope_men || 113
|
? activeTee.slope_women || activeTee.slope_men || 113
|
||||||
: activeTee.slope_men || activeTee.slope_women || 113
|
: activeTee.slope_men || activeTee.slope_women || 113
|
||||||
);
|
);
|
||||||
const courseRating = Number(
|
const courseRating = toNumber(
|
||||||
String(
|
gender === 'damer'
|
||||||
gender === 'damer'
|
? activeTee.cr_women || activeTee.cr_men || coursePar
|
||||||
? activeTee.cr_women || activeTee.cr_men || course.par
|
: activeTee.cr_men || activeTee.cr_women || coursePar
|
||||||
: activeTee.cr_men || activeTee.cr_women || course.par
|
|
||||||
).replace(',', '.')
|
|
||||||
);
|
);
|
||||||
playingHandicap = Math.round((exactHcp * (slope / 113)) + (courseRating - course.par));
|
|
||||||
|
if (Number.isFinite(exactHcp) && Number.isFinite(courseRating)) {
|
||||||
|
playingHandicap = Math.round((exactHcp * (slope / 113)) + (courseRating - coursePar));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getExtraStrokes = (hcpIndex: number) => {
|
const getExtraStrokes = (hcpIndex: number) => {
|
||||||
if (!hcpIndex || isNaN(playingHandicap)) return 0;
|
if (!Number.isFinite(playingHandicap)) return 0;
|
||||||
const base = Math.floor(playingHandicap / 18);
|
|
||||||
const rem = playingHandicap % 18;
|
const normalizedHcpIndex = Number(hcpIndex);
|
||||||
return base + (hcpIndex <= rem ? 1 : 0);
|
const strokeRank = strokeRankByIndex.get(normalizedHcpIndex);
|
||||||
|
if (!strokeRank || strokesPerCycle <= 0) return 0;
|
||||||
|
|
||||||
|
const handicapMagnitude = Math.abs(playingHandicap);
|
||||||
|
const base = Math.floor(handicapMagnitude / strokesPerCycle);
|
||||||
|
const remainder = handicapMagnitude % strokesPerCycle;
|
||||||
|
const strokeCount = base + (strokeRank <= remainder ? 1 : 0);
|
||||||
|
|
||||||
|
return playingHandicap < 0 ? -strokeCount : strokeCount;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sumPar = (holes: any[]) => holes.reduce((acc, h) => acc + (h.par || 0), 0);
|
const sumPar = (holes: any[]) => holes.reduce((acc, h) => acc + (h.par || 0), 0);
|
||||||
|
|
@ -132,7 +166,7 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="rounded-2xl bg-white p-3 shadow-sm">
|
<div className="rounded-2xl bg-white p-3 shadow-sm">
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.16em] text-[#7ca982]">Mottatt</p>
|
<p className="text-[10px] font-black uppercase tracking-[0.16em] text-[#7ca982]">Mottatt</p>
|
||||||
<p className="mt-1 text-lg font-black text-[#11280f]">{extra > 0 ? `+${extra}` : '-'}</p>
|
<p className="mt-1 text-lg font-black text-[#11280f]">{extra > 0 ? `+${extra}` : extra < 0 ? `${extra}` : '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl bg-[#eef5e4] p-3 shadow-sm">
|
<div className="rounded-2xl bg-[#eef5e4] p-3 shadow-sm">
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.16em] text-[#7ca982]">Din Par</p>
|
<p className="text-[10px] font-black uppercase tracking-[0.16em] text-[#7ca982]">Din Par</p>
|
||||||
|
|
@ -171,7 +205,7 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour
|
||||||
<h2 className="break-words text-4xl font-black tracking-tighter text-[#11280f] sm:text-5xl">{courseDisplayName}</h2>
|
<h2 className="break-words text-4xl font-black tracking-tighter text-[#11280f] sm:text-5xl">{courseDisplayName}</h2>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="mt-2 mb-1 text-xs font-black uppercase tracking-[0.2em] text-[#7ca982]">
|
<p className="mt-2 mb-1 text-xs font-black uppercase tracking-[0.2em] text-[#7ca982]">
|
||||||
Par {course.par} • {course.length_meters || '--'} meter
|
Par {coursePar} • {course.length_meters || '--'} meter
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
|
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
|
||||||
Rating utløper: {slopeExpiry}
|
Rating utløper: {slopeExpiry}
|
||||||
|
|
@ -250,7 +284,7 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour
|
||||||
<td className="p-4 pl-10 text-left text-lg font-black text-gray-800">{hole.hole_number}</td>
|
<td className="p-4 pl-10 text-left text-lg font-black text-gray-800">{hole.hole_number}</td>
|
||||||
<td className="border-l border-gray-100 bg-white p-4">{hole.par}</td>
|
<td className="border-l border-gray-100 bg-white p-4">{hole.par}</td>
|
||||||
<td className="border-l border-gray-100 p-4 text-xs font-mono text-gray-300">{hole.hcp_index}</td>
|
<td className="border-l border-gray-100 p-4 text-xs font-mono text-gray-300">{hole.hcp_index}</td>
|
||||||
<td className="border-l border-gray-100 bg-[#7ca982]/5 p-4 font-mono text-[#7ca982]">{extra > 0 ? `+${extra}` : '-'}</td>
|
<td className="border-l border-gray-100 bg-[#7ca982]/5 p-4 font-mono text-[#7ca982]">{extra > 0 ? `+${extra}` : extra < 0 ? `${extra}` : '-'}</td>
|
||||||
<td className="border-l border-gray-100 bg-[#7ca982]/10 p-4 text-lg font-mono">{hole.par + extra}</td>
|
<td className="border-l border-gray-100 bg-[#7ca982]/10 p-4 text-lg font-mono">{hole.par + extra}</td>
|
||||||
{activeColumns.map((column) => (
|
{activeColumns.map((column) => (
|
||||||
<td key={column.teeKey} className={`border-l border-white p-4 font-mono transition-all ${column.theme.col} ${column.theme.text}`}>
|
<td key={column.teeKey} className={`border-l border-white p-4 font-mono transition-all ${column.theme.col} ${column.theme.text}`}>
|
||||||
|
|
@ -277,7 +311,7 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour
|
||||||
<td className="p-4 pl-10 text-left text-lg font-black text-gray-800">{hole.hole_number}</td>
|
<td className="p-4 pl-10 text-left text-lg font-black text-gray-800">{hole.hole_number}</td>
|
||||||
<td className="border-l border-gray-100 bg-white p-4">{hole.par}</td>
|
<td className="border-l border-gray-100 bg-white p-4">{hole.par}</td>
|
||||||
<td className="border-l border-gray-100 p-4 text-xs font-mono text-gray-300">{hole.hcp_index}</td>
|
<td className="border-l border-gray-100 p-4 text-xs font-mono text-gray-300">{hole.hcp_index}</td>
|
||||||
<td className="border-l border-gray-100 bg-[#7ca982]/5 p-4 font-mono text-[#7ca982]">{extra > 0 ? `+${extra}` : '-'}</td>
|
<td className="border-l border-gray-100 bg-[#7ca982]/5 p-4 font-mono text-[#7ca982]">{extra > 0 ? `+${extra}` : extra < 0 ? `${extra}` : '-'}</td>
|
||||||
<td className="border-l border-gray-100 bg-[#7ca982]/10 p-4 text-lg font-mono">{hole.par + extra}</td>
|
<td className="border-l border-gray-100 bg-[#7ca982]/10 p-4 text-lg font-mono">{hole.par + extra}</td>
|
||||||
{activeColumns.map((column) => (
|
{activeColumns.map((column) => (
|
||||||
<td key={column.teeKey} className={`border-l border-white p-4 font-mono transition-all ${column.theme.col} ${column.theme.text}`}>
|
<td key={column.teeKey} className={`border-l border-white p-4 font-mono transition-all ${column.theme.col} ${column.theme.text}`}>
|
||||||
|
|
|
||||||
|
|
@ -341,8 +341,56 @@ export default function FacilityDetailView({
|
||||||
return parseSharedJson(val, fallback);
|
return parseSharedJson(val, fallback);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCourseHoleCount = (course: any) => {
|
||||||
|
if (
|
||||||
|
typeof course?.physical_hole_count === "number" &&
|
||||||
|
Number.isFinite(course.physical_hole_count) &&
|
||||||
|
course.physical_hole_count > 0
|
||||||
|
) {
|
||||||
|
return course.physical_hole_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(course?.holes)) {
|
||||||
|
return course.holes.filter(Boolean).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof course?.holes === "number" && Number.isFinite(course.holes)) {
|
||||||
|
return course.holes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
const rawCourses = parseJson(facility.courses, []);
|
const rawCourses = parseJson(facility.courses, []);
|
||||||
const activeCourses = Array.isArray(rawCourses) ? rawCourses.filter((c: any) => c.holes && (typeof c.holes === 'string' || c.holes.length > 0)) : [];
|
const activeCourses = Array.isArray(rawCourses)
|
||||||
|
? rawCourses.filter((course: any) => course?.is_visible !== false && getCourseHoleCount(course) > 0)
|
||||||
|
: [];
|
||||||
|
const visibleHoleCount = activeCourses.reduce((sum: number, course: any) => sum + getCourseHoleCount(course), 0);
|
||||||
|
const totalIncludedPhysicalHoleCount = Array.isArray(rawCourses)
|
||||||
|
? rawCourses.reduce((sum: number, course: any) => {
|
||||||
|
if (!course || course.include_in_physical_hole_total === false) return sum;
|
||||||
|
const courseHoleCount = getCourseHoleCount(course);
|
||||||
|
return courseHoleCount > 0 ? sum + courseHoleCount : sum;
|
||||||
|
}, 0)
|
||||||
|
: 0;
|
||||||
|
const amenityHoleDisplay = (() => {
|
||||||
|
const raw = String(parseJson(facility.amenities, {}).antall_hull || "").trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
if (!raw.includes("+")) return raw;
|
||||||
|
|
||||||
|
const values = (raw.match(/\d+/g) || [])
|
||||||
|
.map((value) => Number(value))
|
||||||
|
.filter((value) => Number.isFinite(value) && value > 0);
|
||||||
|
if (values.length === 0) return raw;
|
||||||
|
|
||||||
|
return String(values.reduce((sum, value) => sum + value, 0));
|
||||||
|
})();
|
||||||
|
const facilityPhysicalHoleCount =
|
||||||
|
typeof facility.total_physical_hole_count === "number" &&
|
||||||
|
Number.isFinite(facility.total_physical_hole_count) &&
|
||||||
|
facility.total_physical_hole_count > 0
|
||||||
|
? facility.total_physical_hole_count
|
||||||
|
: totalIncludedPhysicalHoleCount || visibleHoleCount;
|
||||||
const amenities = parseJson(facility.amenities, {});
|
const amenities = parseJson(facility.amenities, {});
|
||||||
const camperParking = String(facility.camper_parking || "").trim();
|
const camperParking = String(facility.camper_parking || "").trim();
|
||||||
const galleryRaw = parseJson(facility.gallery, []);
|
const galleryRaw = parseJson(facility.gallery, []);
|
||||||
|
|
@ -417,7 +465,7 @@ export default function FacilityDetailView({
|
||||||
hasMediaSection ? { id: 'media', label: 'Media', showOnMobile: true } : null,
|
hasMediaSection ? { id: 'media', label: 'Media', showOnMobile: true } : null,
|
||||||
{ id: 'prices', label: 'Priser', showOnMobile: true },
|
{ id: 'prices', label: 'Priser', showOnMobile: true },
|
||||||
hasVtg ? { id: 'vtg', label: 'VTG', showOnMobile: true } : null,
|
hasVtg ? { id: 'vtg', label: 'VTG', showOnMobile: true } : null,
|
||||||
{ id: 'scorecards', label: 'Scorekort', showOnMobile: true },
|
activeCourses.length > 0 ? { id: 'scorecards', label: 'Scorekort', showOnMobile: true } : null,
|
||||||
].filter(
|
].filter(
|
||||||
(item): item is { id: string; label: string; showOnMobile: boolean } => Boolean(item)
|
(item): item is { id: string; label: string; showOnMobile: boolean } => Boolean(item)
|
||||||
);
|
);
|
||||||
|
|
@ -725,7 +773,7 @@ export default function FacilityDetailView({
|
||||||
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm text-sm font-bold text-gray-700 flex flex-col">
|
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm text-sm font-bold text-gray-700 flex flex-col">
|
||||||
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter text-[#11280f]">Banen</h3>
|
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter text-[#11280f]">Banen</h3>
|
||||||
<div className="space-y-5 flex-grow">
|
<div className="space-y-5 flex-grow">
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Hull:</span><span>{amenities.antall_hull || '--'}</span></div>
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Hull:</span><span>{facilityPhysicalHoleCount || amenityHoleDisplay || amenities.antall_hull || '--'}</span></div>
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Lengde:</span><span>{facility.length_meters ? `${facility.length_meters}m` : '--'}</span></div>
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Lengde:</span><span>{facility.length_meters ? `${facility.length_meters}m` : '--'}</span></div>
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Sesong:</span><span>{facility.season || '--'}</span></div>
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Sesong:</span><span>{facility.season || '--'}</span></div>
|
||||||
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Byggeår:</span><span>{facility.established_year || '--'}</span></div>
|
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Byggeår:</span><span>{facility.established_year || '--'}</span></div>
|
||||||
|
|
@ -1162,6 +1210,7 @@ export default function FacilityDetailView({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 9. SCOREKORT SEKSJON */}
|
{/* 9. SCOREKORT SEKSJON */}
|
||||||
|
{activeCourses.length > 0 && (
|
||||||
<section id="scorecards" className="pt-10 space-y-20 overflow-hidden">
|
<section id="scorecards" className="pt-10 space-y-20 overflow-hidden">
|
||||||
<h3 className="text-center text-3xl md:text-5xl font-black uppercase tracking-tighter">Scorekort</h3>
|
<h3 className="text-center text-3xl md:text-5xl font-black uppercase tracking-tighter">Scorekort</h3>
|
||||||
<div className="w-full flex flex-col items-center gap-20">
|
<div className="w-full flex flex-col items-center gap-20">
|
||||||
|
|
@ -1172,6 +1221,7 @@ export default function FacilityDetailView({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<FacilityFeedbackForm
|
<FacilityFeedbackForm
|
||||||
facilityId={Number(facility.id)}
|
facilityId={Number(facility.id)}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import {
|
||||||
createPageMetadata,
|
createPageMetadata,
|
||||||
} from "@/app/seo";
|
} from "@/app/seo";
|
||||||
|
|
||||||
export const revalidate = 900;
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const fallbackPageTitle = "Alle norske golfbaner: Finn din neste runde på gress | TeeOff.no";
|
const fallbackPageTitle = "Alle norske golfbaner: Finn din neste runde på gress | TeeOff.no";
|
||||||
|
|
@ -33,7 +32,7 @@ export async function generateMetadata() {
|
||||||
|
|
||||||
export default async function GolfCoursesIndexPage() {
|
export default async function GolfCoursesIndexPage() {
|
||||||
const seo = await resolveSitePageSeo("golfbaner", fallbackPageTitle, fallbackPageDescription);
|
const seo = await resolveSitePageSeo("golfbaner", fallbackPageTitle, fallbackPageDescription);
|
||||||
const safeData = await fetchPublicFacilities<FacilityRecord>("search", revalidate);
|
const safeData = await fetchPublicFacilities<FacilityRecord>("search", 0);
|
||||||
const collectionJsonLd = createCollectionPageJsonLd({
|
const collectionJsonLd = createCollectionPageJsonLd({
|
||||||
name: seo.title,
|
name: seo.title,
|
||||||
description: seo.description,
|
description: seo.description,
|
||||||
|
|
|
||||||
92
frontend/src/app/internal/revalidate-public/route.ts
Normal file
92
frontend/src/app/internal/revalidate-public/route.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { revalidatePath, revalidateTag } from "next/cache";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { PUBLIC_FACILITIES_CACHE_TAG } from "@/app/publicFacilities";
|
||||||
|
import { SITE_PAGE_CACHE_TAG } from "@/app/sitePages";
|
||||||
|
import { SITE_PAGE_SEO_CACHE_TAG } from "@/app/pageSeo";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type RevalidatePayload = {
|
||||||
|
includeFacilities?: boolean;
|
||||||
|
includePlacePages?: boolean;
|
||||||
|
includeSitePages?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FACILITY_PAGE_PATHS = ["/", "/golfbaner", "/medlemskap", "/vtg"];
|
||||||
|
const SITE_PAGE_PATHS = ["/golfbaner", "/vtg", "/medlemskap", "/banebesok", "/meninger", "/simulatorer"];
|
||||||
|
|
||||||
|
function getExpectedSecret(): string {
|
||||||
|
return String(process.env.PUBLIC_SESSION_SECRET || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthorized(request: Request): boolean {
|
||||||
|
const expectedSecret = getExpectedSecret();
|
||||||
|
if (!expectedSecret) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suppliedSecret = String(request.headers.get("x-teeoff-revalidate-secret") || "").trim();
|
||||||
|
return suppliedSecret === expectedSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function revalidateFacilityPages(includePlacePages: boolean): void {
|
||||||
|
revalidateTag(PUBLIC_FACILITIES_CACHE_TAG, "max");
|
||||||
|
|
||||||
|
for (const path of FACILITY_PAGE_PATHS) {
|
||||||
|
revalidatePath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/golfbaner/[slug]", "page");
|
||||||
|
revalidatePath("/sted/[slug]", "page");
|
||||||
|
|
||||||
|
if (includePlacePages) {
|
||||||
|
revalidatePath("/sted/[slug]", "page");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revalidateSitePageContent(): void {
|
||||||
|
revalidateTag(SITE_PAGE_CACHE_TAG, "max");
|
||||||
|
revalidateTag(SITE_PAGE_SEO_CACHE_TAG, "max");
|
||||||
|
|
||||||
|
for (const path of SITE_PAGE_PATHS) {
|
||||||
|
revalidatePath(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revalidatePlacePagesOnly(): void {
|
||||||
|
revalidatePath("/sted/[slug]", "page");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!isAuthorized(request)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: RevalidatePayload = {};
|
||||||
|
try {
|
||||||
|
payload = (await request.json()) as RevalidatePayload;
|
||||||
|
} catch {
|
||||||
|
payload = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const includeFacilities = payload.includeFacilities !== false;
|
||||||
|
const includePlacePages = payload.includePlacePages === true;
|
||||||
|
const includeSitePages = payload.includeSitePages === true;
|
||||||
|
|
||||||
|
if (includeFacilities) {
|
||||||
|
revalidateFacilityPages(includePlacePages);
|
||||||
|
} else if (includePlacePages) {
|
||||||
|
revalidatePlacePagesOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeSitePages) {
|
||||||
|
revalidateSitePageContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
revalidated: true,
|
||||||
|
includeFacilities,
|
||||||
|
includePlacePages,
|
||||||
|
includeSitePages,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,13 @@ import { cache } from "react";
|
||||||
import { API_URL } from "@/config/constants";
|
import { API_URL } from "@/config/constants";
|
||||||
import { resolveSeoDescription, resolveSeoTitle } from "@/app/seo";
|
import { resolveSeoDescription, resolveSeoTitle } from "@/app/seo";
|
||||||
|
|
||||||
|
export const SITE_PAGE_SEO_CACHE_TAG = "site-page-seo";
|
||||||
|
|
||||||
|
export function getSitePageSeoCacheTags(pageKey: string): string[] {
|
||||||
|
const normalizedPageKey = String(pageKey || "").trim().toLowerCase() || "default";
|
||||||
|
return [SITE_PAGE_SEO_CACHE_TAG, `${SITE_PAGE_SEO_CACHE_TAG}:${normalizedPageKey}`];
|
||||||
|
}
|
||||||
|
|
||||||
export type SitePageSeoRecord = {
|
export type SitePageSeoRecord = {
|
||||||
page_key?: string;
|
page_key?: string;
|
||||||
meta_title?: string | null;
|
meta_title?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
|
import { unstable_cache } from "next/cache";
|
||||||
import { API_URL } from "@/config/constants";
|
import { API_URL } from "@/config/constants";
|
||||||
|
|
||||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
export async function fetchPublicFacilities<T>(
|
export const PUBLIC_FACILITIES_CACHE_TAG = "public-facilities";
|
||||||
|
|
||||||
|
export function getPublicFacilitiesCacheTags(view: string): string[] {
|
||||||
|
const normalizedView = String(view || "").trim().toLowerCase() || "default";
|
||||||
|
return [PUBLIC_FACILITIES_CACHE_TAG, `${PUBLIC_FACILITIES_CACHE_TAG}:${normalizedView}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPublicFacilitiesUncached<T>(
|
||||||
view: string,
|
view: string,
|
||||||
_revalidateSeconds: number,
|
allowEmpty: boolean,
|
||||||
{
|
attempts: number,
|
||||||
allowEmpty = false,
|
|
||||||
attempts = 3,
|
|
||||||
}: {
|
|
||||||
allowEmpty?: boolean;
|
|
||||||
attempts?: number;
|
|
||||||
} = {},
|
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
|
@ -45,3 +47,26 @@ export async function fetchPublicFacilities<T>(
|
||||||
|
|
||||||
throw lastError ?? new Error("Kunne ikke hente anlegg");
|
throw lastError ?? new Error("Kunne ikke hente anlegg");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchPublicFacilities<T>(
|
||||||
|
view: string,
|
||||||
|
_revalidateSeconds: number,
|
||||||
|
{
|
||||||
|
allowEmpty = false,
|
||||||
|
attempts = 3,
|
||||||
|
}: {
|
||||||
|
allowEmpty?: boolean;
|
||||||
|
attempts?: number;
|
||||||
|
} = {},
|
||||||
|
): Promise<T[]> {
|
||||||
|
const normalizedView = String(view || "").trim().toLowerCase() || "default";
|
||||||
|
const readFromCache = unstable_cache(
|
||||||
|
() => fetchPublicFacilitiesUncached<T>(view, allowEmpty, attempts),
|
||||||
|
[`public-facilities:${normalizedView}`, allowEmpty ? "allow-empty" : "require-data"],
|
||||||
|
{
|
||||||
|
tags: getPublicFacilitiesCacheTags(view),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return readFromCache();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { API_URL } from "@/config/constants";
|
import { API_URL } from "@/config/constants";
|
||||||
|
|
||||||
|
export const SITE_PAGE_CACHE_TAG = "site-pages";
|
||||||
|
|
||||||
|
export function getSitePageCacheTags(pageKey: string): string[] {
|
||||||
|
const normalizedPageKey = String(pageKey || "").trim().toLowerCase() || "default";
|
||||||
|
return [SITE_PAGE_CACHE_TAG, `${SITE_PAGE_CACHE_TAG}:${normalizedPageKey}`];
|
||||||
|
}
|
||||||
|
|
||||||
export type SitePageRecord = {
|
export type SitePageRecord = {
|
||||||
page_key: string;
|
page_key: string;
|
||||||
eyebrow?: string | null;
|
eyebrow?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import HeaderSearch from "@/components/HeaderSearch";
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
href: string;
|
href: string;
|
||||||
|
|
@ -134,7 +135,7 @@ export default function Header() {
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="hidden items-center gap-8 text-[12px] font-extrabold uppercase tracking-[0.14em] text-white/90 md:flex">
|
<nav className="hidden items-center gap-6 text-[12px] font-extrabold uppercase tracking-[0.14em] text-white/90 lg:flex xl:gap-8">
|
||||||
<div
|
<div
|
||||||
className="group relative"
|
className="group relative"
|
||||||
onMouseEnter={openPlacesMenu}
|
onMouseEnter={openPlacesMenu}
|
||||||
|
|
@ -247,9 +248,16 @@ export default function Header() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<HeaderSearch
|
||||||
|
onOpen={() => {
|
||||||
|
setIsPlacesOpen(false);
|
||||||
|
setIsResourcesOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<button onClick={() => setIsOpen(!isOpen)} className="p-2 text-white md:hidden" aria-label="Meny">
|
<button onClick={() => setIsOpen(!isOpen)} className="p-2 text-white lg:hidden" aria-label="Meny">
|
||||||
<div className="mb-1.5 h-0.5 w-6 bg-current transition-all"></div>
|
<div className="mb-1.5 h-0.5 w-6 bg-current transition-all"></div>
|
||||||
<div className="mb-1.5 h-0.5 w-6 bg-current"></div>
|
<div className="mb-1.5 h-0.5 w-6 bg-current"></div>
|
||||||
<div className="h-0.5 w-6 bg-current"></div>
|
<div className="h-0.5 w-6 bg-current"></div>
|
||||||
|
|
@ -315,6 +323,13 @@ export default function Header() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-white/10 pt-5">
|
||||||
|
<p className={mobileMenuSectionHeadingClass}>Søk</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<HeaderSearch mobile onNavigate={closeAllMenus} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
408
frontend/src/components/HeaderSearch.tsx
Normal file
408
frontend/src/components/HeaderSearch.tsx
Normal file
|
|
@ -0,0 +1,408 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||||
|
import {
|
||||||
|
buildMenuSearchHref,
|
||||||
|
normalizeMenuSearchText,
|
||||||
|
rankMenuSearchItems,
|
||||||
|
type MenuSearchItem,
|
||||||
|
} from "@/lib/menuSearch";
|
||||||
|
|
||||||
|
type HeaderSearchProps = {
|
||||||
|
mobile?: boolean;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
onOpen?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchFacilityRecord = {
|
||||||
|
slug?: string;
|
||||||
|
name?: string;
|
||||||
|
city?: string | null;
|
||||||
|
county?: string | null;
|
||||||
|
banetype?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchArticleRecord = {
|
||||||
|
section?: "banebesok" | "meninger" | string | null;
|
||||||
|
status?: string | null;
|
||||||
|
slug?: string;
|
||||||
|
title?: string;
|
||||||
|
facility_name?: string | null;
|
||||||
|
location_label?: string | null;
|
||||||
|
eyebrow?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let menuSearchItemsPromise: Promise<MenuSearchItem[]> | null = null;
|
||||||
|
|
||||||
|
async function fetchJsonArray<T>(url: string) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Søk svarte med ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as unknown;
|
||||||
|
if (!Array.isArray(payload)) {
|
||||||
|
throw new Error("Søk svarte ikke med en liste");
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFacilitySearchItem(facility: SearchFacilityRecord): MenuSearchItem | null {
|
||||||
|
const slug = String(facility?.slug || "").trim();
|
||||||
|
const title = String(facility?.name || "").trim();
|
||||||
|
if (!slug || !title) return null;
|
||||||
|
|
||||||
|
const subtitle = [facility.city, facility.county]
|
||||||
|
.map((value) => String(value || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
const searchTerms = [
|
||||||
|
title,
|
||||||
|
slug.replace(/-/g, " "),
|
||||||
|
facility.city,
|
||||||
|
facility.county,
|
||||||
|
facility.banetype,
|
||||||
|
"golfbane",
|
||||||
|
"golfbaner",
|
||||||
|
"baneprofil",
|
||||||
|
]
|
||||||
|
.map((value) => String(value || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
href: `/golfbaner/${slug}`,
|
||||||
|
kind: "facility",
|
||||||
|
label: "Bane",
|
||||||
|
subtitle: subtitle || undefined,
|
||||||
|
titleText: normalizeMenuSearchText(title),
|
||||||
|
searchText: normalizeMenuSearchText(searchTerms),
|
||||||
|
priority: 120,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArticleSearchItem(article: SearchArticleRecord): MenuSearchItem | null {
|
||||||
|
const section = article.section === "meninger" ? "meninger" : article.section === "banebesok" ? "banebesok" : null;
|
||||||
|
const slug = String(article?.slug || "").trim();
|
||||||
|
const title = String(article?.title || "").trim();
|
||||||
|
if (!section || !slug || !title) return null;
|
||||||
|
|
||||||
|
const label = section === "meninger" ? "Meninger" : "Banebesøk";
|
||||||
|
const subtitle =
|
||||||
|
String(article.facility_name || "").trim() ||
|
||||||
|
String(article.location_label || "").trim() ||
|
||||||
|
undefined;
|
||||||
|
const searchTerms = [
|
||||||
|
title,
|
||||||
|
slug.replace(/-/g, " "),
|
||||||
|
article.facility_name,
|
||||||
|
article.location_label,
|
||||||
|
article.eyebrow,
|
||||||
|
label,
|
||||||
|
"artikkel",
|
||||||
|
]
|
||||||
|
.map((value) => String(value || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
href: buildMenuSearchHref(section, slug),
|
||||||
|
kind: "article",
|
||||||
|
label,
|
||||||
|
subtitle,
|
||||||
|
section,
|
||||||
|
titleText: normalizeMenuSearchText(title),
|
||||||
|
searchText: normalizeMenuSearchText(searchTerms),
|
||||||
|
priority: section === "banebesok" ? 72 : 64,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMenuSearchItems() {
|
||||||
|
if (!menuSearchItemsPromise) {
|
||||||
|
menuSearchItemsPromise = Promise.allSettled([
|
||||||
|
fetchJsonArray<SearchFacilityRecord>("/api/facilities?view=search"),
|
||||||
|
fetchJsonArray<SearchArticleRecord>("/api/articles?section=banebesok"),
|
||||||
|
fetchJsonArray<SearchArticleRecord>("/api/articles?section=meninger"),
|
||||||
|
])
|
||||||
|
.then((results) => {
|
||||||
|
const items = new Map<string, MenuSearchItem>();
|
||||||
|
|
||||||
|
if (results[0].status === "fulfilled") {
|
||||||
|
for (const facility of results[0].value) {
|
||||||
|
const item = buildFacilitySearchItem(facility);
|
||||||
|
if (item) items.set(item.href, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const result of results.slice(1)) {
|
||||||
|
if (result.status !== "fulfilled") continue;
|
||||||
|
|
||||||
|
for (const article of result.value) {
|
||||||
|
const item = buildArticleSearchItem(article);
|
||||||
|
if (item) items.set(item.href, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.size === 0) {
|
||||||
|
throw new Error("Ingen søkedata tilgjengelig");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(items.values());
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
menuSearchItemsPromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return menuSearchItemsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
|
||||||
|
<path
|
||||||
|
d="M10.5 4.75a5.75 5.75 0 1 0 0 11.5a5.75 5.75 0 0 0 0-11.5Zm0 1.5a4.25 4.25 0 1 1 0 8.5a4.25 4.25 0 0 1 0-8.5Zm6.43 9.62l3.07 3.07a.75.75 0 1 1-1.06 1.06l-3.07-3.07a.75.75 0 1 1 1.06-1.06Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeaderSearch({ mobile = false, onNavigate, onOpen }: HeaderSearchProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [items, setItems] = useState<MenuSearchItem[] | null>(null);
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
|
||||||
|
const isExpanded = mobile || isFocused || Boolean(query.trim());
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
if (!items || !query.trim()) return [];
|
||||||
|
return rankMenuSearchItems(items, query, 6);
|
||||||
|
}, [items, query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!results.length) {
|
||||||
|
setActiveIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveIndex((current) => (current >= results.length ? 0 : current));
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mobile) return undefined;
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (!containerRef.current?.contains(event.target as Node)) {
|
||||||
|
setIsFocused(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
}, [mobile]);
|
||||||
|
|
||||||
|
const ensureItems = async () => {
|
||||||
|
if (items) return items;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextItems = await loadMenuSearchItems();
|
||||||
|
setItems(nextItems);
|
||||||
|
return nextItems;
|
||||||
|
} catch {
|
||||||
|
setError("Søket kunne ikke lastes akkurat nå.");
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
setIsFocused(true);
|
||||||
|
onOpen?.();
|
||||||
|
void ensureItems();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateTo = (href: string) => {
|
||||||
|
setQuery("");
|
||||||
|
setIsFocused(false);
|
||||||
|
onNavigate?.();
|
||||||
|
router.push(href);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openOrSubmit = async () => {
|
||||||
|
if (!isExpanded) {
|
||||||
|
handleOpen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextItems = items ?? (await ensureItems());
|
||||||
|
const rankedResults = rankMenuSearchItems(nextItems, query, 1);
|
||||||
|
|
||||||
|
if (rankedResults[0]) {
|
||||||
|
navigateTo(rankedResults[0].href);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await openOrSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = async (event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!results.length) return;
|
||||||
|
setActiveIndex((current) => (current + 1) % results.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!results.length) return;
|
||||||
|
setActiveIndex((current) => (current - 1 + results.length) % results.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setIsFocused(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Enter" && results[activeIndex]) {
|
||||||
|
event.preventDefault();
|
||||||
|
navigateTo(results[activeIndex].href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items && event.key.length === 1) {
|
||||||
|
void ensureItems();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapperClassName = mobile ? "w-full" : "relative h-11 w-11";
|
||||||
|
const shellClassName = mobile
|
||||||
|
? "relative w-full"
|
||||||
|
: `absolute right-0 top-1/2 -translate-y-1/2 transition-[width] duration-200 ${isExpanded ? "w-[min(20rem,calc(100vw-2rem))]" : "w-11"}`;
|
||||||
|
const formClassName = mobile
|
||||||
|
? "rounded-[1.35rem] border border-white/12 bg-white/8"
|
||||||
|
: "rounded-full border border-white/14 bg-white/8 shadow-[0_8px_24px_rgba(0,0,0,0.18)] backdrop-blur-md";
|
||||||
|
const inputClassName = mobile
|
||||||
|
? "w-full bg-transparent text-base font-bold text-white placeholder:text-white/58 focus:outline-none"
|
||||||
|
: `bg-transparent text-[13px] font-bold tracking-normal text-white placeholder:text-white/58 focus:outline-none transition-[width,opacity] duration-200 ${
|
||||||
|
isExpanded ? "w-full opacity-100" : "pointer-events-none w-0 opacity-0"
|
||||||
|
}`;
|
||||||
|
const showDropdown = isFocused && (Boolean(query.trim()) || loading || Boolean(error));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={wrapperClassName}
|
||||||
|
onBlurCapture={(event) => {
|
||||||
|
const nextTarget = event.relatedTarget as Node | null;
|
||||||
|
if (!containerRef.current?.contains(nextTarget)) {
|
||||||
|
setIsFocused(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={shellClassName}>
|
||||||
|
<form className={formClassName} onSubmit={handleSubmit}>
|
||||||
|
<div
|
||||||
|
className={`flex items-center ${mobile ? "gap-3 px-4 py-3.5" : `px-3 py-2.5 ${isExpanded ? "gap-3" : "justify-center"}`}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void openOrSubmit();
|
||||||
|
}}
|
||||||
|
className="flex h-5 w-5 shrink-0 items-center justify-center text-white/78 transition hover:text-[#8BC34A]"
|
||||||
|
aria-label="Søk"
|
||||||
|
>
|
||||||
|
<SearchIcon />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
onFocus={handleOpen}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Søk bane eller artikkel"
|
||||||
|
className={inputClassName}
|
||||||
|
aria-label="Søk etter bane eller artikkel"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{showDropdown ? (
|
||||||
|
<div className={`absolute ${mobile ? "left-0 right-0" : "right-0 w-full"} top-[calc(100%+0.7rem)] z-[2200]`}>
|
||||||
|
<div className="overflow-hidden rounded-[1.5rem] border border-white/10 bg-[#25312A] shadow-2xl">
|
||||||
|
{loading ? (
|
||||||
|
<div className="px-4 py-4 text-sm font-bold text-white/72">Laster søk …</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="px-4 py-4 text-sm font-bold text-[#FFD7CC]">{error}</div>
|
||||||
|
) : results.length > 0 ? (
|
||||||
|
<ul className="py-2">
|
||||||
|
{results.map((result, index) => (
|
||||||
|
<li key={result.href}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
navigateTo(result.href);
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-start justify-between gap-4 px-4 py-3 text-left transition ${
|
||||||
|
index === activeIndex ? "bg-white/10" : "hover:bg-white/6"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-black tracking-normal text-white">
|
||||||
|
{result.title}
|
||||||
|
</span>
|
||||||
|
{result.subtitle ? (
|
||||||
|
<span className="block truncate text-xs font-bold tracking-normal text-white/62">
|
||||||
|
{result.subtitle}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 rounded-full border border-white/10 bg-white/8 px-2.5 py-1 text-[10px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
|
||||||
|
{result.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : query.trim() ? (
|
||||||
|
<div className="px-4 py-4 text-sm font-bold text-white/72">Ingen treff på søket ditt.</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
frontend/src/lib/menuSearch.ts
Normal file
135
frontend/src/lib/menuSearch.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
export type MenuSearchSection = "banebesok" | "meninger";
|
||||||
|
|
||||||
|
export type MenuSearchKind = "facility" | "article";
|
||||||
|
|
||||||
|
export type MenuSearchItem = {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
kind: MenuSearchKind;
|
||||||
|
label: string;
|
||||||
|
subtitle?: string;
|
||||||
|
section?: MenuSearchSection;
|
||||||
|
titleText: string;
|
||||||
|
searchText: string;
|
||||||
|
priority?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MENU_SEARCH_STOP_WORDS = new Set([
|
||||||
|
"artikkel",
|
||||||
|
"artikler",
|
||||||
|
"av",
|
||||||
|
"bane",
|
||||||
|
"banebesok",
|
||||||
|
"baner",
|
||||||
|
"de",
|
||||||
|
"den",
|
||||||
|
"det",
|
||||||
|
"en",
|
||||||
|
"et",
|
||||||
|
"for",
|
||||||
|
"golf",
|
||||||
|
"golfbane",
|
||||||
|
"golfbaner",
|
||||||
|
"i",
|
||||||
|
"med",
|
||||||
|
"og",
|
||||||
|
"om",
|
||||||
|
"pa",
|
||||||
|
"til",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const normalizeMenuSearchText = (value: unknown) =>
|
||||||
|
String(value ?? "")
|
||||||
|
.replace(/[æøå]/gi, (char) => {
|
||||||
|
const normalized = char.toLowerCase();
|
||||||
|
if (normalized === "æ") return "ae";
|
||||||
|
if (normalized === "ø") return "o";
|
||||||
|
if (normalized === "å") return "a";
|
||||||
|
return normalized;
|
||||||
|
})
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
export const tokenizeMenuSearchQuery = (query: string) => {
|
||||||
|
const tokens = normalizeMenuSearchText(query)
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
const filteredTokens = tokens.filter((token) => !MENU_SEARCH_STOP_WORDS.has(token));
|
||||||
|
return filteredTokens.length > 0 ? filteredTokens : tokens;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildMenuSearchHref = (section: MenuSearchSection, slug: string) =>
|
||||||
|
section === "meninger" ? `/meninger/${slug}` : `/banebesok/${slug}`;
|
||||||
|
|
||||||
|
export function rankMenuSearchItems(items: MenuSearchItem[], query: string, limit = 6) {
|
||||||
|
const normalizedQuery = normalizeMenuSearchText(query);
|
||||||
|
if (!normalizedQuery) return [];
|
||||||
|
|
||||||
|
const queryTokens = tokenizeMenuSearchQuery(query);
|
||||||
|
|
||||||
|
return items
|
||||||
|
.map((item) => ({
|
||||||
|
item,
|
||||||
|
score: scoreMenuSearchItem(item, normalizedQuery, queryTokens),
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.score >= 0)
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.score !== right.score) return right.score - left.score;
|
||||||
|
return left.item.title.localeCompare(right.item.title, "nb");
|
||||||
|
})
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((entry) => entry.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreMenuSearchItem(item: MenuSearchItem, normalizedQuery: string, queryTokens: string[]) {
|
||||||
|
const title = item.titleText;
|
||||||
|
const search = item.searchText;
|
||||||
|
let score = item.priority ?? 0;
|
||||||
|
|
||||||
|
if (title === normalizedQuery) {
|
||||||
|
score += 1_200;
|
||||||
|
} else if (title.startsWith(normalizedQuery)) {
|
||||||
|
score += 900;
|
||||||
|
} else if (search.startsWith(normalizedQuery)) {
|
||||||
|
score += 700;
|
||||||
|
} else if (title.includes(normalizedQuery)) {
|
||||||
|
score += 520;
|
||||||
|
} else if (search.includes(normalizedQuery)) {
|
||||||
|
score += 320;
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchedTokens = 0;
|
||||||
|
|
||||||
|
for (const token of queryTokens) {
|
||||||
|
if (title.includes(token)) {
|
||||||
|
matchedTokens += 1;
|
||||||
|
score += title.startsWith(token) ? 160 : 110;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search.includes(token)) {
|
||||||
|
matchedTokens += 1;
|
||||||
|
score += 55;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedTokens === 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryTokens.length > 1 && matchedTokens === queryTokens.length) {
|
||||||
|
score += 140;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.kind === "facility") {
|
||||||
|
score += 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
3
init.sql
3
init.sql
|
|
@ -31,12 +31,15 @@ CREATE TABLE courses (
|
||||||
facility_id INTEGER REFERENCES facilities(id) ON DELETE CASCADE,
|
facility_id INTEGER REFERENCES facilities(id) ON DELETE CASCADE,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
holes INTEGER NOT NULL,
|
holes INTEGER NOT NULL,
|
||||||
|
physical_hole_count INTEGER,
|
||||||
|
include_in_physical_hole_total BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
par INTEGER,
|
par INTEGER,
|
||||||
length_meters INTEGER,
|
length_meters INTEGER,
|
||||||
course_type VARCHAR(100),
|
course_type VARCHAR(100),
|
||||||
architect VARCHAR(255),
|
architect VARCHAR(255),
|
||||||
course_guide_url VARCHAR(255),
|
course_guide_url VARCHAR(255),
|
||||||
status VARCHAR(50) DEFAULT 'Ukjent',
|
status VARCHAR(50) DEFAULT 'Ukjent',
|
||||||
|
is_visible BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
status_updated_at TIMESTAMP,
|
status_updated_at TIMESTAMP,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
|
|
||||||
10
migrations/2026-05-05_add_course_physical_hole_count.sql
Normal file
10
migrations/2026-05-05_add_course_physical_hole_count.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
ALTER TABLE courses
|
||||||
|
ADD COLUMN IF NOT EXISTS physical_hole_count INTEGER;
|
||||||
|
|
||||||
|
UPDATE courses c
|
||||||
|
SET physical_hole_count = NULLIF(REGEXP_REPLACE(COALESCE(f.amenities->>'antall_hull', ''), '[^0-9]+', '', 'g'), '')::INTEGER
|
||||||
|
FROM facilities f
|
||||||
|
WHERE c.facility_id = f.id
|
||||||
|
AND c.is_main_course = TRUE
|
||||||
|
AND c.physical_hole_count IS NULL
|
||||||
|
AND COALESCE(f.amenities->>'antall_hull', '') <> '';
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
ALTER TABLE courses
|
||||||
|
ADD COLUMN IF NOT EXISTS include_in_physical_hole_total BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
|
|
||||||
|
UPDATE courses
|
||||||
|
SET include_in_physical_hole_total = TRUE
|
||||||
|
WHERE include_in_physical_hole_total IS NULL;
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
WITH parsed_hole_counts AS (
|
||||||
|
SELECT
|
||||||
|
c.id AS course_id,
|
||||||
|
NULLIF(SUBSTRING(COALESCE(f.amenities->>'antall_hull', '') FROM '([0-9]+)'), '')::INTEGER AS parsed_hole_count
|
||||||
|
FROM courses c
|
||||||
|
JOIN facilities f ON f.id = c.facility_id
|
||||||
|
WHERE c.is_main_course = TRUE
|
||||||
|
AND COALESCE(f.amenities->>'antall_hull', '') <> ''
|
||||||
|
)
|
||||||
|
UPDATE courses c
|
||||||
|
SET physical_hole_count = p.parsed_hole_count
|
||||||
|
FROM parsed_hole_counts p
|
||||||
|
WHERE c.id = p.course_id
|
||||||
|
AND p.parsed_hole_count IS NOT NULL
|
||||||
|
AND (
|
||||||
|
c.physical_hole_count IS NULL
|
||||||
|
OR c.physical_hole_count > (p.parsed_hole_count * 2)
|
||||||
|
);
|
||||||
|
|
@ -47,12 +47,15 @@ CREATE TABLE courses (
|
||||||
facility_id INTEGER REFERENCES facilities(id) ON DELETE CASCADE,
|
facility_id INTEGER REFERENCES facilities(id) ON DELETE CASCADE,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
holes INTEGER,
|
holes INTEGER,
|
||||||
|
physical_hole_count INTEGER,
|
||||||
|
include_in_physical_hole_total BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
par INTEGER,
|
par INTEGER,
|
||||||
length_meters INTEGER,
|
length_meters INTEGER,
|
||||||
course_type VARCHAR(255),
|
course_type VARCHAR(255),
|
||||||
architect VARCHAR(255),
|
architect VARCHAR(255),
|
||||||
status VARCHAR(255),
|
status VARCHAR(255),
|
||||||
is_main_course BOOLEAN DEFAULT TRUE,
|
is_main_course BOOLEAN DEFAULT TRUE,
|
||||||
|
is_visible BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue