+
+ 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 (
+
+
+
+ {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 (
+ 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`}
+ >
+ ★
+
+ );
+ })}
+
+
+ );
+}
+
+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.
+
+
+
+ Logg ut
+
+
+
+
+
+
+
+
+
+
+
+
Din totalscore
+
{selectedOverall !== null ? formatAverage(selectedOverall) : "--"}
+
+
+ {isSaving ? "Lagrer..." : data.user_rating ? "Oppdater vurdering" : "Lagre vurdering"}
+
+
+
+ ) : 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]"
+ />
+
+ {isSendingMagicLink ? "Sender..." : "Send innloggingslenke"}
+
+
+
+ ) : 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() {