Stedsideinfo er klart. Dette er før forsøk på å speede opp stedsidene
This commit is contained in:
parent
db478c849e
commit
356a8642b9
11 changed files with 1575 additions and 84 deletions
251
backend/main.py
251
backend/main.py
|
|
@ -31,7 +31,7 @@ import qrcode
|
||||||
import qrcode.image.svg
|
import qrcode.image.svg
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional, List, Any
|
from typing import Optional, List, Any
|
||||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
|
@ -633,6 +633,12 @@ class PublicMagicLinkRequest(BaseModel):
|
||||||
return_to: Optional[str] = "/"
|
return_to: Optional[str] = "/"
|
||||||
|
|
||||||
|
|
||||||
|
class FacilityRatingUpsertRequest(BaseModel):
|
||||||
|
quality_rating: int = Field(ge=1, le=5)
|
||||||
|
conditions_rating: int = Field(ge=1, le=5)
|
||||||
|
hospitality_rating: int = Field(ge=1, le=5)
|
||||||
|
|
||||||
|
|
||||||
class PublicContactFormRequest(BaseModel):
|
class PublicContactFormRequest(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
email: str
|
email: str
|
||||||
|
|
@ -677,7 +683,7 @@ def format_row(row):
|
||||||
]
|
]
|
||||||
json_dict_fields = [
|
json_dict_fields = [
|
||||||
'amenities', 'vtg', 'nsg_data', 'golfamore_data',
|
'amenities', 'vtg', 'nsg_data', 'golfamore_data',
|
||||||
'membership_draft', 'greenfee_draft', 'vtg_draft', 'golfpakker_draft'
|
'membership_draft', 'greenfee_draft', 'vtg_draft', 'golfpakker_draft', 'hole_par_counts'
|
||||||
]
|
]
|
||||||
|
|
||||||
for field in json_list_fields:
|
for field in json_list_fields:
|
||||||
|
|
@ -1268,6 +1274,87 @@ async def require_authenticated_public_user(request: Request) -> dict[str, Any]:
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_published_facility_by_slug(conn, slug: str):
|
||||||
|
return await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT id, name, slug
|
||||||
|
FROM facilities
|
||||||
|
WHERE slug = $1
|
||||||
|
AND COALESCE(is_published, TRUE) = TRUE
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_facility_rating_payload(
|
||||||
|
conn,
|
||||||
|
facility_id: int,
|
||||||
|
viewer_id: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
summary_row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::int AS rating_count,
|
||||||
|
ROUND(AVG(quality_rating)::numeric, 1) AS quality_average,
|
||||||
|
ROUND(AVG(conditions_rating)::numeric, 1) AS conditions_average,
|
||||||
|
ROUND(AVG(hospitality_rating)::numeric, 1) AS hospitality_average,
|
||||||
|
ROUND(AVG((quality_rating + conditions_rating + hospitality_rating)::numeric / 3), 1) AS overall_average
|
||||||
|
FROM facility_ratings
|
||||||
|
WHERE facility_id = $1
|
||||||
|
""",
|
||||||
|
facility_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_rating = None
|
||||||
|
if viewer_id is not None:
|
||||||
|
user_row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
quality_rating,
|
||||||
|
conditions_rating,
|
||||||
|
hospitality_rating,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM facility_ratings
|
||||||
|
WHERE facility_id = $1 AND user_id = $2
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
facility_id,
|
||||||
|
viewer_id,
|
||||||
|
)
|
||||||
|
if user_row:
|
||||||
|
user_rating = {
|
||||||
|
"quality_rating": int(user_row["quality_rating"]),
|
||||||
|
"conditions_rating": int(user_row["conditions_rating"]),
|
||||||
|
"hospitality_rating": int(user_row["hospitality_rating"]),
|
||||||
|
"overall_rating": round(
|
||||||
|
(
|
||||||
|
int(user_row["quality_rating"])
|
||||||
|
+ int(user_row["conditions_rating"])
|
||||||
|
+ int(user_row["hospitality_rating"])
|
||||||
|
)
|
||||||
|
/ 3,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
"created_at": user_row["created_at"].isoformat() if user_row["created_at"] else None,
|
||||||
|
"updated_at": user_row["updated_at"].isoformat() if user_row["updated_at"] else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
rating_count = int(summary_row["rating_count"] or 0) if summary_row else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": {
|
||||||
|
"rating_count": rating_count,
|
||||||
|
"quality_average": float(summary_row["quality_average"]) if summary_row and summary_row["quality_average"] is not None else None,
|
||||||
|
"conditions_average": float(summary_row["conditions_average"]) if summary_row and summary_row["conditions_average"] is not None else None,
|
||||||
|
"hospitality_average": float(summary_row["hospitality_average"]) if summary_row and summary_row["hospitality_average"] is not None else None,
|
||||||
|
"overall_average": float(summary_row["overall_average"]) if summary_row and summary_row["overall_average"] is not None else None,
|
||||||
|
},
|
||||||
|
"user_rating": user_rating,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def find_published_article_by_slug(conn, slug: str, section: str | None = None):
|
async def find_published_article_by_slug(conn, slug: str, section: str | None = None):
|
||||||
if section:
|
if section:
|
||||||
return await conn.fetchrow(
|
return await conn.fetchrow(
|
||||||
|
|
@ -1476,6 +1563,30 @@ async def ensure_public_user_tables(conn):
|
||||||
CREATE INDEX IF NOT EXISTS public_magic_links_user_idx
|
CREATE INDEX IF NOT EXISTS public_magic_links_user_idx
|
||||||
ON public_magic_links (user_id, created_at DESC)
|
ON public_magic_links (user_id, created_at DESC)
|
||||||
""")
|
""")
|
||||||
|
await conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS facility_ratings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
facility_id INTEGER NOT NULL REFERENCES facilities(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES public_users(id) ON DELETE CASCADE,
|
||||||
|
quality_rating SMALLINT NOT NULL,
|
||||||
|
conditions_rating SMALLINT NOT NULL,
|
||||||
|
hospitality_rating SMALLINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (facility_id, user_id),
|
||||||
|
CHECK (quality_rating BETWEEN 1 AND 5),
|
||||||
|
CHECK (conditions_rating BETWEEN 1 AND 5),
|
||||||
|
CHECK (hospitality_rating BETWEEN 1 AND 5)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
await conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS facility_ratings_facility_idx
|
||||||
|
ON facility_ratings (facility_id, updated_at DESC)
|
||||||
|
""")
|
||||||
|
await conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS facility_ratings_user_idx
|
||||||
|
ON facility_ratings (user_id, updated_at DESC)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|
@ -2103,6 +2214,72 @@ async def public_logout(request: Request, response: Response):
|
||||||
)
|
)
|
||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/facilities/{slug}/ratings")
|
||||||
|
async def get_facility_ratings(request: Request, slug: str):
|
||||||
|
viewer = await get_authenticated_public_user(request)
|
||||||
|
|
||||||
|
async with app.state.pool.acquire() as conn:
|
||||||
|
facility = await get_published_facility_by_slug(conn, slug)
|
||||||
|
if not facility:
|
||||||
|
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet.")
|
||||||
|
|
||||||
|
payload = await build_facility_rating_payload(
|
||||||
|
conn,
|
||||||
|
int(facility["id"]),
|
||||||
|
int(viewer["id"]) if viewer else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"auth_configured": get_public_auth_config()["configured"],
|
||||||
|
"auth_providers": get_public_auth_config(),
|
||||||
|
"viewer": viewer,
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/facilities/{slug}/ratings")
|
||||||
|
async def upsert_facility_rating(request: Request, payload: FacilityRatingUpsertRequest, slug: str):
|
||||||
|
viewer = await require_authenticated_public_user(request)
|
||||||
|
|
||||||
|
async with app.state.pool.acquire() as conn:
|
||||||
|
facility = await get_published_facility_by_slug(conn, slug)
|
||||||
|
if not facility:
|
||||||
|
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet.")
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO facility_ratings (
|
||||||
|
facility_id, user_id, quality_rating, conditions_rating, hospitality_rating
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5
|
||||||
|
)
|
||||||
|
ON CONFLICT (facility_id, user_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
quality_rating = EXCLUDED.quality_rating,
|
||||||
|
conditions_rating = EXCLUDED.conditions_rating,
|
||||||
|
hospitality_rating = EXCLUDED.hospitality_rating,
|
||||||
|
updated_at = NOW()
|
||||||
|
""",
|
||||||
|
int(facility["id"]),
|
||||||
|
int(viewer["id"]),
|
||||||
|
payload.quality_rating,
|
||||||
|
payload.conditions_rating,
|
||||||
|
payload.hospitality_rating,
|
||||||
|
)
|
||||||
|
|
||||||
|
response_payload = await build_facility_rating_payload(
|
||||||
|
conn,
|
||||||
|
int(facility["id"]),
|
||||||
|
int(viewer["id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"detail": "Vurderingen er lagret.",
|
||||||
|
"viewer": viewer,
|
||||||
|
**response_payload,
|
||||||
|
}
|
||||||
|
|
||||||
# --- DATA ENDPOINTS ---
|
# --- DATA ENDPOINTS ---
|
||||||
|
|
||||||
@app.get("/api/facilities")
|
@app.get("/api/facilities")
|
||||||
|
|
@ -2117,6 +2294,39 @@ async def get_facilities():
|
||||||
ORDER BY is_main_course DESC, id ASC
|
ORDER BY is_main_course DESC, id ASC
|
||||||
) cs
|
) cs
|
||||||
) as course_statuses, (
|
) as course_statuses, (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM holes h
|
||||||
|
JOIN courses c ON c.id = h.course_id
|
||||||
|
WHERE c.facility_id = f.id
|
||||||
|
) as total_hole_count, (
|
||||||
|
SELECT jsonb_build_object(
|
||||||
|
'3', COUNT(*) FILTER (WHERE h.par = 3),
|
||||||
|
'4', COUNT(*) FILTER (WHERE h.par = 4),
|
||||||
|
'5', COUNT(*) FILTER (WHERE h.par = 5),
|
||||||
|
'6', COUNT(*) FILTER (WHERE h.par = 6)
|
||||||
|
)
|
||||||
|
FROM holes h
|
||||||
|
JOIN courses c ON c.id = h.course_id
|
||||||
|
WHERE c.facility_id = f.id
|
||||||
|
) as hole_par_counts, (
|
||||||
|
SELECT MIN((length_value.value)::int)
|
||||||
|
FROM holes h
|
||||||
|
JOIN courses c ON c.id = h.course_id
|
||||||
|
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
|
||||||
|
WHERE c.facility_id = f.id
|
||||||
|
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
|
||||||
|
AND length_value.value ~ '^[0-9]+$'
|
||||||
|
AND (length_value.value)::int BETWEEN 30 AND 900
|
||||||
|
) as shortest_hole_meters, (
|
||||||
|
SELECT MAX((length_value.value)::int)
|
||||||
|
FROM holes h
|
||||||
|
JOIN courses c ON c.id = h.course_id
|
||||||
|
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
|
||||||
|
WHERE c.facility_id = f.id
|
||||||
|
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
|
||||||
|
AND length_value.value ~ '^[0-9]+$'
|
||||||
|
AND (length_value.value)::int BETWEEN 30 AND 900
|
||||||
|
) as longest_hole_meters, (
|
||||||
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
|
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
|
||||||
SELECT
|
SELECT
|
||||||
forecast_date,
|
forecast_date,
|
||||||
|
|
@ -2299,26 +2509,41 @@ async def get_course_visit(slug: str):
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/articles")
|
@app.get("/api/articles")
|
||||||
async def get_articles(section: Optional[str] = Query(default="all")):
|
async def get_articles(
|
||||||
|
section: Optional[str] = Query(default="all"),
|
||||||
|
facility_slug: Optional[str] = Query(default=None),
|
||||||
|
limit: Optional[int] = Query(default=None, ge=1, le=12),
|
||||||
|
):
|
||||||
"""Henter publiserte artikler, valgfritt filtrert på seksjon."""
|
"""Henter publiserte artikler, valgfritt filtrert på seksjon."""
|
||||||
normalized_section = normalize_article_section(section, allow_all=True)
|
normalized_section = normalize_article_section(section, allow_all=True)
|
||||||
|
normalized_facility_slug = str(facility_slug or "").strip().lower() or None
|
||||||
|
|
||||||
query = """
|
clauses = ["status = 'published'"]
|
||||||
|
params: list[Any] = []
|
||||||
|
|
||||||
|
if normalized_section != "all":
|
||||||
|
params.append(normalized_section)
|
||||||
|
clauses.append(f"section = ${len(params)}")
|
||||||
|
|
||||||
|
if normalized_facility_slug:
|
||||||
|
params.append(normalized_facility_slug)
|
||||||
|
clauses.append(f"LOWER(COALESCE(facility_slug, '')) = ${len(params)}")
|
||||||
|
|
||||||
|
limit_clause = ""
|
||||||
|
if limit is not None:
|
||||||
|
params.append(int(limit))
|
||||||
|
limit_clause = f"LIMIT ${len(params)}"
|
||||||
|
|
||||||
|
query = f"""
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM articles
|
FROM articles
|
||||||
WHERE status = 'published'
|
WHERE {' AND '.join(clauses)}
|
||||||
{section_clause}
|
|
||||||
ORDER BY COALESCE(published_at, created_at) DESC, id DESC
|
ORDER BY COALESCE(published_at, created_at) DESC, id DESC
|
||||||
|
{limit_clause}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async with app.state.pool.acquire() as conn:
|
async with app.state.pool.acquire() as conn:
|
||||||
if normalized_section == "all":
|
rows = await conn.fetch(query, *params)
|
||||||
rows = await conn.fetch(query.format(section_clause=""))
|
|
||||||
else:
|
|
||||||
rows = await conn.fetch(
|
|
||||||
query.format(section_clause="AND section = $1"),
|
|
||||||
normalized_section,
|
|
||||||
)
|
|
||||||
return [format_article_row(row) for row in rows]
|
return [format_article_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,51 @@ function articleToForm(article: AdminArticle): ArticleFormState {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ScrollToTopButton() {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||||
|
className="fixed bottom-5 right-5 z-[80] rounded-2xl bg-[#11280f] px-5 py-3 text-xs font-black uppercase tracking-widest text-white shadow-[0_18px_45px_rgba(17,40,15,0.28)] transition-transform hover:-translate-y-0.5 md:bottom-8 md:right-8"
|
||||||
|
>
|
||||||
|
Til toppen
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArticlePayload(
|
||||||
|
form: ArticleFormState,
|
||||||
|
statusOverride?: ArticleFormState["status"],
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...form,
|
||||||
|
section: form.section,
|
||||||
|
slug: form.slug.trim(),
|
||||||
|
title: form.title.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
excerpt: form.excerpt.trim(),
|
||||||
|
eyebrow: form.eyebrow.trim(),
|
||||||
|
location_label: form.location_label.trim(),
|
||||||
|
facility_name: form.facility_name.trim(),
|
||||||
|
facility_slug: form.facility_slug.trim(),
|
||||||
|
author_name: form.author_name.trim(),
|
||||||
|
status: statusOverride || form.status,
|
||||||
|
content_html: form.content_html,
|
||||||
|
source_url: form.source_url.trim(),
|
||||||
|
source_label: form.source_label.trim(),
|
||||||
|
published_at: form.published_at ? new Date(form.published_at).toISOString() : null,
|
||||||
|
media_gallery: form.media_gallery.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
type: item.type,
|
||||||
|
src: item.src.trim(),
|
||||||
|
alt: item.alt.trim(),
|
||||||
|
caption: item.caption.trim(),
|
||||||
|
poster: (item.poster || "").trim(),
|
||||||
|
})),
|
||||||
|
featured_media_id: form.featured_media_id || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminArticlesPage() {
|
export default function AdminArticlesPage() {
|
||||||
const [articles, setArticles] = useState<AdminArticle[]>([]);
|
const [articles, setArticles] = useState<AdminArticle[]>([]);
|
||||||
const [facilities, setFacilities] = useState<FacilityOption[]>([]);
|
const [facilities, setFacilities] = useState<FacilityOption[]>([]);
|
||||||
|
|
@ -169,6 +214,7 @@ export default function AdminArticlesPage() {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [quickToggleArticleId, setQuickToggleArticleId] = useState<number | null>(null);
|
||||||
const [isUploadingHeroImages, setIsUploadingHeroImages] = useState(false);
|
const [isUploadingHeroImages, setIsUploadingHeroImages] = useState(false);
|
||||||
const [feedback, setFeedback] = useState<string>("");
|
const [feedback, setFeedback] = useState<string>("");
|
||||||
const [slugTouched, setSlugTouched] = useState(false);
|
const [slugTouched, setSlugTouched] = useState(false);
|
||||||
|
|
@ -411,33 +457,7 @@ export default function AdminArticlesPage() {
|
||||||
setFeedback("");
|
setFeedback("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = buildArticlePayload(form, statusOverride);
|
||||||
...form,
|
|
||||||
section: form.section,
|
|
||||||
slug: form.slug.trim(),
|
|
||||||
title: form.title.trim(),
|
|
||||||
description: form.description.trim(),
|
|
||||||
excerpt: form.excerpt.trim(),
|
|
||||||
eyebrow: form.eyebrow.trim(),
|
|
||||||
location_label: form.location_label.trim(),
|
|
||||||
facility_name: form.facility_name.trim(),
|
|
||||||
facility_slug: form.facility_slug.trim(),
|
|
||||||
author_name: form.author_name.trim(),
|
|
||||||
status: statusOverride || form.status,
|
|
||||||
content_html: form.content_html,
|
|
||||||
source_url: form.source_url.trim(),
|
|
||||||
source_label: form.source_label.trim(),
|
|
||||||
published_at: form.published_at ? new Date(form.published_at).toISOString() : null,
|
|
||||||
media_gallery: form.media_gallery.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
type: item.type,
|
|
||||||
src: item.src.trim(),
|
|
||||||
alt: item.alt.trim(),
|
|
||||||
caption: item.caption.trim(),
|
|
||||||
poster: (item.poster || "").trim(),
|
|
||||||
})),
|
|
||||||
featured_media_id: form.featured_media_id || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await adminFetch(`${API_URL}/admin/articles`, {
|
const response = await adminFetch(`${API_URL}/admin/articles`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -469,6 +489,45 @@ export default function AdminArticlesPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleQuickToggleStatus = async (article: AdminArticle) => {
|
||||||
|
setQuickToggleArticleId(article.id);
|
||||||
|
setFeedback("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextStatus: ArticleFormState["status"] =
|
||||||
|
article.status === "published" ? "draft" : "published";
|
||||||
|
const payload = buildArticlePayload(articleToForm(article), nextStatus);
|
||||||
|
|
||||||
|
const response = await adminFetch(`${API_URL}/admin/articles`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: "Kunne ikke endre status på artikkelen" }));
|
||||||
|
throw new Error(error.detail || "Kunne ikke endre status på artikkelen");
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedArticle = (await response.json()) as AdminArticle;
|
||||||
|
await loadArticles();
|
||||||
|
if (selectedArticleId === savedArticle.id) {
|
||||||
|
setForm(articleToForm(savedArticle));
|
||||||
|
}
|
||||||
|
setFeedback(
|
||||||
|
savedArticle.status === "published"
|
||||||
|
? `«${savedArticle.title}» er publisert.`
|
||||||
|
: `«${savedArticle.title}» er satt tilbake til utkast.`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback(error instanceof Error ? error.message : "Kunne ikke endre status på artikkelen.");
|
||||||
|
} finally {
|
||||||
|
setQuickToggleArticleId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!selectedArticleId) return;
|
if (!selectedArticleId) return;
|
||||||
const confirmed = window.confirm("Vil du slette denne artikkelen?");
|
const confirmed = window.confirm("Vil du slette denne artikkelen?");
|
||||||
|
|
@ -495,6 +554,7 @@ export default function AdminArticlesPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[#f3f6ee] px-4 py-8 sm:px-6 lg:px-8">
|
<main className="min-h-screen bg-[#f3f6ee] px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
<ScrollToTopButton />
|
||||||
<div className="mx-auto max-w-[1700px]">
|
<div className="mx-auto max-w-[1700px]">
|
||||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -552,28 +612,21 @@ export default function AdminArticlesPage() {
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{articles.map((article) => (
|
{articles.map((article) => (
|
||||||
<button
|
<div
|
||||||
key={article.id}
|
key={article.id}
|
||||||
type="button"
|
className={`rounded-[1.5rem] border px-4 py-4 transition ${
|
||||||
onClick={() => handleSelectArticle(article)}
|
|
||||||
className={`w-full rounded-[1.5rem] border px-4 py-4 text-left transition ${
|
|
||||||
selectedArticleId === article.id
|
selectedArticleId === article.id
|
||||||
? "border-[#FF5722] bg-[#FFF4EF] shadow-sm"
|
? "border-[#FF5722] bg-[#FFF4EF] shadow-sm"
|
||||||
: "border-[#112015]/8 bg-white hover:border-[#8BC34A]"
|
: "border-[#112015]/8 bg-white hover:border-[#8BC34A]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<p className="text-sm font-black text-[#112015]">{article.title}</p>
|
<button
|
||||||
<span
|
type="button"
|
||||||
className={`rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-[0.16em] ${
|
onClick={() => handleSelectArticle(article)}
|
||||||
article.status === "published"
|
className="min-w-0 flex-1 text-left"
|
||||||
? "bg-[#edf6e3] text-[#11280f]"
|
|
||||||
: "bg-[#f1f3f5] text-[#5d6a60]"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{article.status === "published" ? "Publisert" : "Utkast"}
|
<p className="text-sm font-black text-[#112015]">{article.title}</p>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-[11px] font-bold uppercase tracking-[0.14em] text-[#6A766C]">
|
<p className="mt-2 text-[11px] font-bold uppercase tracking-[0.14em] text-[#6A766C]">
|
||||||
/{article.slug}
|
/{article.slug}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -587,6 +640,25 @@ export default function AdminArticlesPage() {
|
||||||
Klikk for å redigere
|
Klikk for å redigere
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleQuickToggleStatus(article)}
|
||||||
|
disabled={quickToggleArticleId === article.id}
|
||||||
|
className={`shrink-0 rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-[0.16em] transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||||
|
article.status === "published"
|
||||||
|
? "bg-[#edf6e3] text-[#11280f] hover:bg-[#dceec8]"
|
||||||
|
: "bg-[#f1f3f5] text-[#5d6a60] hover:bg-[#e2e7ea]"
|
||||||
|
}`}
|
||||||
|
title="Klikk for å bytte status"
|
||||||
|
>
|
||||||
|
{quickToggleArticleId === article.id
|
||||||
|
? "Lagrer..."
|
||||||
|
: article.status === "published"
|
||||||
|
? "Publisert"
|
||||||
|
: "Utkast"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,12 @@ export type FacilityRecord = {
|
||||||
golfamore?: boolean | null;
|
golfamore?: boolean | null;
|
||||||
golfamore_url?: string | null;
|
golfamore_url?: string | null;
|
||||||
nsg_url?: string | null;
|
nsg_url?: string | null;
|
||||||
|
greenfee?: unknown;
|
||||||
|
standard_medlemskap?: number | null;
|
||||||
|
total_hole_count?: number | null;
|
||||||
|
hole_par_counts?: unknown;
|
||||||
|
shortest_hole_meters?: number | null;
|
||||||
|
longest_hole_meters?: number | null;
|
||||||
vtg_pris?: number | null;
|
vtg_pris?: number | null;
|
||||||
vtg_lenke?: string | null;
|
vtg_lenke?: string | null;
|
||||||
vtg_beskrivelse?: string | null;
|
vtg_beskrivelse?: string | null;
|
||||||
|
|
@ -66,6 +72,28 @@ export type PlaceConfig = {
|
||||||
intro: string;
|
intro: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlaceStats = {
|
||||||
|
facilityCount: number;
|
||||||
|
totalGolfHoles: number;
|
||||||
|
totalCourseCount: number;
|
||||||
|
openCourseCount: number;
|
||||||
|
openNowCount: number;
|
||||||
|
hole18Count: number;
|
||||||
|
hole9Count: number;
|
||||||
|
hole6Count: number;
|
||||||
|
hole27PlusCount: number;
|
||||||
|
par3HoleCount: number;
|
||||||
|
par4HoleCount: number;
|
||||||
|
par5HoleCount: number;
|
||||||
|
par6HoleCount: number;
|
||||||
|
shortestHoleMeters: number | null;
|
||||||
|
longestHoleMeters: number | null;
|
||||||
|
avgPrimetimeGreenfee: number | null;
|
||||||
|
avgStandardMembership: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SmallNumberGender = "common" | "neuter";
|
||||||
|
|
||||||
export const AREA_GROUPS: Record<string, string[]> = {
|
export const AREA_GROUPS: Record<string, string[]> = {
|
||||||
"nord-norge": ["finnmark", "troms", "nordland"],
|
"nord-norge": ["finnmark", "troms", "nordland"],
|
||||||
"midt-norge": ["trondelag", "nord-trondelag", "sor-trondelag"],
|
"midt-norge": ["trondelag", "nord-trondelag", "sor-trondelag"],
|
||||||
|
|
@ -111,6 +139,8 @@ export const HIERARCHICAL_AREA_OPTIONS = [
|
||||||
{ value: "county:viken", label: "Viken", slug: "viken" },
|
{ value: "county:viken", label: "Viken", slug: "viken" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const PREPOSITION_PA_LABELS = new Set(["Vestlandet", "Sørlandet", "Østlandet"]);
|
||||||
|
|
||||||
export const STATUS_ICON_PATHS: Record<string, string> = {
|
export const STATUS_ICON_PATHS: Record<string, string> = {
|
||||||
aapen: "/icons/open.png",
|
aapen: "/icons/open.png",
|
||||||
aapen_med_vintergreener: "/icons/open-winter.png",
|
aapen_med_vintergreener: "/icons/open-winter.png",
|
||||||
|
|
@ -342,3 +372,376 @@ export const getPlaceConfigFromSlug = (slug: string): PlaceConfig | null => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAvailablePlaceConfigs = () => HIERARCHICAL_AREA_OPTIONS.map(({ slug }) => slug);
|
export const getAvailablePlaceConfigs = () => HIERARCHICAL_AREA_OPTIONS.map(({ slug }) => slug);
|
||||||
|
|
||||||
|
export const getPlacePreposition = (label: string) => (PREPOSITION_PA_LABELS.has(label) ? "på" : "i");
|
||||||
|
|
||||||
|
const OPEN_NOW_STATUSES = new Set(["aapen", "aapen_med_vintergreener", "stenger_snart"]);
|
||||||
|
|
||||||
|
const GREENFEE_EXCLUSION_PATTERNS = [
|
||||||
|
"fjernmedlemmer",
|
||||||
|
"b medlem",
|
||||||
|
"b medlemmer",
|
||||||
|
"b-medlem",
|
||||||
|
"b-medlemmer",
|
||||||
|
"medlem uten",
|
||||||
|
"medlemmer uten",
|
||||||
|
"pluss medlem",
|
||||||
|
"gjest fra",
|
||||||
|
"gjest av ",
|
||||||
|
"gjest av medlem",
|
||||||
|
"gjest med medlem",
|
||||||
|
"medlems gjest",
|
||||||
|
"medlemmers gjester",
|
||||||
|
"nsg",
|
||||||
|
"seniorgolf",
|
||||||
|
"golfamore",
|
||||||
|
"golfa more",
|
||||||
|
"internasjonale",
|
||||||
|
"international",
|
||||||
|
"junior",
|
||||||
|
"student",
|
||||||
|
"golfbil",
|
||||||
|
"nybegynner",
|
||||||
|
"aksjonaer",
|
||||||
|
"aksjonær",
|
||||||
|
"minigolf",
|
||||||
|
"fotballgolf",
|
||||||
|
"frisbeegolf",
|
||||||
|
"korthullsbanegolf",
|
||||||
|
"aktivitetsparken",
|
||||||
|
"samarbeidsklubb",
|
||||||
|
"samarbeidende klubber",
|
||||||
|
"ukesgreenfee",
|
||||||
|
"ukes greenfee",
|
||||||
|
"ukegreenfee",
|
||||||
|
"uke greenfee",
|
||||||
|
"ukekort",
|
||||||
|
"pr uke",
|
||||||
|
"per uke",
|
||||||
|
"manedsgreenfee",
|
||||||
|
"månedsgreenfee",
|
||||||
|
"et dogn",
|
||||||
|
"et døgn",
|
||||||
|
"dogn",
|
||||||
|
"døgn",
|
||||||
|
"24 t",
|
||||||
|
"24t",
|
||||||
|
"arskort",
|
||||||
|
"sesongkort",
|
||||||
|
"sesonggreenfee",
|
||||||
|
"sesong greenfee",
|
||||||
|
"ferie greenfee",
|
||||||
|
"bulk kjop",
|
||||||
|
"bulk kjøp",
|
||||||
|
"kuponghefte",
|
||||||
|
"klippekort",
|
||||||
|
"helgekort",
|
||||||
|
"to runder",
|
||||||
|
"greenfeepakke",
|
||||||
|
"weekend pakke",
|
||||||
|
"midtuke pakke",
|
||||||
|
"ubegrenset golf",
|
||||||
|
"forhandsreservasjon",
|
||||||
|
"forhåndsreservasjon",
|
||||||
|
"naboklubber",
|
||||||
|
"klubber i innlandet",
|
||||||
|
"golf innlandet",
|
||||||
|
"familie",
|
||||||
|
"lojalitetskort",
|
||||||
|
"avtaleklubb",
|
||||||
|
"arsgreenfee",
|
||||||
|
"ars greenfee",
|
||||||
|
"årsgreenfee",
|
||||||
|
"års greenfee",
|
||||||
|
"greenfeemedlem",
|
||||||
|
"golden hour",
|
||||||
|
"spill mer",
|
||||||
|
"turnering",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SHORT_COURSE_PATTERNS = [
|
||||||
|
"korthull",
|
||||||
|
"par3golf",
|
||||||
|
"par 3",
|
||||||
|
"par3",
|
||||||
|
"pitch putt",
|
||||||
|
"pitch & putt",
|
||||||
|
"6 hull",
|
||||||
|
"6-hull",
|
||||||
|
"6 hulls",
|
||||||
|
"6-hulls",
|
||||||
|
];
|
||||||
|
const PARTIAL_ROUND_PATTERNS = ["9 hull", "9hull", "9-hull"];
|
||||||
|
const FULL_ROUND_PATTERNS = ["18 hull", "18-hull", "18 hol", "18 holes", "mer enn 9 hull"];
|
||||||
|
const DAY_GREENFEE_PATTERNS = [
|
||||||
|
"dagsgreenfee",
|
||||||
|
"dags greenfee",
|
||||||
|
"daggreenfee",
|
||||||
|
"dagsfee",
|
||||||
|
"pr dag",
|
||||||
|
"per dag",
|
||||||
|
"hele dagen",
|
||||||
|
];
|
||||||
|
const NON_PRIMARY_GREENFEE_PATTERNS = ["lavsesong", "kveldstid", "hver ekstra runde", "ekstra runde"];
|
||||||
|
|
||||||
|
const GENERIC_FACILITY_HINT_TOKENS = new Set([
|
||||||
|
"golf",
|
||||||
|
"golfklubb",
|
||||||
|
"klubb",
|
||||||
|
"golfpark",
|
||||||
|
"golfbane",
|
||||||
|
"golfbanen",
|
||||||
|
"golfbane",
|
||||||
|
"og",
|
||||||
|
"omegn",
|
||||||
|
"links",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const getHoleCategory = (value: unknown) => {
|
||||||
|
const normalized = normalizeText(value).replace(/\s+/g, "");
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
if (normalized === "18") return "18";
|
||||||
|
if (normalized === "9") return "9";
|
||||||
|
if (normalized === "6") return "6";
|
||||||
|
|
||||||
|
const matches = Array.from(normalized.matchAll(/\d+/g))
|
||||||
|
.map((match) => Number(match[0]))
|
||||||
|
.filter((number) => Number.isFinite(number));
|
||||||
|
|
||||||
|
if (matches.length === 0) return null;
|
||||||
|
|
||||||
|
const total = matches.reduce((sum, number) => sum + number, 0);
|
||||||
|
|
||||||
|
if (total >= 27) return "27+";
|
||||||
|
if (total === 18) return "18";
|
||||||
|
if (total === 9) return "9";
|
||||||
|
if (total === 6) return "6";
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPrimetimeGreenfee = (facility: FacilityRecord) => {
|
||||||
|
const rows = parseJson<Array<Record<string, unknown>>>(facility.greenfee, []);
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) return null;
|
||||||
|
|
||||||
|
const facilityShortCourseText = `${normalizeText(facility.slug)} ${normalizeText(facility.name)}`.trim();
|
||||||
|
if (SHORT_COURSE_PATTERNS.some((pattern) => facilityShortCourseText.includes(pattern))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const facilityHintTokens = Array.from(
|
||||||
|
new Set(
|
||||||
|
`${normalizeText(facility.slug)} ${normalizeText(facility.name)}`
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter((token) => token.length > 2 && !GENERIC_FACILITY_HINT_TOKENS.has(token))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const candidates = rows
|
||||||
|
.map((row) => {
|
||||||
|
const adultPrice = Number(row.pris_voksne);
|
||||||
|
if (!Number.isFinite(adultPrice) || adultPrice <= 0) return null;
|
||||||
|
|
||||||
|
const category = normalizeText(row.priskategori);
|
||||||
|
const courseName = normalizeText(row.banenavn);
|
||||||
|
const combinedText = `${category} ${courseName}`.trim();
|
||||||
|
|
||||||
|
const hasExcludedPattern = GREENFEE_EXCLUSION_PATTERNS.some((pattern) => combinedText.includes(pattern));
|
||||||
|
const isShortCourse = SHORT_COURSE_PATTERNS.some((pattern) => combinedText.includes(pattern));
|
||||||
|
const isPartialRound = PARTIAL_ROUND_PATTERNS.some((pattern) => combinedText.includes(pattern));
|
||||||
|
const isFullRound = FULL_ROUND_PATTERNS.some((pattern) => combinedText.includes(pattern));
|
||||||
|
const isDayGreenfee = DAY_GREENFEE_PATTERNS.some((pattern) => combinedText.includes(pattern));
|
||||||
|
const isNonPrimary = NON_PRIMARY_GREENFEE_PATTERNS.some((pattern) => combinedText.includes(pattern));
|
||||||
|
const isExplicitGuest =
|
||||||
|
category.includes("gjest") && !category.includes("gjest av ") && !category.includes("gjest fra");
|
||||||
|
const matchesFacilityHint =
|
||||||
|
facilityHintTokens.length > 0 && facilityHintTokens.some((token) => courseName.includes(token));
|
||||||
|
|
||||||
|
if (hasExcludedPattern || isShortCourse) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
price: adultPrice,
|
||||||
|
isDayGreenfee,
|
||||||
|
isNonPrimary,
|
||||||
|
isPartialRound,
|
||||||
|
isFullRound,
|
||||||
|
isExplicitGuest,
|
||||||
|
matchesFacilityHint,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(
|
||||||
|
value
|
||||||
|
): value is {
|
||||||
|
price: number;
|
||||||
|
isDayGreenfee: boolean;
|
||||||
|
isNonPrimary: boolean;
|
||||||
|
isPartialRound: boolean;
|
||||||
|
isFullRound: boolean;
|
||||||
|
isExplicitGuest: boolean;
|
||||||
|
matchesFacilityHint: boolean;
|
||||||
|
} => value !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
|
||||||
|
let selectionPool = candidates;
|
||||||
|
|
||||||
|
const facilityMatched = selectionPool.filter((candidate) => candidate.matchesFacilityHint);
|
||||||
|
if (facilityMatched.length > 0) {
|
||||||
|
selectionPool = facilityMatched;
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryCandidates = selectionPool.filter((candidate) => !candidate.isNonPrimary);
|
||||||
|
if (primaryCandidates.length > 0) {
|
||||||
|
selectionPool = primaryCandidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullRoundCandidates = selectionPool.filter((candidate) => candidate.isFullRound);
|
||||||
|
if (fullRoundCandidates.length > 0) {
|
||||||
|
selectionPool = fullRoundCandidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonDayCandidates = selectionPool.filter((candidate) => !candidate.isDayGreenfee);
|
||||||
|
if (nonDayCandidates.length > 0) {
|
||||||
|
selectionPool = nonDayCandidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonPartialCandidates = selectionPool.filter((candidate) => !candidate.isPartialRound);
|
||||||
|
if (nonPartialCandidates.length > 0) {
|
||||||
|
selectionPool = nonPartialCandidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bestPrice = Math.max(...selectionPool.map((candidate) => candidate.price));
|
||||||
|
const bestPriceCandidates = selectionPool.filter((candidate) => candidate.price === bestPrice);
|
||||||
|
const bestExplicitGuestCandidates = bestPriceCandidates.filter((candidate) => candidate.isExplicitGuest);
|
||||||
|
const amenities = parseJson<Record<string, unknown>>(facility.amenities, {});
|
||||||
|
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 =
|
||||||
|
bestExplicitGuestCandidates.length > 0 ? bestExplicitGuestCandidates[0] : bestPriceCandidates[0];
|
||||||
|
const selectedIsPartialRound = finalPriceCandidate?.isPartialRound === true;
|
||||||
|
const finalPrice = finalPriceCandidate?.price ?? bestPrice;
|
||||||
|
|
||||||
|
return selectedIsPartialRound && totalHoleCount >= 18 ? finalPrice * 2 : finalPrice;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatInteger = (value: number) => new Intl.NumberFormat("nb-NO").format(value);
|
||||||
|
|
||||||
|
const SMALL_NUMBER_WORDS: Record<SmallNumberGender, string[]> = {
|
||||||
|
common: ["ingen", "en", "to", "tre", "fire", "fem", "seks", "sju", "åtte", "ni"],
|
||||||
|
neuter: ["ingen", "ett", "to", "tre", "fire", "fem", "seks", "sju", "åtte", "ni"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatPlaceCount = (value: number, gender: SmallNumberGender = "common") => {
|
||||||
|
if (Number.isInteger(value) && value >= 0 && value < 10) {
|
||||||
|
return SMALL_NUMBER_WORDS[gender][value];
|
||||||
|
}
|
||||||
|
return formatInteger(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatPlaceCurrency = (value: number) => `${formatInteger(value)} kroner`;
|
||||||
|
|
||||||
|
export const buildPlaceAverageComparison = (localValue: number | null, nationalValue: number | null) => {
|
||||||
|
if (localValue === null || nationalValue === null) return null;
|
||||||
|
|
||||||
|
const delta = localValue - nationalValue;
|
||||||
|
if (delta === 0) {
|
||||||
|
return `På nivå med landssnittet (${formatPlaceCurrency(nationalValue)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return delta > 0
|
||||||
|
? `${formatPlaceCurrency(delta)} over landssnittet (${formatPlaceCurrency(nationalValue)})`
|
||||||
|
: `${formatPlaceCurrency(Math.abs(delta))} under landssnittet (${formatPlaceCurrency(nationalValue)})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildPlaceStats = (facilities: EnrichedFacility[]): PlaceStats => {
|
||||||
|
const relevantFacilities = facilities.filter((facility) => normalizeStatus(facility.primaryStatus) !== "nedlagt");
|
||||||
|
|
||||||
|
const greenfees = relevantFacilities
|
||||||
|
.map((facility) => getPrimetimeGreenfee(facility))
|
||||||
|
.filter((value): value is number => typeof value === "number");
|
||||||
|
|
||||||
|
const memberships = relevantFacilities
|
||||||
|
.map((facility) => facility.standard_medlemskap)
|
||||||
|
.filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0);
|
||||||
|
|
||||||
|
const shortestHoleCandidates = relevantFacilities
|
||||||
|
.map((facility) => facility.shortest_hole_meters)
|
||||||
|
.filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0);
|
||||||
|
|
||||||
|
const longestHoleCandidates = relevantFacilities
|
||||||
|
.map((facility) => facility.longest_hole_meters)
|
||||||
|
.filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0);
|
||||||
|
|
||||||
|
const holeParTotals = relevantFacilities.reduce(
|
||||||
|
(totals, facility) => {
|
||||||
|
const parCounts = parseJson<Record<string, unknown>>(facility.hole_par_counts, {});
|
||||||
|
return {
|
||||||
|
par3: totals.par3 + (Number(parCounts["3"]) || 0),
|
||||||
|
par4: totals.par4 + (Number(parCounts["4"]) || 0),
|
||||||
|
par5: totals.par5 + (Number(parCounts["5"]) || 0),
|
||||||
|
par6: totals.par6 + (Number(parCounts["6"]) || 0),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ par3: 0, par4: 0, par5: 0, par6: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const courseTotals = relevantFacilities.reduce(
|
||||||
|
(totals, facility) => {
|
||||||
|
const courseStatuses = parseJson<CourseStatus[]>(facility.course_statuses, []).filter(
|
||||||
|
(course) => course && (course.name || course.status)
|
||||||
|
);
|
||||||
|
const openCourses = courseStatuses.filter((course) =>
|
||||||
|
OPEN_NOW_STATUSES.has(normalizeStatus(course.status))
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: totals.total + courseStatuses.length,
|
||||||
|
open: totals.open + openCourses,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ total: 0, open: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
facilityCount: relevantFacilities.length,
|
||||||
|
totalGolfHoles: relevantFacilities.reduce((sum, facility) => sum + (Number(facility.total_hole_count) || 0), 0),
|
||||||
|
totalCourseCount: courseTotals.total,
|
||||||
|
openCourseCount: courseTotals.open,
|
||||||
|
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,
|
||||||
|
hole9Count: relevantFacilities.filter((facility) => getHoleCategory(parseJson<Record<string, unknown>>(facility.amenities, {}).antall_hull) === "9").length,
|
||||||
|
hole6Count: relevantFacilities.filter((facility) => getHoleCategory(parseJson<Record<string, unknown>>(facility.amenities, {}).antall_hull) === "6").length,
|
||||||
|
hole27PlusCount: relevantFacilities.filter((facility) => getHoleCategory(parseJson<Record<string, unknown>>(facility.amenities, {}).antall_hull) === "27+").length,
|
||||||
|
par3HoleCount: holeParTotals.par3,
|
||||||
|
par4HoleCount: holeParTotals.par4,
|
||||||
|
par5HoleCount: holeParTotals.par5,
|
||||||
|
par6HoleCount: holeParTotals.par6,
|
||||||
|
shortestHoleMeters: shortestHoleCandidates.length > 0 ? Math.min(...shortestHoleCandidates) : null,
|
||||||
|
longestHoleMeters: longestHoleCandidates.length > 0 ? Math.max(...longestHoleCandidates) : null,
|
||||||
|
avgPrimetimeGreenfee:
|
||||||
|
greenfees.length > 0 ? Math.round(greenfees.reduce((sum, value) => sum + value, 0) / greenfees.length) : null,
|
||||||
|
avgStandardMembership:
|
||||||
|
memberships.length > 0 ? Math.round(memberships.reduce((sum, value) => sum + value, 0) / memberships.length) : null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildPlaceStatsIntro = (label: string, stats: PlaceStats) => {
|
||||||
|
const preposition = getPlacePreposition(label);
|
||||||
|
return `Det finnes ${formatPlaceCount(stats.totalGolfHoles, "neuter")} golfhull ${preposition} ${label}, fordelt på ${formatPlaceCount(stats.facilityCount, "neuter")} golfanlegg med til sammen ${formatPlaceCount(stats.totalCourseCount)} baner. ${formatPlaceCount(stats.openCourseCount)} av banene er åpne nå.`;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants";
|
||||||
import { STATUS_ICON_PATHS, buildMapUrl, getPrimaryStatus, getPublicCourseDisplayName, parseJson as parseSharedJson, slugify } from "@/app/facilityData";
|
import { STATUS_ICON_PATHS, buildMapUrl, getPrimaryStatus, getPublicCourseDisplayName, parseJson as parseSharedJson, slugify } from "@/app/facilityData";
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import CourseDisplay from './CourseDisplay';
|
import CourseDisplay from './CourseDisplay';
|
||||||
|
import FacilityEditorialHub from './FacilityEditorialHub';
|
||||||
import FacilityFeedbackForm from './FacilityFeedbackForm';
|
import FacilityFeedbackForm from './FacilityFeedbackForm';
|
||||||
|
|
||||||
const FacilityDetailLeafletMap = dynamic(() => import("./FacilityDetailLeafletMap"), {
|
const FacilityDetailLeafletMap = dynamic(() => import("./FacilityDetailLeafletMap"), {
|
||||||
|
|
@ -175,7 +176,16 @@ const SOCIAL_ICONS: Record<string, React.ReactNode> = {
|
||||||
snapchat: <path d="M12 2C8.5 2 6 5 6 8.5c0 1.5.5 3 1.5 4-1 .5-2.5 1-3.5 1-.5 0-1 .5-1 1s.5 1 1.5 1h15c1 0 1.5-.5 1.5-1s-.5-1-1-1c-1 0-2.5-.5-3.5-1 1-1 1.5-2.5 1.5-4C18 5 15.5 2 12 2zm0 15c-3 0-5-1-5-1s.5 1.5 1.5 2h7C16.5 17.5 17 16 17 16s-2 1-5 1z" />
|
snapchat: <path d="M12 2C8.5 2 6 5 6 8.5c0 1.5.5 3 1.5 4-1 .5-2.5 1-3.5 1-.5 0-1 .5-1 1s.5 1 1.5 1h15c1 0 1.5-.5 1.5-1s-.5-1-1-1c-1 0-2.5-.5-3.5-1 1-1 1.5-2.5 1.5-4C18 5 15.5 2 12 2zm0 15c-3 0-5-1-5-1s.5 1.5 1.5 2h7C16.5 17.5 17 16 17 16s-2 1-5 1z" />
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FacilityDetailView({ facility }: { facility: any }) {
|
export default function FacilityDetailView({
|
||||||
|
facility,
|
||||||
|
relatedArticles = { banebesok: [], meninger: [] },
|
||||||
|
}: {
|
||||||
|
facility: any;
|
||||||
|
relatedArticles?: {
|
||||||
|
banebesok: any[];
|
||||||
|
meninger: any[];
|
||||||
|
};
|
||||||
|
}) {
|
||||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||||
const [currentSlide, setCurrentSlide] = useState(0);
|
const [currentSlide, setCurrentSlide] = useState(0);
|
||||||
|
|
||||||
|
|
@ -626,10 +636,18 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6 flex-grow">
|
<div className="space-y-6 flex-grow">
|
||||||
|
<div className="rounded-2xl border border-[#11280f]/10 bg-[#f7faf4] p-4 text-sm leading-6 text-[#31412f]">
|
||||||
|
<p>
|
||||||
|
<span className="font-black text-[#11280f]">Standardmedlemskap:</span> Hva vil det koste meg, en gjennomsnittsgolfer i alder og kjønn, å spille så mye jeg ønsker på denne banen?
|
||||||
|
</p>
|
||||||
|
<p className="mt-3">
|
||||||
|
<span className="font-black text-[#11280f]">Billigst mulig:</span> Hva vil det koste meg å være medlem her, dersom jeg aksepterer at jeg må betale greenfee hver runde? Medlemskapet skal også gi rett til greenfeespill på andre baner.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{facility.standard_medlemskap && (
|
{facility.standard_medlemskap && (
|
||||||
<div className="bg-gray-50 rounded-2xl p-5 border border-gray-100 relative overflow-hidden group hover:border-[#8bc34a]/30 transition-colors">
|
<div className="bg-gray-50 rounded-2xl p-5 border border-gray-100 relative overflow-hidden group hover:border-[#8bc34a]/30 transition-colors">
|
||||||
<div className="absolute top-0 right-0 bg-[#8bc34a] text-white text-[9px] font-black uppercase tracking-widest px-3 py-1 rounded-bl-xl">Mest valgte</div>
|
<span className="block text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Standardmedlemskap</span>
|
||||||
<span className="block text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Standard</span>
|
|
||||||
<span className="text-3xl font-black text-[#11280f]">{facility.standard_medlemskap},-</span>
|
<span className="text-3xl font-black text-[#11280f]">{facility.standard_medlemskap},-</span>
|
||||||
{facility.standard_medlemskap_navn && <p className="text-xs font-bold mt-1 text-[#8bc34a]">{facility.standard_medlemskap_navn}</p>}
|
{facility.standard_medlemskap_navn && <p className="text-xs font-bold mt-1 text-[#8bc34a]">{facility.standard_medlemskap_navn}</p>}
|
||||||
{facility.standard_medlemskap_kommentarer && (
|
{facility.standard_medlemskap_kommentarer && (
|
||||||
|
|
@ -644,7 +662,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
|
|
||||||
{facility.rimeligste_alternativ && (
|
{facility.rimeligste_alternativ && (
|
||||||
<div className="bg-white rounded-2xl p-5 border border-gray-100 hover:border-[#8bc34a]/30 transition-colors">
|
<div className="bg-white rounded-2xl p-5 border border-gray-100 hover:border-[#8bc34a]/30 transition-colors">
|
||||||
<span className="block text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Rimeligste golfkort</span>
|
<span className="block text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Billigst mulig</span>
|
||||||
<span className="text-xl font-black text-gray-600">{facility.rimeligste_alternativ},-</span>
|
<span className="text-xl font-black text-gray-600">{facility.rimeligste_alternativ},-</span>
|
||||||
{facility.rimeligste_navn && <p className="text-[10px] font-bold mt-1 text-gray-500">{facility.rimeligste_navn}</p>}
|
{facility.rimeligste_navn && <p className="text-[10px] font-bold mt-1 text-gray-500">{facility.rimeligste_navn}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -790,6 +808,12 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<FacilityEditorialHub
|
||||||
|
facilitySlug={String(facility.slug || "")}
|
||||||
|
facilityName={String(facility.name || "golfanlegget")}
|
||||||
|
relatedArticles={relatedArticles}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 9. SCOREKORT SEKSJON */}
|
{/* 9. SCOREKORT SEKSJON */}
|
||||||
<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>
|
||||||
|
|
|
||||||
613
frontend/src/app/golfbaner/[slug]/FacilityEditorialHub.tsx
Normal file
613
frontend/src/app/golfbaner/[slug]/FacilityEditorialHub.tsx
Normal file
|
|
@ -0,0 +1,613 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
type EditorialArticle = {
|
||||||
|
section: "banebesok" | "meninger";
|
||||||
|
slug: string;
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
publishedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FacilityEditorialHubProps = {
|
||||||
|
facilitySlug: string;
|
||||||
|
facilityName: string;
|
||||||
|
relatedArticles: {
|
||||||
|
banebesok: EditorialArticle[];
|
||||||
|
meninger: EditorialArticle[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type Viewer = {
|
||||||
|
id: number;
|
||||||
|
display_name?: string | null;
|
||||||
|
full_name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthProviders = {
|
||||||
|
configured: boolean;
|
||||||
|
google: boolean;
|
||||||
|
magic_link: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RatingSummary = {
|
||||||
|
rating_count: number;
|
||||||
|
quality_average: number | null;
|
||||||
|
conditions_average: number | null;
|
||||||
|
hospitality_average: number | null;
|
||||||
|
overall_average: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserRating = {
|
||||||
|
quality_rating: number;
|
||||||
|
conditions_rating: number;
|
||||||
|
hospitality_rating: number;
|
||||||
|
overall_rating: number;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RatingResponse = {
|
||||||
|
auth_configured: boolean;
|
||||||
|
auth_providers?: AuthProviders;
|
||||||
|
viewer: Viewer | null;
|
||||||
|
summary: RatingSummary;
|
||||||
|
user_rating: UserRating | null;
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (value?: string | null) => {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "";
|
||||||
|
return new Intl.DateTimeFormat("nb-NO", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAverage = (value?: number | null) => {
|
||||||
|
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat("nb-NO", {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clampRating = (value: number) => Math.max(1, Math.min(5, value));
|
||||||
|
|
||||||
|
function buildEditorialPath(section: "banebesok" | "meninger", slug: string) {
|
||||||
|
return `/${section}/${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTipHref(facilityName: string, kind: "banebesok" | "meninger") {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
topic: "Banebesøk / meninger / tips",
|
||||||
|
message:
|
||||||
|
kind === "banebesok"
|
||||||
|
? `Hei! Jeg vil tipse om et banebesøk knyttet til ${facilityName}.\n\nDette gjelder:\n`
|
||||||
|
: `Hei! Jeg vil sende inn et tips til en mening knyttet til ${facilityName}.\n\nDette gjelder:\n`,
|
||||||
|
});
|
||||||
|
return `/kontakt?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFacilityRatings(slug: string) {
|
||||||
|
const response = await fetch(`/api/facilities/${slug}/ratings`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Kunne ikke hente vurderinger.");
|
||||||
|
}
|
||||||
|
return (await response.json()) as RatingResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArticleColumn({
|
||||||
|
title,
|
||||||
|
articles,
|
||||||
|
facilityName,
|
||||||
|
kind,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
articles: EditorialArticle[];
|
||||||
|
facilityName: string;
|
||||||
|
kind: "banebesok" | "meninger";
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const ctaHref = buildTipHref(facilityName, kind);
|
||||||
|
const emptyCopy =
|
||||||
|
kind === "banebesok"
|
||||||
|
? `Vi har ikke publisert noe banebesøk fra ${facilityName} ennå.`
|
||||||
|
: `Ingen artikler om ${facilityName} er publisert ennå.`;
|
||||||
|
const emptyPrompt =
|
||||||
|
kind === "banebesok"
|
||||||
|
? "Har du spilt her nylig? Tips redaksjonen om et mulig banebesøk."
|
||||||
|
: "Har du en erfaring eller observasjon om anlegget? Tips redaksjonen om en artikkel.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="rounded-[2rem] border border-[#112015]/8 bg-white p-6 shadow-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">Fra TeeOff</p>
|
||||||
|
<h3 className="mt-3 text-2xl font-black text-[#112015]">{title}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{articles.length > 0 ? (
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
{articles.slice(0, compact ? 2 : 3).map((article) => (
|
||||||
|
<Link
|
||||||
|
key={`${article.section}-${article.slug}`}
|
||||||
|
href={buildEditorialPath(article.section, article.slug)}
|
||||||
|
className="block rounded-[1.4rem] border border-[#112015]/8 bg-[#FCFDF9] p-5 transition hover:-translate-y-0.5 hover:border-[#8BC34A]/45"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-[10px] font-black uppercase tracking-[0.16em] text-[#6A766C]">
|
||||||
|
<span>{article.eyebrow}</span>
|
||||||
|
{article.publishedAt ? <span>{formatDate(article.publishedAt)}</span> : null}
|
||||||
|
</div>
|
||||||
|
<h4 className="mt-3 text-lg font-black leading-tight text-[#112015]">{article.title}</h4>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-[#536256]">{article.excerpt}</p>
|
||||||
|
<p className="mt-4 text-[11px] font-black uppercase tracking-[0.16em] text-[#FF5722]">Les mer</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 rounded-[1.5rem] border border-dashed border-[#112015]/12 bg-[#F7F9F2] p-5">
|
||||||
|
<p className="text-base font-black text-[#112015]">
|
||||||
|
{kind === "banebesok"
|
||||||
|
? emptyCopy
|
||||||
|
: `Det er ennå ikke publisert noe om ${facilityName} her på teeoff.no.`}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-[#536256]">{emptyPrompt}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 border-t border-[#112015]/8 pt-5">
|
||||||
|
<p className="text-xs font-bold text-[#536256]">
|
||||||
|
{kind === "banebesok"
|
||||||
|
? "Har du et godt tips til banebesøk?"
|
||||||
|
: "Har du et relevant tips?"}
|
||||||
|
</p>
|
||||||
|
<Link href={ctaHref} className="btn btn-md btn-secondary">
|
||||||
|
{kind === "banebesok" ? "Tips om banebesøk" : "Tips TeeOff."}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RatingCategory({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onChange: (next: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[1.35rem] border border-[#112015]/8 bg-white p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-black text-[#112015]">{label}</p>
|
||||||
|
<span className="text-xs font-black uppercase tracking-[0.16em] text-[#6A766C]">
|
||||||
|
{value > 0 ? `${value}/5` : "Velg"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
{Array.from({ length: 5 }, (_, index) => {
|
||||||
|
const nextValue = index + 1;
|
||||||
|
const active = value >= nextValue;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${label}-${nextValue}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(nextValue)}
|
||||||
|
className={`flex h-10 w-10 items-center justify-center rounded-full border text-xl transition ${
|
||||||
|
active
|
||||||
|
? "border-[#FFB54D] bg-[#FFF3D9] text-[#FF8C00]"
|
||||||
|
: "border-[#112015]/10 bg-white text-[#CBD5C5] hover:border-[#8BC34A]/40 hover:text-[#8BC34A]"
|
||||||
|
}`}
|
||||||
|
aria-label={`${label}: ${nextValue} stjerner`}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FacilityRatingsCard({
|
||||||
|
facilitySlug,
|
||||||
|
facilityName,
|
||||||
|
}: {
|
||||||
|
facilitySlug: string;
|
||||||
|
facilityName: string;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [data, setData] = useState<RatingResponse>({
|
||||||
|
auth_configured: false,
|
||||||
|
auth_providers: {
|
||||||
|
configured: false,
|
||||||
|
google: false,
|
||||||
|
magic_link: false,
|
||||||
|
},
|
||||||
|
viewer: null,
|
||||||
|
summary: {
|
||||||
|
rating_count: 0,
|
||||||
|
quality_average: null,
|
||||||
|
conditions_average: null,
|
||||||
|
hospitality_average: null,
|
||||||
|
overall_average: null,
|
||||||
|
},
|
||||||
|
user_rating: null,
|
||||||
|
});
|
||||||
|
const [qualityRating, setQualityRating] = useState(0);
|
||||||
|
const [conditionsRating, setConditionsRating] = useState(0);
|
||||||
|
const [hospitalityRating, setHospitalityRating] = useState(0);
|
||||||
|
const [magicEmail, setMagicEmail] = useState("");
|
||||||
|
const [feedback, setFeedback] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isSendingMagicLink, setIsSendingMagicLink] = useState(false);
|
||||||
|
|
||||||
|
const authStatus = searchParams.get("comment_auth");
|
||||||
|
const returnToParams = new URLSearchParams(searchParams.toString());
|
||||||
|
returnToParams.delete("comment_auth");
|
||||||
|
const returnToQuery = returnToParams.toString();
|
||||||
|
const returnTo = `${pathname || "/"}${returnToQuery ? `?${returnToQuery}` : ""}`;
|
||||||
|
const googleLoginHref = `/api/public/auth/google/start?return_to=${encodeURIComponent(returnTo)}`;
|
||||||
|
|
||||||
|
const selectedOverall = useMemo(() => {
|
||||||
|
const values = [qualityRating, conditionsRating, hospitalityRating].filter((value) => value > 0);
|
||||||
|
if (values.length !== 3) return null;
|
||||||
|
return Number(((values[0] + values[1] + values[2]) / 3).toFixed(1));
|
||||||
|
}, [qualityRating, conditionsRating, hospitalityRating]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await fetchFacilityRatings(facilitySlug);
|
||||||
|
if (!cancelled) {
|
||||||
|
setData(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setFeedback(error instanceof Error ? error.message : "Kunne ikke hente vurderinger.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [facilitySlug]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data.user_rating) {
|
||||||
|
setQualityRating(0);
|
||||||
|
setConditionsRating(0);
|
||||||
|
setHospitalityRating(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setQualityRating(clampRating(data.user_rating.quality_rating));
|
||||||
|
setConditionsRating(clampRating(data.user_rating.conditions_rating));
|
||||||
|
setHospitalityRating(clampRating(data.user_rating.hospitality_rating));
|
||||||
|
}, [data.user_rating]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authStatus) return;
|
||||||
|
|
||||||
|
router.replace(returnTo || "/", { scroll: false });
|
||||||
|
|
||||||
|
if (authStatus === "google_success" || authStatus === "magic_success") {
|
||||||
|
setFeedback(authStatus === "google_success" ? "Du er logget inn med Google." : "Du er logget inn.");
|
||||||
|
void fetchFacilityRatings(facilitySlug)
|
||||||
|
.then((result) => setData(result))
|
||||||
|
.catch(() => setFeedback("Du er logget inn, men vurderingene kunne ikke hentes på nytt."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authStatus === "google_cancelled") {
|
||||||
|
setFeedback("Google-innlogging ble avbrutt.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (authStatus === "blocked") {
|
||||||
|
setFeedback("Denne brukeren er blokkert fra å vurdere anlegg.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (authStatus === "magic_expired") {
|
||||||
|
setFeedback("Innloggingslenken er utløpt. Be om en ny.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (authStatus === "magic_invalid") {
|
||||||
|
setFeedback("Innloggingslenken er ugyldig eller allerede brukt.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFeedback("Innloggingen feilet. Prøv igjen.");
|
||||||
|
}, [authStatus, facilitySlug, returnTo, router]);
|
||||||
|
|
||||||
|
const handleMagicLinkRequest = async () => {
|
||||||
|
setIsSendingMagicLink(true);
|
||||||
|
setFeedback("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/public/auth/magic-link/request", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: magicEmail,
|
||||||
|
return_to: returnTo,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(String(payload?.detail || "Kunne ikke sende innloggingslenke."));
|
||||||
|
}
|
||||||
|
|
||||||
|
setFeedback(String(payload?.detail || "Hvis adressen kan brukes til innlogging, sender vi deg en lenke."));
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback(error instanceof Error ? error.message : "Kunne ikke sende innloggingslenke.");
|
||||||
|
} finally {
|
||||||
|
setIsSendingMagicLink(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await fetch("/api/public/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
setFeedback("Du er logget ut.");
|
||||||
|
setData(await fetchFacilityRatings(facilitySlug));
|
||||||
|
} catch {
|
||||||
|
setFeedback("Du ble logget ut, men vurderingene kunne ikke hentes på nytt.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!data.viewer) {
|
||||||
|
setFeedback("Du må være innlogget for å vurdere anlegget.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!qualityRating || !conditionsRating || !hospitalityRating) {
|
||||||
|
setFeedback("Velg stjerner for alle tre kategorier.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setFeedback("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/facilities/${facilitySlug}/ratings`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
quality_rating: qualityRating,
|
||||||
|
conditions_rating: conditionsRating,
|
||||||
|
hospitality_rating: hospitalityRating,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(String(payload?.detail || "Kunne ikke lagre vurderingen."));
|
||||||
|
}
|
||||||
|
|
||||||
|
setFeedback(String(payload?.detail || "Vurderingen er lagret."));
|
||||||
|
setData((current) => ({
|
||||||
|
...current,
|
||||||
|
...payload,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback(error instanceof Error ? error.message : "Kunne ikke lagre vurderingen.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="rounded-[2rem] border border-[#112015]/8 bg-[#112015] p-6 text-white shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">Brukervurderinger</p>
|
||||||
|
<h3 className="mt-3 text-2xl font-black">Vurder anlegget</h3>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-white/70">
|
||||||
|
Innloggede brukere kan gi stjerner for kvalitet på anlegg, forhold og gjestfrihet hos {facilityName}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[1.4rem] bg-white/10 px-4 py-3 text-right">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-white/50">Totalscore</p>
|
||||||
|
<p className="mt-1 text-4xl font-black">{formatAverage(data.summary.overall_average)}</p>
|
||||||
|
<p className="mt-1 text-[11px] font-bold text-white/60">
|
||||||
|
{data.summary.rating_count > 0 ? `${data.summary.rating_count} vurderinger` : "Ingen vurderinger ennå"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-3 sm:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{ label: "Anlegg", value: data.summary.quality_average },
|
||||||
|
{ label: "Forhold", value: data.summary.conditions_average },
|
||||||
|
{ label: "Gjestfrihet", value: data.summary.hospitality_average },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.label} className="rounded-[1.35rem] border border-white/10 bg-white/5 p-4">
|
||||||
|
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-white/55">{item.label}</p>
|
||||||
|
<p className="mt-2 text-2xl font-black">{formatAverage(item.value)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="mt-6 rounded-[1.4rem] border border-white/10 bg-white/5 px-4 py-5 text-sm font-bold text-white/70">
|
||||||
|
Laster vurderinger...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoading && data.viewer ? (
|
||||||
|
<div className="mt-6 rounded-[1.6rem] border border-white/10 bg-[#F7F9F2] p-5 text-[#112015]">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Innlogget</p>
|
||||||
|
<p className="mt-2 text-lg font-black">{data.viewer.display_name || data.viewer.full_name || "Innlogget"}</p>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[#536256]">
|
||||||
|
Gi stjerner i alle tre kategorier. Systemet beregner totalscoren automatisk.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={handleLogout} className="btn btn-md btn-secondary">
|
||||||
|
Logg ut
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
<RatingCategory label="Kvalitet på anlegg" value={qualityRating} onChange={setQualityRating} />
|
||||||
|
<RatingCategory label="Forhold" value={conditionsRating} onChange={setConditionsRating} />
|
||||||
|
<RatingCategory label="Gjestfrihet" value={hospitalityRating} onChange={setHospitalityRating} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 rounded-[1.35rem] border border-[#112015]/8 bg-white p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Din totalscore</p>
|
||||||
|
<p className="mt-2 text-2xl font-black text-[#112015]">{selectedOverall !== null ? formatAverage(selectedOverall) : "--"}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || selectedOverall === null}
|
||||||
|
className="btn btn-md btn-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? "Lagrer..." : data.user_rating ? "Oppdater vurdering" : "Lagre vurdering"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoading && !data.viewer ? (
|
||||||
|
<div className="mt-6 rounded-[1.6rem] border border-white/10 bg-[#F7F9F2] p-5 text-[#112015]">
|
||||||
|
<p className="text-lg font-black">Logg inn for å vurdere anlegget</p>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-[#536256]">
|
||||||
|
Du kan gi stjerner for kvalitet på anlegg, forhold og gjestfrihet. Hver bruker kan oppdatere sin egen vurdering senere.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{data.auth_providers?.google || data.auth_providers?.magic_link ? (
|
||||||
|
<div className="mt-5 space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{data.auth_providers?.google ? (
|
||||||
|
<Link href={googleLoginHref} className="btn btn-md btn-primary">
|
||||||
|
Fortsett med Google
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
<p className="text-sm font-bold text-[#536256]">Innlogging kreves for vurderinger.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.auth_providers?.magic_link ? (
|
||||||
|
<div className="rounded-[1.4rem] border border-[#112015]/8 bg-white p-4">
|
||||||
|
<p className="text-[11px] font-black uppercase tracking-[0.16em] text-[#6A766C]">Eller via e-post</p>
|
||||||
|
<div className="mt-3 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={magicEmail}
|
||||||
|
onChange={(event) => setMagicEmail(event.target.value)}
|
||||||
|
placeholder="din@epost.no"
|
||||||
|
className="min-w-0 flex-1 rounded-full border border-[#112015]/10 bg-[#F7F9F2] px-4 py-3 text-sm text-[#112015] outline-none focus:border-[#8BC34A]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleMagicLinkRequest}
|
||||||
|
disabled={isSendingMagicLink}
|
||||||
|
className="btn btn-md btn-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSendingMagicLink ? "Sender..." : "Send innloggingslenke"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-5 rounded-[1.4rem] border border-[#112015]/8 bg-white p-4 text-sm font-bold text-[#536256]">
|
||||||
|
Innlogging for vurderinger er ikke tilgjengelig akkurat nå.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{feedback ? (
|
||||||
|
<div className="mt-5 rounded-[1.25rem] bg-white/10 px-4 py-3 text-sm font-bold text-white">
|
||||||
|
{feedback}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FacilityEditorialHub({
|
||||||
|
facilitySlug,
|
||||||
|
facilityName,
|
||||||
|
relatedArticles,
|
||||||
|
}: FacilityEditorialHubProps) {
|
||||||
|
return (
|
||||||
|
<section id="teeoff-innhold" className="pt-10 space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-black uppercase tracking-tighter text-[#112015] md:text-4xl">
|
||||||
|
Om {facilityName} på TeeOff
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 max-w-4xl text-sm leading-6 text-[#536256] md:text-base">
|
||||||
|
Her finner du redaksjonelle lenker til banebesøk og artikler om anlegget, samt brukervurderinger fra innloggede besøkende.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link href={buildTipHref(facilityName, "banebesok")} className="btn btn-md btn-secondary">
|
||||||
|
Tips om banebesøk
|
||||||
|
</Link>
|
||||||
|
<Link href={buildTipHref(facilityName, "meninger")} className="btn btn-md btn-secondary">
|
||||||
|
Tips om artikkel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<ArticleColumn
|
||||||
|
title="Banebesøk"
|
||||||
|
articles={relatedArticles.banebesok}
|
||||||
|
facilityName={facilityName}
|
||||||
|
kind="banebesok"
|
||||||
|
/>
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1fr_0.95fr]">
|
||||||
|
<ArticleColumn
|
||||||
|
title="Artikler"
|
||||||
|
articles={relatedArticles.meninger}
|
||||||
|
facilityName={facilityName}
|
||||||
|
kind="meninger"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
<FacilityRatingsCard facilitySlug={facilitySlug} facilityName={facilityName} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
createVtgCourseJsonLd,
|
createVtgCourseJsonLd,
|
||||||
trimDescription,
|
trimDescription,
|
||||||
} from "@/app/seo";
|
} from "@/app/seo";
|
||||||
|
import { getFacilityEditorialArticles } from "@/content/editorialArticles";
|
||||||
import FacilityDetailView from "./FacilityDetailView";
|
import FacilityDetailView from "./FacilityDetailView";
|
||||||
|
|
||||||
type GolfCoursePageProps = {
|
type GolfCoursePageProps = {
|
||||||
|
|
@ -97,6 +98,7 @@ export default async function GolfCoursePage({ params }: GolfCoursePageProps) {
|
||||||
|
|
||||||
const facilityJsonLd = createFacilityJsonLd(facility);
|
const facilityJsonLd = createFacilityJsonLd(facility);
|
||||||
const vtgCourseJsonLd = createVtgCourseJsonLd(facility);
|
const vtgCourseJsonLd = createVtgCourseJsonLd(facility);
|
||||||
|
const relatedArticles = await getFacilityEditorialArticles(facility.slug, 3);
|
||||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||||
{ name: "Hjem", path: "/" },
|
{ name: "Hjem", path: "/" },
|
||||||
{ name: "Golfbaner", path: "/golfbaner" },
|
{ name: "Golfbaner", path: "/golfbaner" },
|
||||||
|
|
@ -119,7 +121,7 @@ export default async function GolfCoursePage({ params }: GolfCoursePageProps) {
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||||
/>
|
/>
|
||||||
<FacilityDetailView facility={facility} />
|
<FacilityDetailView facility={facility} relatedArticles={relatedArticles} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,14 @@ export const metadata = createPageMetadata({
|
||||||
path: "/kontakt",
|
path: "/kontakt",
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function ContactPage() {
|
export default async function ContactPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams?: Promise<{ topic?: string; message?: string }>;
|
||||||
|
}) {
|
||||||
|
const params = (await searchParams) || {};
|
||||||
|
const initialTopic = typeof params.topic === "string" ? params.topic : undefined;
|
||||||
|
const initialMessage = typeof params.message === "string" ? params.message : undefined;
|
||||||
const collectionJsonLd = createCollectionPageJsonLd({
|
const collectionJsonLd = createCollectionPageJsonLd({
|
||||||
name: pageTitle,
|
name: pageTitle,
|
||||||
description: pageDescription,
|
description: pageDescription,
|
||||||
|
|
@ -102,7 +109,7 @@ export default function ContactPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ContactForm />
|
<ContactForm initialTopic={initialTopic} initialMessage={initialMessage} />
|
||||||
</div>
|
</div>
|
||||||
</InfoPageShell>
|
</InfoPageShell>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
type FacilityRecord,
|
type FacilityRecord,
|
||||||
enrichFacilities,
|
enrichFacilities,
|
||||||
filterFacilitiesByArea,
|
filterFacilitiesByArea,
|
||||||
|
getPlacePreposition,
|
||||||
} from "@/app/facilityData";
|
} from "@/app/facilityData";
|
||||||
|
|
||||||
type PlaceExplorerProps = {
|
type PlaceExplorerProps = {
|
||||||
|
|
@ -17,8 +18,6 @@ type PlaceExplorerProps = {
|
||||||
placeTitle: string;
|
placeTitle: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PREPOSITION_PA_LABELS = new Set(["Vestlandet", "Sørlandet", "Østlandet"]);
|
|
||||||
|
|
||||||
export default function PlaceExplorer({
|
export default function PlaceExplorer({
|
||||||
facilities,
|
facilities,
|
||||||
placeLabel,
|
placeLabel,
|
||||||
|
|
@ -35,7 +34,7 @@ export default function PlaceExplorer({
|
||||||
setFilteredFacilities(facilitiesInPlace);
|
setFilteredFacilities(facilitiesInPlace);
|
||||||
}, [facilitiesInPlace]);
|
}, [facilitiesInPlace]);
|
||||||
|
|
||||||
const preposition = PREPOSITION_PA_LABELS.has(placeLabel) ? "på" : "i";
|
const preposition = getPlacePreposition(placeLabel);
|
||||||
const filterHeading = `Filtrer golfbaner ${preposition} ${placeLabel}`;
|
const filterHeading = `Filtrer golfbaner ${preposition} ${placeLabel}`;
|
||||||
const filterIntro = `Filtrer golfbanene ${preposition} ${placeLabel} videre etter banestatus, antall hull og andre egenskaper.`;
|
const filterIntro = `Filtrer golfbanene ${preposition} ${placeLabel} videre etter banestatus, antall hull og andre egenskaper.`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,17 @@ import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import PlaceExplorer from "@/app/sted/[slug]/PlaceExplorer";
|
import PlaceExplorer from "@/app/sted/[slug]/PlaceExplorer";
|
||||||
import {
|
import {
|
||||||
|
buildPlaceAverageComparison,
|
||||||
|
buildPlaceStats,
|
||||||
|
buildPlaceStatsIntro,
|
||||||
|
formatPlaceCount,
|
||||||
|
formatPlaceCurrency,
|
||||||
type FacilityRecord,
|
type FacilityRecord,
|
||||||
enrichFacilities,
|
enrichFacilities,
|
||||||
filterFacilitiesByArea,
|
filterFacilitiesByArea,
|
||||||
getAvailablePlaceConfigs,
|
getAvailablePlaceConfigs,
|
||||||
getPlaceConfigFromSlug,
|
getPlaceConfigFromSlug,
|
||||||
|
getPlacePreposition,
|
||||||
} from "@/app/facilityData";
|
} from "@/app/facilityData";
|
||||||
import { API_URL } from "@/config/constants";
|
import { API_URL } from "@/config/constants";
|
||||||
import {
|
import {
|
||||||
|
|
@ -73,7 +79,33 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeData = Array.isArray(facilities) ? facilities : [];
|
const safeData = Array.isArray(facilities) ? facilities : [];
|
||||||
const facilitiesInPlace = filterFacilitiesByArea(enrichFacilities(safeData), place.areaFilter);
|
const enrichedFacilities = enrichFacilities(safeData);
|
||||||
|
const facilitiesInPlace = filterFacilitiesByArea(enrichedFacilities, place.areaFilter);
|
||||||
|
const placeStats = buildPlaceStats(facilitiesInPlace);
|
||||||
|
const nationalStats = buildPlaceStats(enrichedFacilities);
|
||||||
|
const placeStatsIntro = buildPlaceStatsIntro(place.label, placeStats);
|
||||||
|
const isNationalPlace = place.slug === "norge";
|
||||||
|
const placePreposition = isNationalPlace ? "i" : getPlacePreposition(place.label);
|
||||||
|
const greenfeeComparison = isNationalPlace
|
||||||
|
? null
|
||||||
|
: buildPlaceAverageComparison(placeStats.avgPrimetimeGreenfee, nationalStats.avgPrimetimeGreenfee);
|
||||||
|
const membershipComparison = isNationalPlace
|
||||||
|
? null
|
||||||
|
: buildPlaceAverageComparison(placeStats.avgStandardMembership, nationalStats.avgStandardMembership);
|
||||||
|
const holeDistributionParts = [
|
||||||
|
`${formatPlaceCount(placeStats.par3HoleCount)} par 3-hull`,
|
||||||
|
`${formatPlaceCount(placeStats.par4HoleCount)} par 4-hull`,
|
||||||
|
`${formatPlaceCount(placeStats.par5HoleCount)} par 5-hull`,
|
||||||
|
placeStats.par6HoleCount > 0 ? `${formatPlaceCount(placeStats.par6HoleCount)} par 6-hull` : null,
|
||||||
|
].filter((part): part is string => Boolean(part));
|
||||||
|
const holeDistributionText =
|
||||||
|
holeDistributionParts.length > 1
|
||||||
|
? `${holeDistributionParts.slice(0, -1).join(", ")} og ${holeDistributionParts.at(-1)}`
|
||||||
|
: holeDistributionParts[0] ?? null;
|
||||||
|
const shortestLongestText =
|
||||||
|
placeStats.shortestHoleMeters !== null && placeStats.longestHoleMeters !== null
|
||||||
|
? `Det korteste golfhullet ${placePreposition} ${place.label} er ${placeStats.shortestHoleMeters} meter, mens det lengste er ${placeStats.longestHoleMeters} meter.`
|
||||||
|
: null;
|
||||||
const collectionJsonLd = createCollectionPageJsonLd({
|
const collectionJsonLd = createCollectionPageJsonLd({
|
||||||
name: place.title,
|
name: place.title,
|
||||||
description: place.intro,
|
description: place.intro,
|
||||||
|
|
@ -118,14 +150,66 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
|
||||||
<p className="mt-4 max-w-3xl text-base leading-8 text-[#617063]">{place.intro}</p>
|
<p className="mt-4 max-w-3xl text-base leading-8 text-[#617063]">{place.intro}</p>
|
||||||
<div className="mt-5 flex flex-wrap gap-3">
|
<div className="mt-5 flex flex-wrap gap-3">
|
||||||
<span className="rounded-full bg-white px-4 py-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#112015] shadow-sm">
|
<span className="rounded-full bg-white px-4 py-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#112015] shadow-sm">
|
||||||
{facilitiesInPlace.length} golfbaner
|
{placeStats.facilityCount} golfanlegg
|
||||||
</span>
|
|
||||||
<span className="rounded-full bg-[#25312A] px-4 py-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-white">
|
|
||||||
Kart og liste i samme visning
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section className="mt-8 overflow-hidden rounded-[2rem] border border-[#D7DED0] bg-white shadow-sm">
|
||||||
|
<div className="px-5 py-6 sm:px-6 lg:px-8 lg:py-8">
|
||||||
|
<div className="max-w-5xl">
|
||||||
|
<p className="text-[11px] font-extrabold uppercase tracking-[0.28em] text-[#6FA786]">Nøkkeltall</p>
|
||||||
|
<h2 className="mt-2 text-3xl text-[#112015] sm:text-4xl">
|
||||||
|
{isNationalPlace ? "Fakta om golfanleggene i Norge" : `Fakta om golfanleggene ${placePreposition} ${place.label}`}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-base leading-8 text-[#213127]">{placeStatsIntro}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-px border-t border-[#D7DED0] bg-[#D7DED0] lg:grid-cols-3">
|
||||||
|
<div className="bg-white/88 px-5 py-5 sm:px-6 lg:px-8">
|
||||||
|
<p className="text-[11px] font-extrabold uppercase tracking-[0.2em] text-[#6FA786]">Greenfee</p>
|
||||||
|
<p className="mt-2 text-2xl font-black text-[#112015]">
|
||||||
|
{placeStats.avgPrimetimeGreenfee !== null ? formatPlaceCurrency(placeStats.avgPrimetimeGreenfee) : "Mangler grunnlag"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[#4F6052]">
|
||||||
|
{placeStats.avgPrimetimeGreenfee !== null
|
||||||
|
? "Gjennomsnittlig primetime-pris i høysesong."
|
||||||
|
: "Ingen tilstrekkelige greenfee-data tilgjengelig."}
|
||||||
|
</p>
|
||||||
|
{greenfeeComparison ? <p className="mt-2 text-sm font-bold text-[#112015]">{greenfeeComparison}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/88 px-5 py-5 sm:px-6 lg:px-8">
|
||||||
|
<p className="text-[11px] font-extrabold uppercase tracking-[0.2em] text-[#6FA786]">Medlemskap</p>
|
||||||
|
<p className="mt-2 text-2xl font-black text-[#112015]">
|
||||||
|
{placeStats.avgStandardMembership !== null ? formatPlaceCurrency(placeStats.avgStandardMembership) : "Mangler grunnlag"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[#4F6052]">
|
||||||
|
{placeStats.avgStandardMembership !== null
|
||||||
|
? "Gjennomsnittlig standard medlemskap med spillerett."
|
||||||
|
: "Ingen tilstrekkelige medlemskapsdata tilgjengelig."}
|
||||||
|
</p>
|
||||||
|
{membershipComparison ? <p className="mt-2 text-sm font-bold text-[#112015]">{membershipComparison}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/88 px-5 py-5 sm:px-6 lg:px-8">
|
||||||
|
<p className="text-[11px] font-extrabold uppercase tracking-[0.2em] text-[#6FA786]">Hullstatistikk</p>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-[#112015]">
|
||||||
|
{formatPlaceCount(placeStats.hole18Count, "neuter")} 18-hullsanlegg
|
||||||
|
<br />
|
||||||
|
{formatPlaceCount(placeStats.hole9Count, "neuter")} 9-hullsanlegg
|
||||||
|
<br />
|
||||||
|
{formatPlaceCount(placeStats.hole6Count, "neuter")} 6-hullsanlegg
|
||||||
|
<br />
|
||||||
|
{formatPlaceCount(placeStats.hole27PlusCount, "neuter")} anlegg med 27 hull eller mer
|
||||||
|
</p>
|
||||||
|
{holeDistributionText ? <p className="mt-3 text-sm leading-6 text-[#4F6052]">{holeDistributionText}.</p> : null}
|
||||||
|
{shortestLongestText ? <p className="mt-2 text-sm leading-6 text-[#4F6052]">{shortestLongestText}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<PlaceExplorer
|
<PlaceExplorer
|
||||||
facilities={safeData}
|
facilities={safeData}
|
||||||
placeLabel={place.label}
|
placeLabel={place.label}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
type SubmitState = "idle" | "success" | "error";
|
type SubmitState = "idle" | "success" | "error";
|
||||||
|
|
||||||
|
|
@ -11,7 +11,13 @@ const topicOptions = [
|
||||||
"Annet",
|
"Annet",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ContactForm() {
|
export default function ContactForm({
|
||||||
|
initialTopic,
|
||||||
|
initialMessage,
|
||||||
|
}: {
|
||||||
|
initialTopic?: string;
|
||||||
|
initialMessage?: string;
|
||||||
|
}) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [topic, setTopic] = useState(topicOptions[0]);
|
const [topic, setTopic] = useState(topicOptions[0]);
|
||||||
|
|
@ -22,6 +28,18 @@ export default function ContactForm() {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [startedAt] = useState(() => Math.floor(Date.now() / 1000));
|
const [startedAt] = useState(() => Math.floor(Date.now() / 1000));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const presetTopic = String(initialTopic || "").trim();
|
||||||
|
const presetMessage = String(initialMessage || "").trim();
|
||||||
|
|
||||||
|
if (presetTopic && topicOptions.includes(presetTopic)) {
|
||||||
|
setTopic(presetTopic);
|
||||||
|
}
|
||||||
|
if (presetMessage) {
|
||||||
|
setMessage(presetMessage);
|
||||||
|
}
|
||||||
|
}, [initialMessage, initialTopic]);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setName("");
|
setName("");
|
||||||
setEmail("");
|
setEmail("");
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,23 @@ async function fetchPublishedArticles(section: ArticleSection) {
|
||||||
return data.map((entry) => mapApiArticle(entry as ArticleApiRecord));
|
return data.map((entry) => mapApiArticle(entry as ArticleApiRecord));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchPublishedFacilityArticles(section: ArticleSection, facilitySlug: string, limit = 3) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
section,
|
||||||
|
facility_slug: facilitySlug,
|
||||||
|
limit: String(limit),
|
||||||
|
});
|
||||||
|
const response = await fetch(`${API_URL}/articles?${params.toString()}`, { cache: "no-store" });
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data.map((entry) => mapApiArticle(entry as ArticleApiRecord));
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchPublishedArticleBySlug(slug: string, section: ArticleSection) {
|
async function fetchPublishedArticleBySlug(slug: string, section: ArticleSection) {
|
||||||
const response = await fetch(`${API_URL}/articles/${slug}?section=${section}`, { cache: "no-store" });
|
const response = await fetch(`${API_URL}/articles/${slug}?section=${section}`, { cache: "no-store" });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -673,3 +690,30 @@ export async function getOpinionArticles() {
|
||||||
export async function getOpinionArticleBySlug(slug: string, options?: { preview?: boolean }) {
|
export async function getOpinionArticleBySlug(slug: string, options?: { preview?: boolean }) {
|
||||||
return getEditorialArticleBySlug(slug, "meninger", options);
|
return getEditorialArticleBySlug(slug, "meninger", options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getFacilityEditorialArticles(facilitySlug: string, limit = 3) {
|
||||||
|
const normalizedFacilitySlug = String(facilitySlug || "").trim();
|
||||||
|
if (!normalizedFacilitySlug) {
|
||||||
|
return {
|
||||||
|
banebesok: [] as EditorialArticle[],
|
||||||
|
meninger: [] as EditorialArticle[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [banebesok, meninger] = await Promise.all([
|
||||||
|
fetchPublishedFacilityArticles("banebesok", normalizedFacilitySlug, limit),
|
||||||
|
fetchPublishedFacilityArticles("meninger", normalizedFacilitySlug, limit),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
banebesok: banebesok || [],
|
||||||
|
meninger: meninger || [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
banebesok: [] as EditorialArticle[],
|
||||||
|
meninger: [] as EditorialArticle[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue