Etter favicon, før tilleggssider

This commit is contained in:
Erol 2026-04-13 13:18:25 +02:00
parent 3bf429a5f7
commit 94afef6f33
16 changed files with 421 additions and 96 deletions

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View 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"}

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View file

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

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

View file

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

View file

@ -1,98 +1,102 @@
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,
applicationName: "TeeOff",
alternates: {
canonical: "/",
},
openGraph: {
type: "website",
locale: "nb_NO",
siteName: "TeeOff",
title: "TeeOff.no - Din guide til norske golfbaner",
description: DEFAULT_DESCRIPTION, description: DEFAULT_DESCRIPTION,
url: SITE_URL, applicationName: "TeeOff",
images: [ manifest: "/site.webmanifest",
{ alternates: {
url: DEFAULT_OG_IMAGE, canonical: "/",
width: 1600, },
height: 900, openGraph: {
alt: "TeeOff.no", type: "website",
}, locale: "nb_NO",
], siteName: "TeeOff",
}, title: "TeeOff.no - Komplett oversikt over ALLE norske golfbaner",
twitter: { description: DEFAULT_DESCRIPTION,
card: "summary_large_image", url: SITE_URL,
site: "@TeeOffno", images: [
creator: "@TeeOffno", {
title: "TeeOff.no - Din guide til norske golfbaner", url: DEFAULT_OG_IMAGE,
description: DEFAULT_DESCRIPTION, width: 1600,
images: [DEFAULT_OG_IMAGE], height: 900,
}, alt: "TeeOff.no",
robots: { },
index: true, ],
follow: true, },
googleBot: { twitter: {
card: "summary_large_image",
site: "@TeeOffno",
creator: "@TeeOffno",
title: "TeeOff.no - Komplett oversikt over ALLE norske golfbaner",
description: DEFAULT_DESCRIPTION,
images: [DEFAULT_OG_IMAGE],
},
robots: {
index: true, index: true,
follow: true, follow: true,
maxSnippet: -1, googleBot: {
maxImagePreview: "large", index: true,
maxVideoPreview: -1, follow: true,
'max-snippet': -1,
'max-image-preview': "large",
'max-video-preview': -1,
},
}, },
}, icons: {
icons: { icon: [
icon: "/icons/cropped-siteicon-1.png", { url: "/android-chrome-192x192.png", type: "image/png", sizes: "192x192" },
shortcut: "/icons/cropped-siteicon-1.png", { url: "/android-chrome-512x512.png", type: "image/png", sizes: "512x512" },
apple: "/icons/cropped-siteicon-1.png", ],
}, 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();
return ( return (
<html lang="nb"> <html lang="nb">
<body className={`${uiFont.variable} ${displayFont.variable} antialiased`}> <body className={`${uiFont.variable} ${displayFont.variable} antialiased`}>
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }}
/> />
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
/> />
<Header /> <Header />
{children} {children}
</body> </body>
</html> </html>
); );
} }

View file

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