diff --git a/backend/main.py b/backend/main.py index fa5e6a1..227f35c 100644 --- a/backend/main.py +++ b/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] diff --git a/frontend/src/app/admin/artikler/page.tsx b/frontend/src/app/admin/artikler/page.tsx index 3aaa6b6..02db5c8 100644 --- a/frontend/src/app/admin/artikler/page.tsx +++ b/frontend/src/app/admin/artikler/page.tsx @@ -161,6 +161,51 @@ function articleToForm(article: AdminArticle): ArticleFormState { }; } +function ScrollToTopButton() { + return ( + + ); +} + +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([]); const [facilities, setFacilities] = useState([]); @@ -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(null); const [isUploadingHeroImages, setIsUploadingHeroImages] = useState(false); const [feedback, setFeedback] = useState(""); 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 (
+
@@ -552,41 +612,53 @@ export default function AdminArticlesPage() { ) : null} {articles.map((article) => ( - +
-

- /{article.slug} -

-

- {article.section === "meninger" ? "Meninger" : "Banebesøk"} -

-

- {article.facility_name || "Uten koblet bane"} -

-

- Klikk for å redigere -

- +
))}
diff --git a/frontend/src/app/facilityData.ts b/frontend/src/app/facilityData.ts index 992910e..4f87fbb 100755 --- a/frontend/src/app/facilityData.ts +++ b/frontend/src/app/facilityData.ts @@ -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 = { "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 = { 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>>(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>(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 = { + 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>(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(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>(facility.amenities, {}).antall_hull) === "18").length, + hole9Count: relevantFacilities.filter((facility) => getHoleCategory(parseJson>(facility.amenities, {}).antall_hull) === "9").length, + hole6Count: relevantFacilities.filter((facility) => getHoleCategory(parseJson>(facility.amenities, {}).antall_hull) === "6").length, + hole27PlusCount: relevantFacilities.filter((facility) => getHoleCategory(parseJson>(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å.`; +}; diff --git a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx index 780544c..2034281 100644 --- a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx +++ b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx @@ -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 = { snapchat: }; -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 }) {
+
+

+ Standardmedlemskap: Hva vil det koste meg, en gjennomsnittsgolfer i alder og kjønn, å spille så mye jeg ønsker på denne banen? +

+

+ Billigst mulig: 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. +

+
+ {facility.standard_medlemskap && (
-
Mest valgte
- Standard + Standardmedlemskap {facility.standard_medlemskap},- {facility.standard_medlemskap_navn &&

{facility.standard_medlemskap_navn}

} {facility.standard_medlemskap_kommentarer && ( @@ -644,7 +662,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) { {facility.rimeligste_alternativ && (
- Rimeligste golfkort + Billigst mulig {facility.rimeligste_alternativ},- {facility.rimeligste_navn &&

{facility.rimeligste_navn}

}
@@ -790,6 +808,12 @@ export default function FacilityDetailView({ facility }: { facility: any }) { )} + + {/* 9. SCOREKORT SEKSJON */}

Scorekort

diff --git a/frontend/src/app/golfbaner/[slug]/FacilityEditorialHub.tsx b/frontend/src/app/golfbaner/[slug]/FacilityEditorialHub.tsx new file mode 100644 index 0000000..915e698 --- /dev/null +++ b/frontend/src/app/golfbaner/[slug]/FacilityEditorialHub.tsx @@ -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 ( +
+
+

Fra TeeOff

+

{title}

+
+ + {articles.length > 0 ? ( +
+ {articles.slice(0, compact ? 2 : 3).map((article) => ( + +
+ {article.eyebrow} + {article.publishedAt ? {formatDate(article.publishedAt)} : null} +
+

{article.title}

+

{article.excerpt}

+

Les mer

+ + ))} +
+ ) : ( +
+

+ {kind === "banebesok" + ? emptyCopy + : `Det er ennå ikke publisert noe om ${facilityName} her på teeoff.no.`} +

+

{emptyPrompt}

+
+ )} + +
+

+ {kind === "banebesok" + ? "Har du et godt tips til banebesøk?" + : "Har du et relevant tips?"} +

+ + {kind === "banebesok" ? "Tips om banebesøk" : "Tips TeeOff."} + +
+
+ ); +} + +function RatingCategory({ + label, + value, + onChange, +}: { + label: string; + value: number; + onChange: (next: number) => void; +}) { + return ( +
+
+

{label}

+ + {value > 0 ? `${value}/5` : "Velg"} + +
+
+ {Array.from({ length: 5 }, (_, index) => { + const nextValue = index + 1; + const active = value >= nextValue; + return ( + + ); + })} +
+
+ ); +} + +function FacilityRatingsCard({ + facilitySlug, + facilityName, +}: { + facilitySlug: string; + facilityName: string; +}) { + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + const [data, setData] = useState({ + 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 ( +
+
+
+

Brukervurderinger

+

Vurder anlegget

+

+ Innloggede brukere kan gi stjerner for kvalitet på anlegg, forhold og gjestfrihet hos {facilityName}. +

+
+
+

Totalscore

+

{formatAverage(data.summary.overall_average)}

+

+ {data.summary.rating_count > 0 ? `${data.summary.rating_count} vurderinger` : "Ingen vurderinger ennå"} +

+
+
+ +
+ {[ + { label: "Anlegg", value: data.summary.quality_average }, + { label: "Forhold", value: data.summary.conditions_average }, + { label: "Gjestfrihet", value: data.summary.hospitality_average }, + ].map((item) => ( +
+

{item.label}

+

{formatAverage(item.value)}

+
+ ))} +
+ + {isLoading ? ( +
+ Laster vurderinger... +
+ ) : null} + + {!isLoading && data.viewer ? ( +
+
+
+

Innlogget

+

{data.viewer.display_name || data.viewer.full_name || "Innlogget"}

+

+ Gi stjerner i alle tre kategorier. Systemet beregner totalscoren automatisk. +

+
+ +
+ +
+ + + +
+ +
+
+

Din totalscore

+

{selectedOverall !== null ? formatAverage(selectedOverall) : "--"}

+
+ +
+
+ ) : null} + + {!isLoading && !data.viewer ? ( +
+

Logg inn for å vurdere anlegget

+

+ Du kan gi stjerner for kvalitet på anlegg, forhold og gjestfrihet. Hver bruker kan oppdatere sin egen vurdering senere. +

+ + {data.auth_providers?.google || data.auth_providers?.magic_link ? ( +
+
+ {data.auth_providers?.google ? ( + + Fortsett med Google + + ) : null} +

Innlogging kreves for vurderinger.

+
+ + {data.auth_providers?.magic_link ? ( +
+

Eller via e-post

+
+ 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]" + /> + +
+
+ ) : null} +
+ ) : ( +
+ Innlogging for vurderinger er ikke tilgjengelig akkurat nå. +
+ )} +
+ ) : null} + + {feedback ? ( +
+ {feedback} +
+ ) : null} +
+ ); +} + +export default function FacilityEditorialHub({ + facilitySlug, + facilityName, + relatedArticles, +}: FacilityEditorialHubProps) { + return ( +
+
+
+

+ Om {facilityName} på TeeOff +

+

+ Her finner du redaksjonelle lenker til banebesøk og artikler om anlegget, samt brukervurderinger fra innloggede besøkende. +

+
+
+ + Tips om banebesøk + + + Tips om artikkel + +
+
+ +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/app/golfbaner/[slug]/page.tsx b/frontend/src/app/golfbaner/[slug]/page.tsx index 8e83025..18f72b7 100755 --- a/frontend/src/app/golfbaner/[slug]/page.tsx +++ b/frontend/src/app/golfbaner/[slug]/page.tsx @@ -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) }} /> - + ); } diff --git a/frontend/src/app/kontakt/page.tsx b/frontend/src/app/kontakt/page.tsx index cd6675c..c5f1166 100644 --- a/frontend/src/app/kontakt/page.tsx +++ b/frontend/src/app/kontakt/page.tsx @@ -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() {
- + diff --git a/frontend/src/app/sted/[slug]/PlaceExplorer.tsx b/frontend/src/app/sted/[slug]/PlaceExplorer.tsx index a190aaf..114190a 100644 --- a/frontend/src/app/sted/[slug]/PlaceExplorer.tsx +++ b/frontend/src/app/sted/[slug]/PlaceExplorer.tsx @@ -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.`; diff --git a/frontend/src/app/sted/[slug]/page.tsx b/frontend/src/app/sted/[slug]/page.tsx index 7719444..7ab3b1c 100755 --- a/frontend/src/app/sted/[slug]/page.tsx +++ b/frontend/src/app/sted/[slug]/page.tsx @@ -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

{place.intro}

- {facilitiesInPlace.length} golfbaner - - - Kart og liste i samme visning + {placeStats.facilityCount} golfanlegg
+
+
+
+

Nøkkeltall

+

+ {isNationalPlace ? "Fakta om golfanleggene i Norge" : `Fakta om golfanleggene ${placePreposition} ${place.label}`} +

+

{placeStatsIntro}

+
+
+ +
+
+

Greenfee

+

+ {placeStats.avgPrimetimeGreenfee !== null ? formatPlaceCurrency(placeStats.avgPrimetimeGreenfee) : "Mangler grunnlag"} +

+

+ {placeStats.avgPrimetimeGreenfee !== null + ? "Gjennomsnittlig primetime-pris i høysesong." + : "Ingen tilstrekkelige greenfee-data tilgjengelig."} +

+ {greenfeeComparison ?

{greenfeeComparison}

: null} +
+ +
+

Medlemskap

+

+ {placeStats.avgStandardMembership !== null ? formatPlaceCurrency(placeStats.avgStandardMembership) : "Mangler grunnlag"} +

+

+ {placeStats.avgStandardMembership !== null + ? "Gjennomsnittlig standard medlemskap med spillerett." + : "Ingen tilstrekkelige medlemskapsdata tilgjengelig."} +

+ {membershipComparison ?

{membershipComparison}

: null} +
+ +
+

Hullstatistikk

+

+ {formatPlaceCount(placeStats.hole18Count, "neuter")} 18-hullsanlegg +
+ {formatPlaceCount(placeStats.hole9Count, "neuter")} 9-hullsanlegg +
+ {formatPlaceCount(placeStats.hole6Count, "neuter")} 6-hullsanlegg +
+ {formatPlaceCount(placeStats.hole27PlusCount, "neuter")} anlegg med 27 hull eller mer +

+ {holeDistributionText ?

{holeDistributionText}.

: null} + {shortestLongestText ?

{shortestLongestText}

: null} +
+
+
+ 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(""); diff --git a/frontend/src/content/editorialArticles.ts b/frontend/src/content/editorialArticles.ts index 5a62212..aba07c9 100644 --- a/frontend/src/content/editorialArticles.ts +++ b/frontend/src/content/editorialArticles.ts @@ -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[], + }; + } +}