Stedsideinfo er klart. Dette er før forsøk på å speede opp stedsidene

This commit is contained in:
Erol Haagenrud 2026-04-21 16:37:41 +02:00
parent db478c849e
commit 356a8642b9
11 changed files with 1575 additions and 84 deletions

View file

@ -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]

View file

@ -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>

View file

@ -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å.`;
};

View file

@ -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 mye jeg ønsker 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 betale greenfee hver runde? Medlemskapet skal også gi rett til greenfeespill 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>

View 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 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 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 .
</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} 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>
);
}

View file

@ -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} />
</>
);
}

View file

@ -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>
</>

View file

@ -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.`;

View file

@ -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}

View file

@ -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("");

View file

@ -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[],
};
}
}