Etter favicon, før tilleggssider
|
|
@ -39,7 +39,7 @@ services:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
container_name: teeoff_frontend
|
container_name: teeoff_frontend
|
||||||
# NY LINJE: Tvinger produksjonsmodus for å stoppe WebSocket-feil og relasting
|
# NY LINJE: Tvinger produksjonsmodus for å stoppe WebSocket-feil og relasting
|
||||||
command: sh -c "npm run build && npm start"
|
command: npm start
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
# VIKTIG: Jeg har fjernet "- ./frontend:/app" her for å sikre stabilitet
|
# VIKTIG: Jeg har fjernet "- ./frontend:/app" her for å sikre stabilitet
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,8 @@ RUN npm install
|
||||||
# Kopier resten av koden
|
# Kopier resten av koden
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Vi starter IKKE serveren i "dev"-modus (utviklingsmodus).
|
# BYGG koden her (kjøres jyb én gang når imaget bygges
|
||||||
CMD ["sh", "-c", "npm run build && npm start"]
|
RUN npm run build
|
||||||
|
|
||||||
|
# Vi starter serveren i "produksjons"-modus (utviklingsmodus).
|
||||||
|
CMD ["npm", "start"]
|
||||||
|
|
|
||||||
BIN
frontend/public/android-chrome-192x192.png
Executable file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/android-chrome-512x512.png
Executable file
|
After Width: | Height: | Size: 38 KiB |
BIN
frontend/public/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 11 KiB |
1
frontend/public/site.webmanifest
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
|
|
@ -235,6 +235,19 @@ const sanitizeHref = (value: string) => {
|
||||||
return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#";
|
return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isInternalTeeoffHref = (href: string) => {
|
||||||
|
if (!href || href.startsWith("/") || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(href, "https://nye.teeoff.no");
|
||||||
|
return url.hostname === "teeoff.no" || url.hostname.endsWith(".teeoff.no");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const sanitizeRichText = (value: string | null | undefined) => {
|
const sanitizeRichText = (value: string | null | undefined) => {
|
||||||
const source = String(value || "").replace(/\r\n?/g, "\n");
|
const source = String(value || "").replace(/\r\n?/g, "\n");
|
||||||
if (!source.trim()) return "";
|
if (!source.trim()) return "";
|
||||||
|
|
@ -262,7 +275,10 @@ const sanitizeRichText = (value: string | null | undefined) => {
|
||||||
.replace(/<\s*a\b([^>]*)>/gi, (_, attrs: string) => {
|
.replace(/<\s*a\b([^>]*)>/gi, (_, attrs: string) => {
|
||||||
const hrefMatch = attrs.match(/href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
const hrefMatch = attrs.match(/href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
||||||
const href = sanitizeHref(hrefMatch?.[1] || hrefMatch?.[2] || hrefMatch?.[3] || "#");
|
const href = sanitizeHref(hrefMatch?.[1] || hrefMatch?.[2] || hrefMatch?.[3] || "#");
|
||||||
return keep(`<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer">`);
|
if (isInternalTeeoffHref(href)) {
|
||||||
|
return keep(`<a href="${escapeHtml(href)}">`);
|
||||||
|
}
|
||||||
|
return keep(`<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer noopener">`);
|
||||||
})
|
})
|
||||||
.replace(/<\s*\/\s*a\s*>/gi, () => keep("</a>"));
|
.replace(/<\s*\/\s*a\s*>/gi, () => keep("</a>"));
|
||||||
|
|
||||||
|
|
|
||||||
BIN
frontend/src/app/android-chrome-192x192.png
Executable file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/src/app/android-chrome-512x512.png
Executable file
|
After Width: | Height: | Size: 38 KiB |
BIN
frontend/src/app/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
|
@ -47,6 +47,19 @@ const sanitizeHref = (value: string) => {
|
||||||
return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#";
|
return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isInternalTeeoffHref = (href: string) => {
|
||||||
|
if (!href || href.startsWith("/") || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(href, "https://nye.teeoff.no");
|
||||||
|
return url.hostname === "teeoff.no" || url.hostname.endsWith(".teeoff.no");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const sanitizeRichText = (value: string | null | undefined) => {
|
const sanitizeRichText = (value: string | null | undefined) => {
|
||||||
const source = String(value || "").replace(/\r\n?/g, "\n");
|
const source = String(value || "").replace(/\r\n?/g, "\n");
|
||||||
if (!source.trim()) return "";
|
if (!source.trim()) return "";
|
||||||
|
|
@ -74,7 +87,10 @@ const sanitizeRichText = (value: string | null | undefined) => {
|
||||||
.replace(/<\s*a\b([^>]*)>/gi, (_, attrs: string) => {
|
.replace(/<\s*a\b([^>]*)>/gi, (_, attrs: string) => {
|
||||||
const hrefMatch = attrs.match(/href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
const hrefMatch = attrs.match(/href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
||||||
const href = sanitizeHref(hrefMatch?.[1] || hrefMatch?.[2] || hrefMatch?.[3] || "#");
|
const href = sanitizeHref(hrefMatch?.[1] || hrefMatch?.[2] || hrefMatch?.[3] || "#");
|
||||||
return keep(`<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer">`);
|
if (isInternalTeeoffHref(href)) {
|
||||||
|
return keep(`<a href="${escapeHtml(href)}">`);
|
||||||
|
}
|
||||||
|
return keep(`<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer noopener">`);
|
||||||
})
|
})
|
||||||
.replace(/<\s*\/\s*a\s*>/gi, () => keep("</a>"));
|
.replace(/<\s*\/\s*a\s*>/gi, () => keep("</a>"));
|
||||||
|
|
||||||
|
|
|
||||||
133
frontend/src/app/golfbaner/[slug]/opengraph-image.tsx
Executable file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import { API_URL } from "@/config/constants";
|
||||||
|
import { buildAbsoluteUrl } from "@/app/seo";
|
||||||
|
|
||||||
|
export const size = {
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const contentType = "image/png";
|
||||||
|
|
||||||
|
type OpenGraphImageProps = {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getFacility(slug: string) {
|
||||||
|
const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: "no-store" });
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OpenGraphImage({ params }: OpenGraphImageProps) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const facility = await getFacility(slug);
|
||||||
|
|
||||||
|
const title = facility?.name || "TeeOff";
|
||||||
|
const location = [facility?.city, facility?.county].filter(Boolean).join(" · ") || "Norske golfanlegg";
|
||||||
|
const imageUrl = facility?.image_url ? buildAbsoluteUrl(facility.image_url) : null;
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
background: "linear-gradient(135deg, #1f2b24 0%, #3b4a3f 100%)",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={title}
|
||||||
|
width={1200}
|
||||||
|
height={630}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background:
|
||||||
|
"linear-gradient(180deg, rgba(17,32,21,0.15) 0%, rgba(17,32,21,0.82) 72%, rgba(17,32,21,0.92) 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
padding: "52px 58px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "rgba(139, 195, 74, 0.92)",
|
||||||
|
color: "#112015",
|
||||||
|
padding: "12px 22px",
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: "0.18em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
TeeOff
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 18, maxWidth: 900 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "#9ecb66",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Golfbane
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
fontSize: 66,
|
||||||
|
lineHeight: 1.02,
|
||||||
|
fontWeight: 900,
|
||||||
|
letterSpacing: "-0.04em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "rgba(255,255,255,0.88)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{location}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
createBreadcrumbJsonLd,
|
createBreadcrumbJsonLd,
|
||||||
createFacilityJsonLd,
|
createFacilityJsonLd,
|
||||||
createPageMetadata,
|
createPageMetadata,
|
||||||
|
createVtgCourseJsonLd,
|
||||||
trimDescription,
|
trimDescription,
|
||||||
} from "@/app/seo";
|
} from "@/app/seo";
|
||||||
import FacilityDetailView from "./FacilityDetailView";
|
import FacilityDetailView from "./FacilityDetailView";
|
||||||
|
|
@ -37,7 +38,7 @@ export async function generateMetadata({ params }: GolfCoursePageProps): Promise
|
||||||
title,
|
title,
|
||||||
description: trimDescription(facility.description) || fallbackDescription,
|
description: trimDescription(facility.description) || fallbackDescription,
|
||||||
path: `/golfbaner/${slug}`,
|
path: `/golfbaner/${slug}`,
|
||||||
image: facility.image_url,
|
image: `/golfbaner/${slug}/opengraph-image`,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return createPageMetadata({
|
return createPageMetadata({
|
||||||
|
|
@ -57,6 +58,7 @@ export default async function GolfCoursePage({ params }: GolfCoursePageProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const facilityJsonLd = createFacilityJsonLd(facility);
|
const facilityJsonLd = createFacilityJsonLd(facility);
|
||||||
|
const vtgCourseJsonLd = createVtgCourseJsonLd(facility);
|
||||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||||
{ name: "Hjem", path: "/" },
|
{ name: "Hjem", path: "/" },
|
||||||
{ name: "Golfbaner", path: "/golfbaner" },
|
{ name: "Golfbaner", path: "/golfbaner" },
|
||||||
|
|
@ -69,6 +71,12 @@ export default async function GolfCoursePage({ params }: GolfCoursePageProps) {
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(facilityJsonLd) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(facilityJsonLd) }}
|
||||||
/>
|
/>
|
||||||
|
{vtgCourseJsonLd ? (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(vtgCourseJsonLd) }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,36 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Mulish, Oswald } from "next/font/google";
|
import { Mulish, Oswald } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import {
|
import {
|
||||||
DEFAULT_DESCRIPTION,
|
DEFAULT_DESCRIPTION,
|
||||||
DEFAULT_OG_IMAGE,
|
DEFAULT_OG_IMAGE,
|
||||||
SITE_URL,
|
SITE_URL,
|
||||||
createOrganizationJsonLd,
|
createOrganizationJsonLd,
|
||||||
createWebsiteJsonLd,
|
createWebsiteJsonLd,
|
||||||
} from "@/app/seo";
|
} from "@/app/seo";
|
||||||
|
|
||||||
const uiFont = Mulish({
|
const uiFont = Mulish({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-ui",
|
variable: "--font-ui",
|
||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
const displayFont = Oswald({
|
const displayFont = Oswald({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-display",
|
variable: "--font-display",
|
||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(SITE_URL),
|
metadataBase: new URL(SITE_URL),
|
||||||
title: {
|
title: {
|
||||||
default: "TeeOff.no - Din guide til norske golfbaner",
|
default: "TeeOff.no - Komplett oversikt over ALLE norske golfbaner",
|
||||||
template: "%s | TeeOff.no",
|
template: "%s | TeeOff.no",
|
||||||
},
|
},
|
||||||
description: DEFAULT_DESCRIPTION,
|
description: DEFAULT_DESCRIPTION,
|
||||||
applicationName: "TeeOff",
|
applicationName: "TeeOff",
|
||||||
|
manifest: "/site.webmanifest",
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: "/",
|
canonical: "/",
|
||||||
},
|
},
|
||||||
|
|
@ -37,7 +38,7 @@ export const metadata: Metadata = {
|
||||||
type: "website",
|
type: "website",
|
||||||
locale: "nb_NO",
|
locale: "nb_NO",
|
||||||
siteName: "TeeOff",
|
siteName: "TeeOff",
|
||||||
title: "TeeOff.no - Din guide til norske golfbaner",
|
title: "TeeOff.no - Komplett oversikt over ALLE norske golfbaner",
|
||||||
description: DEFAULT_DESCRIPTION,
|
description: DEFAULT_DESCRIPTION,
|
||||||
url: SITE_URL,
|
url: SITE_URL,
|
||||||
images: [
|
images: [
|
||||||
|
|
@ -53,7 +54,7 @@ export const metadata: Metadata = {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
site: "@TeeOffno",
|
site: "@TeeOffno",
|
||||||
creator: "@TeeOffno",
|
creator: "@TeeOffno",
|
||||||
title: "TeeOff.no - Din guide til norske golfbaner",
|
title: "TeeOff.no - Komplett oversikt over ALLE norske golfbaner",
|
||||||
description: DEFAULT_DESCRIPTION,
|
description: DEFAULT_DESCRIPTION,
|
||||||
images: [DEFAULT_OG_IMAGE],
|
images: [DEFAULT_OG_IMAGE],
|
||||||
},
|
},
|
||||||
|
|
@ -63,19 +64,22 @@ export const metadata: Metadata = {
|
||||||
googleBot: {
|
googleBot: {
|
||||||
index: true,
|
index: true,
|
||||||
follow: true,
|
follow: true,
|
||||||
maxSnippet: -1,
|
'max-snippet': -1,
|
||||||
maxImagePreview: "large",
|
'max-image-preview': "large",
|
||||||
maxVideoPreview: -1,
|
'max-video-preview': -1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/icons/cropped-siteicon-1.png",
|
icon: [
|
||||||
shortcut: "/icons/cropped-siteicon-1.png",
|
{ url: "/android-chrome-192x192.png", type: "image/png", sizes: "192x192" },
|
||||||
apple: "/icons/cropped-siteicon-1.png",
|
{ url: "/android-chrome-512x512.png", type: "image/png", sizes: "512x512" },
|
||||||
|
],
|
||||||
|
shortcut: [{ url: "/android-chrome-192x192.png", type: "image/png" }],
|
||||||
|
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
const organizationJsonLd = createOrganizationJsonLd();
|
const organizationJsonLd = createOrganizationJsonLd();
|
||||||
const websiteJsonLd = createWebsiteJsonLd();
|
const websiteJsonLd = createWebsiteJsonLd();
|
||||||
|
|
||||||
|
|
@ -95,4 +99,4 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export function stripHtml(value: string | null | undefined) {
|
||||||
export function trimDescription(value: string | null | undefined, maxLength = 160) {
|
export function trimDescription(value: string | null | undefined, maxLength = 160) {
|
||||||
const plain = stripHtml(value);
|
const plain = stripHtml(value);
|
||||||
if (plain.length <= maxLength) return plain;
|
if (plain.length <= maxLength) return plain;
|
||||||
return `${plain.slice(0, maxLength - 1).trimEnd()}…`;
|
return `${plain.slice(0, maxLength - 3).trimEnd()}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPageMetadata({
|
export function createPageMetadata({
|
||||||
|
|
@ -173,7 +173,9 @@ export function createCollectionPageJsonLd({ name, description, path }: Collecti
|
||||||
|
|
||||||
export function createFacilityJsonLd(facility: FacilitySeoRecord) {
|
export function createFacilityJsonLd(facility: FacilitySeoRecord) {
|
||||||
const socialLinks = parseJson<SocialLink[]>(facility.social_links, []);
|
const socialLinks = parseJson<SocialLink[]>(facility.social_links, []);
|
||||||
const sameAs = [facility.website_url, ...socialLinks.map((entry) => entry?.url || null)].filter(Boolean);
|
const sameAs = [facility.website_url, ...socialLinks.map((entry) => entry?.url || null)].filter(
|
||||||
|
(value): value is string => Boolean(value),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
|
|
@ -212,6 +214,148 @@ export function createFacilityJsonLd(facility: FacilitySeoRecord) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VtgDateRecord = {
|
||||||
|
dato?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createVtgCourseJsonLd(facility: FacilitySeoRecord) {
|
||||||
|
const dates = parseJson<VtgDateRecord[]>(facility.vtg_datoer, [])
|
||||||
|
.map((entry) => ({
|
||||||
|
raw: stripHtml(entry?.dato || ""),
|
||||||
|
status: stripHtml(entry?.status || ""),
|
||||||
|
comparableDate: parseComparableDate(entry?.dato || ""),
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.raw && entry.comparableDate);
|
||||||
|
|
||||||
|
if (
|
||||||
|
dates.length === 0 &&
|
||||||
|
!facility.vtg_pris &&
|
||||||
|
!facility.vtg_beskrivelse &&
|
||||||
|
!facility.vtg_lenke
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Course",
|
||||||
|
"@id": buildAbsoluteUrl(`/golfbaner/${facility.slug}#vtg-course`),
|
||||||
|
name: `Veien til Golf hos ${facility.name}`,
|
||||||
|
description:
|
||||||
|
trimDescription(facility.vtg_beskrivelse) ||
|
||||||
|
`Nybegynnerkurs i golf hos ${facility.name}, samlet og strukturert av TeeOff.`,
|
||||||
|
provider: {
|
||||||
|
"@type": "SportsActivityLocation",
|
||||||
|
name: facility.name,
|
||||||
|
url: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
|
||||||
|
},
|
||||||
|
hasCourseInstance: dates.map((entry, index) => ({
|
||||||
|
"@type": "CourseInstance",
|
||||||
|
"@id": buildAbsoluteUrl(`/golfbaner/${facility.slug}#vtg-instance-${index + 1}`),
|
||||||
|
courseMode: "onsite",
|
||||||
|
startDate: entry.comparableDate?.toISOString(),
|
||||||
|
location: {
|
||||||
|
"@type": "GolfCourse",
|
||||||
|
name: facility.name,
|
||||||
|
url: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
|
||||||
|
},
|
||||||
|
offers:
|
||||||
|
typeof facility.vtg_pris === "number"
|
||||||
|
? {
|
||||||
|
"@type": "Offer",
|
||||||
|
price: facility.vtg_pris,
|
||||||
|
priceCurrency: "NOK",
|
||||||
|
url: facility.vtg_lenke || buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
|
||||||
|
availability:
|
||||||
|
entry.status.toLowerCase().includes("full")
|
||||||
|
? "https://schema.org/SoldOut"
|
||||||
|
: "https://schema.org/InStock",
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseComparableDate(raw: string) {
|
||||||
|
const trimmed = String(raw || "").trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const isoCandidate = new Date(trimmed);
|
||||||
|
if (!Number.isNaN(isoCandidate.getTime())) {
|
||||||
|
isoCandidate.setHours(0, 0, 0, 0);
|
||||||
|
return isoCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericDateMatch = trimmed.match(/(\d{1,2})[./](\d{1,2})[./](\d{2,4})/);
|
||||||
|
if (numericDateMatch) {
|
||||||
|
const day = Number(numericDateMatch[1]);
|
||||||
|
const month = Number(numericDateMatch[2]) - 1;
|
||||||
|
const yearValue = Number(numericDateMatch[3]);
|
||||||
|
const year = yearValue < 100 ? 2000 + yearValue : yearValue;
|
||||||
|
const parsed = new Date(year, month, day);
|
||||||
|
parsed.setHours(0, 0, 0, 0);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = trimmed
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[.,]/g, " ")
|
||||||
|
.replace(/\s+/g, " ");
|
||||||
|
|
||||||
|
const monthMap: Record<string, number> = {
|
||||||
|
januar: 0,
|
||||||
|
jan: 0,
|
||||||
|
februar: 1,
|
||||||
|
feb: 1,
|
||||||
|
mars: 2,
|
||||||
|
mar: 2,
|
||||||
|
april: 3,
|
||||||
|
apr: 3,
|
||||||
|
mai: 4,
|
||||||
|
juni: 5,
|
||||||
|
jun: 5,
|
||||||
|
juli: 6,
|
||||||
|
jul: 6,
|
||||||
|
august: 7,
|
||||||
|
aug: 7,
|
||||||
|
september: 8,
|
||||||
|
sep: 8,
|
||||||
|
sept: 8,
|
||||||
|
oktober: 9,
|
||||||
|
okt: 9,
|
||||||
|
november: 10,
|
||||||
|
nov: 10,
|
||||||
|
desember: 11,
|
||||||
|
des: 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthToken = Object.keys(monthMap).find((monthName) => normalized.includes(monthName));
|
||||||
|
if (!monthToken) return null;
|
||||||
|
|
||||||
|
const dayMatch = normalized.match(/(\d{1,2})/);
|
||||||
|
if (!dayMatch) return null;
|
||||||
|
|
||||||
|
const explicitYearMatch = normalized.match(/\b(20\d{2})\b/);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
let year = explicitYearMatch ? Number(explicitYearMatch[1]) : today.getFullYear();
|
||||||
|
const day = Number(dayMatch[1]);
|
||||||
|
const monthIndex = monthMap[monthToken];
|
||||||
|
|
||||||
|
let parsed = new Date(year, monthIndex, day);
|
||||||
|
parsed.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (!explicitYearMatch && parsed.getTime() < today.getTime() - 7 * 24 * 60 * 60 * 1000) {
|
||||||
|
year += 1;
|
||||||
|
parsed = new Date(year, monthIndex, day);
|
||||||
|
parsed.setHours(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
function parseJson<T>(value: unknown, fallback: T): T {
|
function parseJson<T>(value: unknown, fallback: T): T {
|
||||||
if (!value) return fallback;
|
if (!value) return fallback;
|
||||||
if (typeof value === "object") return value as T;
|
if (typeof value === "object") return value as T;
|
||||||
|
|
|
||||||