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 httpx
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Any
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
|
|
@ -633,6 +633,12 @@ class PublicMagicLinkRequest(BaseModel):
|
|||
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):
|
||||
name: str
|
||||
email: str
|
||||
|
|
@ -677,7 +683,7 @@ def format_row(row):
|
|||
]
|
||||
json_dict_fields = [
|
||||
'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:
|
||||
|
|
@ -1268,6 +1274,87 @@ async def require_authenticated_public_user(request: Request) -> dict[str, Any]:
|
|||
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):
|
||||
if section:
|
||||
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
|
||||
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
|
||||
|
|
@ -2103,6 +2214,72 @@ async def public_logout(request: Request, response: Response):
|
|||
)
|
||||
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 ---
|
||||
|
||||
@app.get("/api/facilities")
|
||||
|
|
@ -2117,6 +2294,39 @@ async def get_facilities():
|
|||
ORDER BY is_main_course DESC, id ASC
|
||||
) cs
|
||||
) as course_statuses, (
|
||||
SELECT COUNT(*)
|
||||
FROM holes h
|
||||
JOIN courses c ON c.id = h.course_id
|
||||
WHERE c.facility_id = f.id
|
||||
) as total_hole_count, (
|
||||
SELECT jsonb_build_object(
|
||||
'3', COUNT(*) FILTER (WHERE h.par = 3),
|
||||
'4', COUNT(*) FILTER (WHERE h.par = 4),
|
||||
'5', COUNT(*) FILTER (WHERE h.par = 5),
|
||||
'6', COUNT(*) FILTER (WHERE h.par = 6)
|
||||
)
|
||||
FROM holes h
|
||||
JOIN courses c ON c.id = h.course_id
|
||||
WHERE c.facility_id = f.id
|
||||
) as hole_par_counts, (
|
||||
SELECT MIN((length_value.value)::int)
|
||||
FROM holes h
|
||||
JOIN courses c ON c.id = h.course_id
|
||||
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
|
||||
WHERE c.facility_id = f.id
|
||||
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
|
||||
AND length_value.value ~ '^[0-9]+$'
|
||||
AND (length_value.value)::int BETWEEN 30 AND 900
|
||||
) as shortest_hole_meters, (
|
||||
SELECT MAX((length_value.value)::int)
|
||||
FROM holes h
|
||||
JOIN courses c ON c.id = h.course_id
|
||||
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
|
||||
WHERE c.facility_id = f.id
|
||||
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
|
||||
AND length_value.value ~ '^[0-9]+$'
|
||||
AND (length_value.value)::int BETWEEN 30 AND 900
|
||||
) as longest_hole_meters, (
|
||||
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
|
||||
SELECT
|
||||
forecast_date,
|
||||
|
|
@ -2299,26 +2509,41 @@ async def get_course_visit(slug: str):
|
|||
|
||||
|
||||
@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."""
|
||||
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 *
|
||||
FROM articles
|
||||
WHERE status = 'published'
|
||||
{section_clause}
|
||||
WHERE {' AND '.join(clauses)}
|
||||
ORDER BY COALESCE(published_at, created_at) DESC, id DESC
|
||||
{limit_clause}
|
||||
"""
|
||||
|
||||
async with app.state.pool.acquire() as conn:
|
||||
if normalized_section == "all":
|
||||
rows = await conn.fetch(query.format(section_clause=""))
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
query.format(section_clause="AND section = $1"),
|
||||
normalized_section,
|
||||
)
|
||||
rows = await conn.fetch(query, *params)
|
||||
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() {
|
||||
const [articles, setArticles] = useState<AdminArticle[]>([]);
|
||||
const [facilities, setFacilities] = useState<FacilityOption[]>([]);
|
||||
|
|
@ -169,6 +214,7 @@ export default function AdminArticlesPage() {
|
|||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [quickToggleArticleId, setQuickToggleArticleId] = useState<number | null>(null);
|
||||
const [isUploadingHeroImages, setIsUploadingHeroImages] = useState(false);
|
||||
const [feedback, setFeedback] = useState<string>("");
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
|
|
@ -411,33 +457,7 @@ export default function AdminArticlesPage() {
|
|||
setFeedback("");
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
...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 payload = buildArticlePayload(form, statusOverride);
|
||||
|
||||
const response = await adminFetch(`${API_URL}/admin/articles`, {
|
||||
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 () => {
|
||||
if (!selectedArticleId) return;
|
||||
const confirmed = window.confirm("Vil du slette denne artikkelen?");
|
||||
|
|
@ -495,6 +554,7 @@ export default function AdminArticlesPage() {
|
|||
|
||||
return (
|
||||
<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="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
|
|
@ -552,41 +612,53 @@ export default function AdminArticlesPage() {
|
|||
) : null}
|
||||
|
||||
{articles.map((article) => (
|
||||
<button
|
||||
<div
|
||||
key={article.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectArticle(article)}
|
||||
className={`w-full rounded-[1.5rem] border px-4 py-4 text-left transition ${
|
||||
className={`rounded-[1.5rem] border px-4 py-4 transition ${
|
||||
selectedArticleId === article.id
|
||||
? "border-[#FF5722] bg-[#FFF4EF] shadow-sm"
|
||||
: "border-[#112015]/8 bg-white hover:border-[#8BC34A]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-black text-[#112015]">{article.title}</p>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-[0.16em] ${
|
||||
article.status === "published"
|
||||
? "bg-[#edf6e3] text-[#11280f]"
|
||||
: "bg-[#f1f3f5] text-[#5d6a60]"
|
||||
}`}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectArticle(article)}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
{article.status === "published" ? "Publisert" : "Utkast"}
|
||||
</span>
|
||||
<p className="text-sm font-black text-[#112015]">{article.title}</p>
|
||||
<p className="mt-2 text-[11px] font-bold uppercase tracking-[0.14em] text-[#6A766C]">
|
||||
/{article.slug}
|
||||
</p>
|
||||
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
|
||||
{article.section === "meninger" ? "Meninger" : "Banebesøk"}
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-6 text-[#536256]">
|
||||
{article.facility_name || "Uten koblet bane"}
|
||||
</p>
|
||||
<p className="mt-3 text-xs font-black uppercase tracking-[0.16em] text-[#FF5722]">
|
||||
Klikk for å redigere
|
||||
</p>
|
||||
</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>
|
||||
<p className="mt-2 text-[11px] font-bold uppercase tracking-[0.14em] text-[#6A766C]">
|
||||
/{article.slug}
|
||||
</p>
|
||||
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
|
||||
{article.section === "meninger" ? "Meninger" : "Banebesøk"}
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-6 text-[#536256]">
|
||||
{article.facility_name || "Uten koblet bane"}
|
||||
</p>
|
||||
<p className="mt-3 text-xs font-black uppercase tracking-[0.16em] text-[#FF5722]">
|
||||
Klikk for å redigere
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ export type FacilityRecord = {
|
|||
golfamore?: boolean | null;
|
||||
golfamore_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_lenke?: string | null;
|
||||
vtg_beskrivelse?: string | null;
|
||||
|
|
@ -66,6 +72,28 @@ export type PlaceConfig = {
|
|||
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[]> = {
|
||||
"nord-norge": ["finnmark", "troms", "nordland"],
|
||||
"midt-norge": ["trondelag", "nord-trondelag", "sor-trondelag"],
|
||||
|
|
@ -111,6 +139,8 @@ export const HIERARCHICAL_AREA_OPTIONS = [
|
|||
{ 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> = {
|
||||
aapen: "/icons/open.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 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 Link from 'next/link';
|
||||
import CourseDisplay from './CourseDisplay';
|
||||
import FacilityEditorialHub from './FacilityEditorialHub';
|
||||
import FacilityFeedbackForm from './FacilityFeedbackForm';
|
||||
|
||||
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" />
|
||||
};
|
||||
|
||||
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 [currentSlide, setCurrentSlide] = useState(0);
|
||||
|
||||
|
|
@ -626,10 +636,18 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
</div>
|
||||
|
||||
<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 && (
|
||||
<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">Standard</span>
|
||||
<span className="block text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Standardmedlemskap</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_kommentarer && (
|
||||
|
|
@ -644,7 +662,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
|
||||
{facility.rimeligste_alternativ && (
|
||||
<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>
|
||||
{facility.rimeligste_navn && <p className="text-[10px] font-bold mt-1 text-gray-500">{facility.rimeligste_navn}</p>}
|
||||
</div>
|
||||
|
|
@ -790,6 +808,12 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
</section>
|
||||
)}
|
||||
|
||||
<FacilityEditorialHub
|
||||
facilitySlug={String(facility.slug || "")}
|
||||
facilityName={String(facility.name || "golfanlegget")}
|
||||
relatedArticles={relatedArticles}
|
||||
/>
|
||||
|
||||
{/* 9. SCOREKORT SEKSJON */}
|
||||
<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>
|
||||
|
|
|
|||
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,
|
||||
trimDescription,
|
||||
} from "@/app/seo";
|
||||
import { getFacilityEditorialArticles } from "@/content/editorialArticles";
|
||||
import FacilityDetailView from "./FacilityDetailView";
|
||||
|
||||
type GolfCoursePageProps = {
|
||||
|
|
@ -97,6 +98,7 @@ export default async function GolfCoursePage({ params }: GolfCoursePageProps) {
|
|||
|
||||
const facilityJsonLd = createFacilityJsonLd(facility);
|
||||
const vtgCourseJsonLd = createVtgCourseJsonLd(facility);
|
||||
const relatedArticles = await getFacilityEditorialArticles(facility.slug, 3);
|
||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||
{ name: "Hjem", path: "/" },
|
||||
{ name: "Golfbaner", path: "/golfbaner" },
|
||||
|
|
@ -119,7 +121,7 @@ export default async function GolfCoursePage({ params }: GolfCoursePageProps) {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<FacilityDetailView facility={facility} />
|
||||
<FacilityDetailView facility={facility} relatedArticles={relatedArticles} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,14 @@ export const metadata = createPageMetadata({
|
|||
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({
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
|
|
@ -102,7 +109,7 @@ export default function ContactPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ContactForm />
|
||||
<ContactForm initialTopic={initialTopic} initialMessage={initialMessage} />
|
||||
</div>
|
||||
</InfoPageShell>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type FacilityRecord,
|
||||
enrichFacilities,
|
||||
filterFacilitiesByArea,
|
||||
getPlacePreposition,
|
||||
} from "@/app/facilityData";
|
||||
|
||||
type PlaceExplorerProps = {
|
||||
|
|
@ -17,8 +18,6 @@ type PlaceExplorerProps = {
|
|||
placeTitle: string;
|
||||
};
|
||||
|
||||
const PREPOSITION_PA_LABELS = new Set(["Vestlandet", "Sørlandet", "Østlandet"]);
|
||||
|
||||
export default function PlaceExplorer({
|
||||
facilities,
|
||||
placeLabel,
|
||||
|
|
@ -35,7 +34,7 @@ export default function PlaceExplorer({
|
|||
setFilteredFacilities(facilitiesInPlace);
|
||||
}, [facilitiesInPlace]);
|
||||
|
||||
const preposition = PREPOSITION_PA_LABELS.has(placeLabel) ? "på" : "i";
|
||||
const preposition = getPlacePreposition(placeLabel);
|
||||
const filterHeading = `Filtrer golfbaner ${preposition} ${placeLabel}`;
|
||||
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 PlaceExplorer from "@/app/sted/[slug]/PlaceExplorer";
|
||||
import {
|
||||
buildPlaceAverageComparison,
|
||||
buildPlaceStats,
|
||||
buildPlaceStatsIntro,
|
||||
formatPlaceCount,
|
||||
formatPlaceCurrency,
|
||||
type FacilityRecord,
|
||||
enrichFacilities,
|
||||
filterFacilitiesByArea,
|
||||
getAvailablePlaceConfigs,
|
||||
getPlaceConfigFromSlug,
|
||||
getPlacePreposition,
|
||||
} from "@/app/facilityData";
|
||||
import { API_URL } from "@/config/constants";
|
||||
import {
|
||||
|
|
@ -73,7 +79,33 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
|
|||
}
|
||||
|
||||
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({
|
||||
name: place.title,
|
||||
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>
|
||||
<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">
|
||||
{facilitiesInPlace.length} golfbaner
|
||||
</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
|
||||
{placeStats.facilityCount} golfanlegg
|
||||
</span>
|
||||
</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
|
||||
facilities={safeData}
|
||||
placeLabel={place.label}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type SubmitState = "idle" | "success" | "error";
|
||||
|
||||
|
|
@ -11,7 +11,13 @@ const topicOptions = [
|
|||
"Annet",
|
||||
];
|
||||
|
||||
export default function ContactForm() {
|
||||
export default function ContactForm({
|
||||
initialTopic,
|
||||
initialMessage,
|
||||
}: {
|
||||
initialTopic?: string;
|
||||
initialMessage?: string;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [topic, setTopic] = useState(topicOptions[0]);
|
||||
|
|
@ -22,6 +28,18 @@ export default function ContactForm() {
|
|||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
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 = () => {
|
||||
setName("");
|
||||
setEmail("");
|
||||
|
|
|
|||
|
|
@ -584,6 +584,23 @@ async function fetchPublishedArticles(section: ArticleSection) {
|
|||
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) {
|
||||
const response = await fetch(`${API_URL}/articles/${slug}?section=${section}`, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
|
|
@ -673,3 +690,30 @@ export async function getOpinionArticles() {
|
|||
export async function getOpinionArticleBySlug(slug: string, options?: { preview?: boolean }) {
|
||||
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