Etter SEO step 1
This commit is contained in:
parent
b5f9f52ffe
commit
3bf429a5f7
11 changed files with 680 additions and 74 deletions
16
frontend/src/app/admin/layout.tsx
Executable file
16
frontend/src/app/admin/layout.tsx
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
googleBot: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
76
frontend/src/app/golfbaner/[slug]/page.tsx
Normal file → Executable file
76
frontend/src/app/golfbaner/[slug]/page.tsx
Normal file → Executable file
|
|
@ -1,17 +1,79 @@
|
|||
// page.tsx
|
||||
import type { Metadata } from "next";
|
||||
import { API_URL } from "@/config/constants";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createFacilityJsonLd,
|
||||
createPageMetadata,
|
||||
trimDescription,
|
||||
} from "@/app/seo";
|
||||
import FacilityDetailView from "./FacilityDetailView";
|
||||
|
||||
export default async function GolfCoursePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
type GolfCoursePageProps = {
|
||||
params: Promise<{ slug: string }>;
|
||||
};
|
||||
|
||||
async function getFacility(slug: string) {
|
||||
const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: "no-store" });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: GolfCoursePageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
|
||||
const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: 'no-store' });
|
||||
const facility = await res.json();
|
||||
|
||||
try {
|
||||
const facility = await getFacility(slug);
|
||||
if (!facility || facility.error) {
|
||||
return createPageMetadata({
|
||||
title: "Golfbane ikke funnet",
|
||||
description: "Golfanlegget du prøvde å åpne finnes ikke på TeeOff.",
|
||||
path: `/golfbaner/${slug}`,
|
||||
});
|
||||
}
|
||||
|
||||
const title = `${facility.name}${facility.city ? ` i ${facility.city}` : ""}`;
|
||||
const fallbackDescription = `${facility.name} på TeeOff med banestatus, priser, kontaktinfo og lenker til nyttige ressurser.`;
|
||||
|
||||
return createPageMetadata({
|
||||
title,
|
||||
description: trimDescription(facility.description) || fallbackDescription,
|
||||
path: `/golfbaner/${slug}`,
|
||||
image: facility.image_url,
|
||||
});
|
||||
} catch {
|
||||
return createPageMetadata({
|
||||
title: "Golfbane",
|
||||
description: "Golfanlegg på TeeOff med status, priser og klubbinfo.",
|
||||
path: `/golfbaner/${slug}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default async function GolfCoursePage({ params }: GolfCoursePageProps) {
|
||||
const { slug } = await params;
|
||||
const facility = await getFacility(slug);
|
||||
|
||||
if (!facility || facility.error) {
|
||||
return <div className="p-20 text-center font-bold text-2xl">Fant ikke golfbanen...</div>;
|
||||
}
|
||||
|
||||
// Vi sender dataene til den navngitte komponenten
|
||||
return <FacilityDetailView facility={facility} />;
|
||||
const facilityJsonLd = createFacilityJsonLd(facility);
|
||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||
{ name: "Hjem", path: "/" },
|
||||
{ name: "Golfbaner", path: "/golfbaner" },
|
||||
{ name: facility.name, path: `/golfbaner/${facility.slug}` },
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(facilityJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<FacilityDetailView facility={facility} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,23 @@
|
|||
import FacilitySearch from "@/app/FacilitySearch";
|
||||
import { API_URL } from "@/config/constants";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createCollectionPageJsonLd,
|
||||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const pageTitle = "Alle golfbaner i Norge";
|
||||
const pageDescription =
|
||||
"Filtrer norske golfbaner etter område, banestatus, antall hull og fasiliteter i TeeOffs samlede oversikt.";
|
||||
|
||||
export const metadata = createPageMetadata({
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/golfbaner",
|
||||
});
|
||||
|
||||
export default async function GolfCoursesIndexPage() {
|
||||
let facilities = [];
|
||||
|
||||
|
|
@ -23,16 +38,35 @@ export default async function GolfCoursesIndexPage() {
|
|||
}
|
||||
|
||||
const safeData = Array.isArray(facilities) ? facilities : [];
|
||||
const collectionJsonLd = createCollectionPageJsonLd({
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/golfbaner",
|
||||
});
|
||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||
{ name: "Hjem", path: "/" },
|
||||
{ name: "Golfbaner", path: "/golfbaner" },
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="site-shell min-h-screen">
|
||||
<FacilitySearch
|
||||
initialFacilities={safeData}
|
||||
variant="catalog"
|
||||
eyebrow="Golfbaner"
|
||||
title="Alle golfbaner samlet på ett sted"
|
||||
intro="Bruk område, banestatus og fasiliteter for å snevre inn oversikten. Her får katalogen være arbeidsflate, ikke hero."
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }}
|
||||
/>
|
||||
</main>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<main className="site-shell min-h-screen">
|
||||
<FacilitySearch
|
||||
initialFacilities={safeData}
|
||||
variant="catalog"
|
||||
eyebrow="Golfbaner"
|
||||
title="Alle golfbaner samlet på ett sted"
|
||||
intro="Bruk område, banestatus og fasiliteter for å snevre inn oversikten. Her får katalogen være arbeidsflate, ikke hero."
|
||||
/>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@ import type { Metadata } from "next";
|
|||
import { Mulish, Oswald } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Header from "@/components/Header";
|
||||
import {
|
||||
DEFAULT_DESCRIPTION,
|
||||
DEFAULT_OG_IMAGE,
|
||||
SITE_URL,
|
||||
createOrganizationJsonLd,
|
||||
createWebsiteJsonLd,
|
||||
} from "@/app/seo";
|
||||
|
||||
const uiFont = Mulish({
|
||||
subsets: ["latin"],
|
||||
|
|
@ -16,8 +23,51 @@ const displayFont = Oswald({
|
|||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "TeeOff.no - Din guide til norske golfbaner",
|
||||
description: "Oppdatert banestatus, priser og informasjon om alle norske golfanlegg.",
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: "TeeOff.no - Din guide til norske golfbaner",
|
||||
template: "%s | TeeOff.no",
|
||||
},
|
||||
description: DEFAULT_DESCRIPTION,
|
||||
applicationName: "TeeOff",
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "nb_NO",
|
||||
siteName: "TeeOff",
|
||||
title: "TeeOff.no - Din guide til norske golfbaner",
|
||||
description: DEFAULT_DESCRIPTION,
|
||||
url: SITE_URL,
|
||||
images: [
|
||||
{
|
||||
url: DEFAULT_OG_IMAGE,
|
||||
width: 1600,
|
||||
height: 900,
|
||||
alt: "TeeOff.no",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
site: "@TeeOffno",
|
||||
creator: "@TeeOffno",
|
||||
title: "TeeOff.no - Din guide til norske golfbaner",
|
||||
description: DEFAULT_DESCRIPTION,
|
||||
images: [DEFAULT_OG_IMAGE],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
maxSnippet: -1,
|
||||
maxImagePreview: "large",
|
||||
maxVideoPreview: -1,
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: "/icons/cropped-siteicon-1.png",
|
||||
shortcut: "/icons/cropped-siteicon-1.png",
|
||||
|
|
@ -26,9 +76,20 @@ export const metadata: Metadata = {
|
|||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const organizationJsonLd = createOrganizationJsonLd();
|
||||
const websiteJsonLd = createWebsiteJsonLd();
|
||||
|
||||
return (
|
||||
<html lang="nb">
|
||||
<body className={`${uiFont.variable} ${displayFont.variable} antialiased`}>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
|
||||
/>
|
||||
<Header />
|
||||
{children}
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,23 @@
|
|||
import { API_URL } from "@/config/constants";
|
||||
import MembershipExplorer, { type MembershipFacility } from "./MembershipExplorer";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createCollectionPageJsonLd,
|
||||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const pageTitle = "Medlemskap i norske golfklubber";
|
||||
const pageDescription =
|
||||
"Sammenlign priser på medlemskap i norske golfklubber, både full spillerett og rimeligste nasjonale alternativ.";
|
||||
|
||||
export const metadata = createPageMetadata({
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/medlemskap",
|
||||
});
|
||||
|
||||
export default async function MembershipPage() {
|
||||
let facilities: MembershipFacility[] = [];
|
||||
|
||||
|
|
@ -28,34 +43,53 @@ export default async function MembershipPage() {
|
|||
typeof facility.standard_medlemskap === "number" ||
|
||||
typeof facility.rimeligste_alternativ === "number",
|
||||
);
|
||||
const collectionJsonLd = createCollectionPageJsonLd({
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/medlemskap",
|
||||
});
|
||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||
{ name: "Hjem", path: "/" },
|
||||
{ name: "Medlemskap", path: "/medlemskap" },
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="site-shell min-h-screen">
|
||||
<section className="border-b border-[#112015]/8 bg-[linear-gradient(135deg,rgba(139,195,74,0.16),rgba(255,255,255,0.92))]">
|
||||
<div className="mx-auto max-w-[1400px] px-4 py-14 sm:px-6 lg:px-8 lg:py-20">
|
||||
<div className="max-w-4xl">
|
||||
<p className="mb-4 text-[11px] font-black uppercase tracking-[0.28em] text-[#8BC34A]">
|
||||
Medlemskap
|
||||
</p>
|
||||
<h1 className="max-w-3xl text-5xl font-black text-[#112015] sm:text-6xl">
|
||||
Dette koster medlemskap i norske golfklubber
|
||||
</h1>
|
||||
<p className="mt-6 max-w-3xl text-base leading-7 text-[#4F5F50] sm:text-lg">
|
||||
Beløpene oppdateres fortløpende etter hvert som vi får verifisert nye priser.
|
||||
Siden er laget for å sammenligne, ikke bare lese. Derfor er tabellformatet
|
||||
beholdt også på mobil.
|
||||
</p>
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<main className="site-shell min-h-screen">
|
||||
<section className="border-b border-[#112015]/8 bg-[linear-gradient(135deg,rgba(139,195,74,0.16),rgba(255,255,255,0.92))]">
|
||||
<div className="mx-auto max-w-[1400px] px-4 py-14 sm:px-6 lg:px-8 lg:py-20">
|
||||
<div className="max-w-4xl">
|
||||
<p className="mb-4 text-[11px] font-black uppercase tracking-[0.28em] text-[#8BC34A]">
|
||||
Medlemskap
|
||||
</p>
|
||||
<h1 className="max-w-3xl text-5xl font-black text-[#112015] sm:text-6xl">
|
||||
Dette koster medlemskap i norske golfklubber
|
||||
</h1>
|
||||
<p className="mt-6 max-w-3xl text-base leading-7 text-[#4F5F50] sm:text-lg">
|
||||
Beløpene oppdateres fortløpende etter hvert som vi får verifisert nye priser.
|
||||
Siden er laget for å sammenligne, ikke bare lese. Derfor er tabellformatet
|
||||
beholdt også på mobil.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-8 max-w-4xl text-sm leading-6 text-[#5B675C]">
|
||||
Velg hvilken type medlemskap du vil sammenligne under. Hver rad kan åpnes for flere
|
||||
detaljer, sist oppdatert-dato og lenke til klubbens egen innmelding.
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 max-w-4xl text-sm leading-6 text-[#5B675C]">
|
||||
Velg hvilken type medlemskap du vil sammenligne under. Hver rad kan åpnes for flere
|
||||
detaljer, sist oppdatert-dato og lenke til klubbens egen innmelding.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-[1400px] px-4 py-8 sm:px-6 lg:px-8 lg:py-10">
|
||||
<MembershipExplorer facilities={visibleFacilities} />
|
||||
</section>
|
||||
</main>
|
||||
<section className="mx-auto max-w-[1400px] px-4 py-8 sm:px-6 lg:px-8 lg:py-10">
|
||||
<MembershipExplorer facilities={visibleFacilities} />
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
import FacilitySearch from "./FacilitySearch";
|
||||
import HeroSlider from "./HeroSlider";
|
||||
import { API_URL } from "@/config/constants";
|
||||
import { createPageMetadata } from "@/app/seo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = createPageMetadata({
|
||||
title: "Din guide til norske golfbaner",
|
||||
description:
|
||||
"Utforsk norske golfanlegg med oppdatert banestatus, kart, priser, medlemskap og Veien til Golf samlet på TeeOff.",
|
||||
path: "/",
|
||||
});
|
||||
|
||||
export default async function Home() {
|
||||
let facilities = [];
|
||||
|
|
|
|||
16
frontend/src/app/robots.ts
Executable file
16
frontend/src/app/robots.ts
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/app/seo";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/admin"],
|
||||
},
|
||||
],
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
host: SITE_URL,
|
||||
};
|
||||
}
|
||||
223
frontend/src/app/seo.ts
Executable file
223
frontend/src/app/seo.ts
Executable file
|
|
@ -0,0 +1,223 @@
|
|||
import type { Metadata } from "next";
|
||||
import { FALLBACK_IMAGE } from "@/config/constants";
|
||||
import type { FacilityRecord } from "@/app/facilityData";
|
||||
|
||||
type MetadataInput = {
|
||||
title: string;
|
||||
description: string;
|
||||
path?: string;
|
||||
image?: string | null;
|
||||
type?: "website" | "article";
|
||||
};
|
||||
|
||||
type BreadcrumbItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type CollectionPageInput = {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type SocialLink = {
|
||||
url?: string | null;
|
||||
};
|
||||
|
||||
type FacilitySeoRecord = FacilityRecord & {
|
||||
address?: string | null;
|
||||
email?: string | null;
|
||||
social_links?: unknown;
|
||||
};
|
||||
|
||||
export const SITE_NAME = "TeeOff";
|
||||
export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL || "https://nye.teeoff.no").replace(/\/$/, "");
|
||||
export const DEFAULT_DESCRIPTION =
|
||||
"Oppdatert banestatus, priser, Veien til Golf og informasjon om norske golfanlegg samlet på ett sted.";
|
||||
export const DEFAULT_OG_IMAGE = buildAbsoluteUrl(FALLBACK_IMAGE);
|
||||
export const ORGANIZATION_ID = `${SITE_URL}#organization`;
|
||||
export const WEBSITE_ID = `${SITE_URL}#website`;
|
||||
|
||||
const SAME_AS_LINKS = [
|
||||
"https://www.facebook.com/TeeOff.norge/",
|
||||
"https://twitter.com/TeeOffno",
|
||||
"https://www.instagram.com/teeoffno/",
|
||||
];
|
||||
|
||||
export function buildAbsoluteUrl(path = "/") {
|
||||
if (!path) return SITE_URL;
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
return `${SITE_URL}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
export function resolveImageUrl(image?: string | null) {
|
||||
if (!image) return DEFAULT_OG_IMAGE;
|
||||
return buildAbsoluteUrl(image);
|
||||
}
|
||||
|
||||
export function stripHtml(value: string | null | undefined) {
|
||||
return String(value || "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function trimDescription(value: string | null | undefined, maxLength = 160) {
|
||||
const plain = stripHtml(value);
|
||||
if (plain.length <= maxLength) return plain;
|
||||
return `${plain.slice(0, maxLength - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function createPageMetadata({
|
||||
title,
|
||||
description,
|
||||
path = "/",
|
||||
image,
|
||||
type = "website",
|
||||
}: MetadataInput): Metadata {
|
||||
const ogImage = resolveImageUrl(image);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: path,
|
||||
},
|
||||
openGraph: {
|
||||
type,
|
||||
locale: "nb_NO",
|
||||
siteName: SITE_NAME,
|
||||
title,
|
||||
description,
|
||||
url: buildAbsoluteUrl(path),
|
||||
images: [
|
||||
{
|
||||
url: ogImage,
|
||||
width: 1600,
|
||||
height: 900,
|
||||
alt: title,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
site: "@TeeOffno",
|
||||
creator: "@TeeOffno",
|
||||
title,
|
||||
description,
|
||||
images: [ogImage],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createOrganizationJsonLd() {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"@id": ORGANIZATION_ID,
|
||||
name: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
url: buildAbsoluteUrl("/icons/cropped-siteicon-1.png"),
|
||||
},
|
||||
sameAs: SAME_AS_LINKS,
|
||||
};
|
||||
}
|
||||
|
||||
export function createWebsiteJsonLd() {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"@id": WEBSITE_ID,
|
||||
url: SITE_URL,
|
||||
name: SITE_NAME,
|
||||
inLanguage: "nb-NO",
|
||||
publisher: {
|
||||
"@id": ORGANIZATION_ID,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createBreadcrumbJsonLd(items: BreadcrumbItem[]) {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: items.map((item, index) => ({
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
name: item.name,
|
||||
item: buildAbsoluteUrl(item.path),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCollectionPageJsonLd({ name, description, path }: CollectionPageInput) {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "CollectionPage",
|
||||
name,
|
||||
description,
|
||||
url: buildAbsoluteUrl(path),
|
||||
inLanguage: "nb-NO",
|
||||
isPartOf: {
|
||||
"@id": WEBSITE_ID,
|
||||
},
|
||||
about: {
|
||||
"@id": ORGANIZATION_ID,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createFacilityJsonLd(facility: FacilitySeoRecord) {
|
||||
const socialLinks = parseJson<SocialLink[]>(facility.social_links, []);
|
||||
const sameAs = [facility.website_url, ...socialLinks.map((entry) => entry?.url || null)].filter(Boolean);
|
||||
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "GolfCourse",
|
||||
"@id": buildAbsoluteUrl(`/golfbaner/${facility.slug}#golfcourse`),
|
||||
name: facility.name,
|
||||
description:
|
||||
trimDescription(facility.description) ||
|
||||
`${facility.name} er et golfanlegg på TeeOff med oppdatert banestatus og praktisk klubbinfo.`,
|
||||
url: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
|
||||
image: resolveImageUrl(facility.image_url),
|
||||
telephone: facility.phone || undefined,
|
||||
email: facility.email || undefined,
|
||||
address:
|
||||
facility.address || facility.city || facility.county
|
||||
? {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: facility.address || undefined,
|
||||
addressLocality: facility.city || undefined,
|
||||
addressRegion: facility.county || undefined,
|
||||
addressCountry: "NO",
|
||||
}
|
||||
: undefined,
|
||||
geo:
|
||||
typeof facility.lat === "number" && typeof facility.lng === "number"
|
||||
? {
|
||||
"@type": "GeoCoordinates",
|
||||
latitude: facility.lat,
|
||||
longitude: facility.lng,
|
||||
}
|
||||
: undefined,
|
||||
sameAs: sameAs.length > 0 ? sameAs : undefined,
|
||||
isPartOf: {
|
||||
"@id": WEBSITE_ID,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseJson<T>(value: unknown, fallback: T): T {
|
||||
if (!value) return fallback;
|
||||
if (typeof value === "object") return value as T;
|
||||
try {
|
||||
return JSON.parse(String(value)) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
69
frontend/src/app/sitemap.ts
Executable file
69
frontend/src/app/sitemap.ts
Executable file
|
|
@ -0,0 +1,69 @@
|
|||
import type { MetadataRoute } from "next";
|
||||
import { API_URL } from "@/config/constants";
|
||||
import { getAvailablePlaceConfigs } from "@/app/facilityData";
|
||||
import { buildAbsoluteUrl } from "@/app/seo";
|
||||
|
||||
type SitemapFacility = {
|
||||
slug?: string;
|
||||
status_updated_at?: string | null;
|
||||
vtg_updated_at?: string | null;
|
||||
};
|
||||
|
||||
const staticRoutes: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: buildAbsoluteUrl("/"),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: buildAbsoluteUrl("/golfbaner"),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily",
|
||||
priority: 0.95,
|
||||
},
|
||||
{
|
||||
url: buildAbsoluteUrl("/medlemskap"),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: buildAbsoluteUrl("/vtg"),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
];
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
let facilities: SitemapFacility[] = [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/facilities`, { cache: "no-store" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
facilities = Array.isArray(data) ? data : [];
|
||||
}
|
||||
} catch {
|
||||
facilities = [];
|
||||
}
|
||||
|
||||
const placeRoutes = getAvailablePlaceConfigs().map((slug) => ({
|
||||
url: buildAbsoluteUrl(`/sted/${slug}`),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily" as const,
|
||||
priority: slug === "norge" ? 0.9 : 0.75,
|
||||
}));
|
||||
|
||||
const facilityRoutes = facilities
|
||||
.filter((facility) => Boolean(facility.slug))
|
||||
.map((facility) => ({
|
||||
url: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
|
||||
lastModified: facility.status_updated_at || facility.vtg_updated_at || new Date(),
|
||||
changeFrequency: "daily" as const,
|
||||
priority: 0.7,
|
||||
}));
|
||||
|
||||
return [...staticRoutes, ...placeRoutes, ...facilityRoutes];
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import FacilitySearch from "@/app/FacilitySearch";
|
||||
import PlaceMap from "@/components/PlaceMap";
|
||||
|
|
@ -9,6 +10,11 @@ import {
|
|||
getPlaceConfigFromSlug,
|
||||
} from "@/app/facilityData";
|
||||
import { API_URL } from "@/config/constants";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createCollectionPageJsonLd,
|
||||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
|
||||
export const dynamicParams = true;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
@ -17,6 +23,29 @@ export async function generateStaticParams() {
|
|||
return getAvailablePlaceConfigs().map((slug) => ({ slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const place = getPlaceConfigFromSlug(slug);
|
||||
|
||||
if (!place) {
|
||||
return createPageMetadata({
|
||||
title: "Sted ikke funnet",
|
||||
description: "Denne stedssiden finnes ikke på TeeOff.",
|
||||
path: `/sted/${slug}`,
|
||||
});
|
||||
}
|
||||
|
||||
return createPageMetadata({
|
||||
title: place.title,
|
||||
description: place.intro,
|
||||
path: `/sted/${slug}`,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function PlacePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const place = getPlaceConfigFromSlug(slug);
|
||||
|
|
@ -45,37 +74,57 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
|
|||
|
||||
const safeData = Array.isArray(facilities) ? facilities : [];
|
||||
const facilitiesInPlace = filterFacilitiesByArea(enrichFacilities(safeData), place.areaFilter);
|
||||
const collectionJsonLd = createCollectionPageJsonLd({
|
||||
name: place.title,
|
||||
description: place.intro,
|
||||
path: `/sted/${slug}`,
|
||||
});
|
||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||
{ name: "Hjem", path: "/" },
|
||||
{ name: "Steder", path: "/sted/norge" },
|
||||
{ name: place.label, path: `/sted/${slug}` },
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="site-shell min-h-screen">
|
||||
<section className="mx-auto max-w-[1400px] px-4 py-8 sm:px-6 sm:py-10 lg:px-8 lg:py-12">
|
||||
<div className="max-w-4xl">
|
||||
<p className="mb-3 text-[11px] font-extrabold uppercase tracking-[0.3em] text-[#6FA786]">Steder</p>
|
||||
<h1 className="section-title text-4xl text-[#112015] sm:text-5xl lg:text-6xl">{place.title}</h1>
|
||||
<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} anlegg
|
||||
</span>
|
||||
<span className="rounded-full bg-[#25312A] px-4 py-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-white">
|
||||
Kart og liste i samme visning
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<PlaceMap facilities={facilitiesInPlace} placeLabel={place.label} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<FacilitySearch
|
||||
initialFacilities={safeData}
|
||||
variant="catalog"
|
||||
title={place.title}
|
||||
intro={`Filtrer anleggene i ${place.label} videre etter banestatus, antall hull og andre egenskaper.`}
|
||||
fixedAreaFilter={place.areaFilter}
|
||||
hideTitleBlock
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }}
|
||||
/>
|
||||
</main>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<main className="site-shell min-h-screen">
|
||||
<section className="mx-auto max-w-[1400px] px-4 py-8 sm:px-6 sm:py-10 lg:px-8 lg:py-12">
|
||||
<div className="max-w-4xl">
|
||||
<p className="mb-3 text-[11px] font-extrabold uppercase tracking-[0.3em] text-[#6FA786]">Steder</p>
|
||||
<h1 className="section-title text-4xl text-[#112015] sm:text-5xl lg:text-6xl">{place.title}</h1>
|
||||
<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} anlegg
|
||||
</span>
|
||||
<span className="rounded-full bg-[#25312A] px-4 py-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-white">
|
||||
Kart og liste i samme visning
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<PlaceMap facilities={facilitiesInPlace} placeLabel={place.label} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<FacilitySearch
|
||||
initialFacilities={safeData}
|
||||
variant="catalog"
|
||||
title={place.title}
|
||||
intro={`Filtrer anleggene i ${place.label} videre etter banestatus, antall hull og andre egenskaper.`}
|
||||
fixedAreaFilter={place.areaFilter}
|
||||
hideTitleBlock
|
||||
/>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,24 @@
|
|||
import { API_URL } from "@/config/constants";
|
||||
import VtgExplorer from "./VtgExplorer";
|
||||
import type { FacilityRecord } from "@/app/facilityData";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createCollectionPageJsonLd,
|
||||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const pageTitle = "Veien til Golf";
|
||||
const pageDescription =
|
||||
"Finn Veien til Golf-kurs etter område, klubb, pris og neste kursdato i TeeOffs VTG-oversikt.";
|
||||
|
||||
export const metadata = createPageMetadata({
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/vtg",
|
||||
});
|
||||
|
||||
export default async function VtgPage() {
|
||||
let facilities: FacilityRecord[] = [];
|
||||
|
||||
|
|
@ -24,9 +39,29 @@ export default async function VtgPage() {
|
|||
facilities = [];
|
||||
}
|
||||
|
||||
const collectionJsonLd = createCollectionPageJsonLd({
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/vtg",
|
||||
});
|
||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||
{ name: "Hjem", path: "/" },
|
||||
{ name: "VTG", path: "/vtg" },
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="site-shell min-h-screen">
|
||||
<VtgExplorer facilities={facilities} />
|
||||
</main>
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<main className="site-shell min-h-screen">
|
||||
<VtgExplorer facilities={facilities} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue