Etter favicon, før tilleggssider
|
|
@ -39,7 +39,7 @@ services:
|
|||
build: ./frontend
|
||||
container_name: teeoff_frontend
|
||||
# NY LINJE: Tvinger produksjonsmodus for å stoppe WebSocket-feil og relasting
|
||||
command: sh -c "npm run build && npm start"
|
||||
command: npm start
|
||||
ports:
|
||||
- "3000:3000"
|
||||
# VIKTIG: Jeg har fjernet "- ./frontend:/app" her for å sikre stabilitet
|
||||
|
|
|
|||
|
|
@ -9,5 +9,8 @@ RUN npm install
|
|||
# Kopier resten av koden
|
||||
COPY . .
|
||||
|
||||
# Vi starter IKKE serveren i "dev"-modus (utviklingsmodus).
|
||||
CMD ["sh", "-c", "npm run build && npm start"]
|
||||
# BYGG koden her (kjøres jyb én gang når imaget bygges
|
||||
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 : "#";
|
||||
};
|
||||
|
||||
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 source = String(value || "").replace(/\r\n?/g, "\n");
|
||||
if (!source.trim()) return "";
|
||||
|
|
@ -262,7 +275,10 @@ const sanitizeRichText = (value: string | null | undefined) => {
|
|||
.replace(/<\s*a\b([^>]*)>/gi, (_, attrs: string) => {
|
||||
const hrefMatch = attrs.match(/href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
||||
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>"));
|
||||
|
||||
|
|
|
|||
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 : "#";
|
||||
};
|
||||
|
||||
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 source = String(value || "").replace(/\r\n?/g, "\n");
|
||||
if (!source.trim()) return "";
|
||||
|
|
@ -74,7 +87,10 @@ const sanitizeRichText = (value: string | null | undefined) => {
|
|||
.replace(/<\s*a\b([^>]*)>/gi, (_, attrs: string) => {
|
||||
const hrefMatch = attrs.match(/href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
||||
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>"));
|
||||
|
||||
|
|
|
|||
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,
|
||||
createFacilityJsonLd,
|
||||
createPageMetadata,
|
||||
createVtgCourseJsonLd,
|
||||
trimDescription,
|
||||
} from "@/app/seo";
|
||||
import FacilityDetailView from "./FacilityDetailView";
|
||||
|
|
@ -37,7 +38,7 @@ export async function generateMetadata({ params }: GolfCoursePageProps): Promise
|
|||
title,
|
||||
description: trimDescription(facility.description) || fallbackDescription,
|
||||
path: `/golfbaner/${slug}`,
|
||||
image: facility.image_url,
|
||||
image: `/golfbaner/${slug}/opengraph-image`,
|
||||
});
|
||||
} catch {
|
||||
return createPageMetadata({
|
||||
|
|
@ -57,6 +58,7 @@ export default async function GolfCoursePage({ params }: GolfCoursePageProps) {
|
|||
}
|
||||
|
||||
const facilityJsonLd = createFacilityJsonLd(facility);
|
||||
const vtgCourseJsonLd = createVtgCourseJsonLd(facility);
|
||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||
{ name: "Hjem", path: "/" },
|
||||
{ name: "Golfbaner", path: "/golfbaner" },
|
||||
|
|
@ -69,6 +71,12 @@ export default async function GolfCoursePage({ params }: GolfCoursePageProps) {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(facilityJsonLd) }}
|
||||
/>
|
||||
{vtgCourseJsonLd ? (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(vtgCourseJsonLd) }}
|
||||
/>
|
||||
) : null}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
|
|
|
|||
|
|
@ -1,98 +1,102 @@
|
|||
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";
|
||||
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"],
|
||||
variable: "--font-ui",
|
||||
display: "swap",
|
||||
});
|
||||
const uiFont = Mulish({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-ui",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const displayFont = Oswald({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-display",
|
||||
display: "swap",
|
||||
});
|
||||
const displayFont = Oswald({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-display",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
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",
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: "TeeOff.no - Komplett oversikt over ALLE norske golfbaner",
|
||||
template: "%s | TeeOff.no",
|
||||
},
|
||||
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: {
|
||||
applicationName: "TeeOff",
|
||||
manifest: "/site.webmanifest",
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "nb_NO",
|
||||
siteName: "TeeOff",
|
||||
title: "TeeOff.no - Komplett oversikt over ALLE 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 - Komplett oversikt over ALLE norske golfbaner",
|
||||
description: DEFAULT_DESCRIPTION,
|
||||
images: [DEFAULT_OG_IMAGE],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
maxSnippet: -1,
|
||||
maxImagePreview: "large",
|
||||
maxVideoPreview: -1,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-snippet': -1,
|
||||
'max-image-preview': "large",
|
||||
'max-video-preview': -1,
|
||||
},
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: "/icons/cropped-siteicon-1.png",
|
||||
shortcut: "/icons/cropped-siteicon-1.png",
|
||||
apple: "/icons/cropped-siteicon-1.png",
|
||||
},
|
||||
};
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/android-chrome-192x192.png", type: "image/png", sizes: "192x192" },
|
||||
{ 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 }) {
|
||||
const organizationJsonLd = createOrganizationJsonLd();
|
||||
const websiteJsonLd = createWebsiteJsonLd();
|
||||
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>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
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>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function stripHtml(value: string | null | undefined) {
|
|||
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()}…`;
|
||||
return `${plain.slice(0, maxLength - 3).trimEnd()}...`;
|
||||
}
|
||||
|
||||
export function createPageMetadata({
|
||||
|
|
@ -173,7 +173,9 @@ export function createCollectionPageJsonLd({ name, description, path }: Collecti
|
|||
|
||||
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);
|
||||
const sameAs = [facility.website_url, ...socialLinks.map((entry) => entry?.url || null)].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
|
||||
return {
|
||||
"@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 {
|
||||
if (!value) return fallback;
|
||||
if (typeof value === "object") return value as T;
|
||||
|
|
|
|||