432 lines
12 KiB
TypeScript
432 lines
12 KiB
TypeScript
import { API_URL } from "@/config/constants";
|
|
import importedMeninger from "@/content/importedMeninger.json";
|
|
|
|
export type CourseVisitImage = {
|
|
src: string;
|
|
alt: string;
|
|
caption: string;
|
|
};
|
|
|
|
export type CourseVisitFact = {
|
|
label: string;
|
|
value: string;
|
|
href?: string;
|
|
};
|
|
|
|
export type CourseVisitBodyBlock =
|
|
| {
|
|
type: "richText";
|
|
title?: string;
|
|
html: string;
|
|
}
|
|
| {
|
|
type: "quote";
|
|
quote: string;
|
|
attribution?: string;
|
|
}
|
|
| {
|
|
type: "checklist";
|
|
title: string;
|
|
items: string[];
|
|
}
|
|
| {
|
|
type: "factGrid";
|
|
title: string;
|
|
items: CourseVisitFact[];
|
|
}
|
|
| {
|
|
type: "callout";
|
|
title: string;
|
|
body: string;
|
|
};
|
|
|
|
export type CourseVisitArticle = {
|
|
slug: string;
|
|
eyebrow: string;
|
|
title: string;
|
|
description: string;
|
|
excerpt: string;
|
|
locationLabel: string;
|
|
facilityName: string;
|
|
facilitySlug: string;
|
|
publishedAt: string;
|
|
updatedAt?: string;
|
|
readingTime: string;
|
|
heroImages: CourseVisitImage[];
|
|
quickFacts: CourseVisitFact[];
|
|
highlights: string[];
|
|
blocks: CourseVisitBodyBlock[];
|
|
sourceUrl?: string;
|
|
sourceLabel?: string;
|
|
};
|
|
|
|
type ImportedMeningerRecord = {
|
|
id: number;
|
|
slug: string;
|
|
title: string;
|
|
excerpt: string;
|
|
contentHtml: string;
|
|
publishedAt: string;
|
|
updatedAt?: string;
|
|
link?: string;
|
|
author?: {
|
|
name?: string | null;
|
|
};
|
|
featuredImage?: {
|
|
url?: string | null;
|
|
alt?: string | null;
|
|
caption?: string | null;
|
|
} | null;
|
|
facilitySlugs?: string[];
|
|
primaryFacilitySlug?: string | null;
|
|
};
|
|
|
|
type CourseVisitApiRecord = {
|
|
id?: number;
|
|
slug: string;
|
|
title: string;
|
|
description?: string | null;
|
|
excerpt?: string | null;
|
|
eyebrow?: string | null;
|
|
location_label?: string | null;
|
|
facility_name?: string | null;
|
|
facility_slug?: string | null;
|
|
author_name?: string | null;
|
|
hero_images?: CourseVisitImage[] | null;
|
|
content_html?: string | null;
|
|
source_url?: string | null;
|
|
source_label?: string | null;
|
|
published_at?: string | null;
|
|
updated_at?: string | null;
|
|
};
|
|
|
|
type FacilityMeta = {
|
|
name: string;
|
|
region: string;
|
|
};
|
|
|
|
const facilityMetaBySlug: Record<string, FacilityMeta> = {
|
|
"lofoten-golfklubb": { name: "Lofoten Golfklubb", region: "Nordland" },
|
|
"kjekstad-golfklubb": { name: "Kjekstad Golfklubb", region: "Buskerud" },
|
|
"kragero-golfklubb": { name: "Kragerø Golfklubb", region: "Telemark" },
|
|
"egersund-golfklubb": { name: "Egersund Golfklubb", region: "Rogaland" },
|
|
"tyrifjord-golfklubb": { name: "Tyrifjord Golfklubb", region: "Buskerud" },
|
|
"kongsvingers-golfklubb": { name: "Kongsvingers Golfklubb", region: "Innlandet" },
|
|
"drammen-golfklubb": { name: "Drammen Golfklubb", region: "Buskerud" },
|
|
};
|
|
|
|
const teeoffInternalLinkPattern = /https?:\/\/teeoff\.no\/([^"'#?\s>]+)/gi;
|
|
const imageTagPattern = /<img\b[^>]*\bsrc=['"]([^'"]+)['"][^>]*\balt=['"]([^'"]*)['"][^>]*>/gi;
|
|
const imageTagWithoutAltPattern = /<img\b[^>]*\bsrc=['"]([^'"]+)['"][^>]*>/gi;
|
|
const disallowedSegments = new Set(["wp-content", "wp-json", "meninger", "category", "author", "tag", "feed"]);
|
|
|
|
function decodeEntities(value: string) {
|
|
return value
|
|
.replace(/…/g, "...")
|
|
.replace(/…/g, "...")
|
|
.replace(/ /g, " ")
|
|
.replace(/«/g, "«")
|
|
.replace(/»/g, "»")
|
|
.replace(/&/g, "&")
|
|
.replace(/&/g, "&");
|
|
}
|
|
|
|
function stripHtml(value: string) {
|
|
return decodeEntities(value).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function formatDate(value: string) {
|
|
return new Intl.DateTimeFormat("nb-NO", {
|
|
day: "numeric",
|
|
month: "long",
|
|
year: "numeric",
|
|
}).format(new Date(value));
|
|
}
|
|
|
|
function getReadingTime(html: string) {
|
|
const wordCount = stripHtml(html).split(/\s+/).filter(Boolean).length;
|
|
const minutes = Math.max(3, Math.round(wordCount / 220));
|
|
return `${minutes} min`;
|
|
}
|
|
|
|
function getFacilityMeta(slug: string) {
|
|
return facilityMetaBySlug[slug] || {
|
|
name: slug
|
|
.split("-")
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(" "),
|
|
region: "Norge",
|
|
};
|
|
}
|
|
|
|
function normalizeInternalLinks(html: string) {
|
|
return html.replace(teeoffInternalLinkPattern, (fullMatch, rawPath: string) => {
|
|
const path = rawPath.split("?")[0].replace(/\/+$/, "");
|
|
const segments = path.split("/").filter(Boolean);
|
|
if (segments.length === 0 || disallowedSegments.has(segments[0])) {
|
|
return fullMatch;
|
|
}
|
|
|
|
const candidate = segments[segments.length - 1];
|
|
if (!candidate.includes("golf")) {
|
|
return fullMatch;
|
|
}
|
|
|
|
return `/golfbaner/${candidate}`;
|
|
});
|
|
}
|
|
|
|
function extractImagesFromHtml(html: string, articleTitle: string) {
|
|
const images: CourseVisitImage[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
for (const match of html.matchAll(imageTagPattern)) {
|
|
const src = match[1];
|
|
const alt = decodeEntities(match[2] || "").trim();
|
|
if (!src || seen.has(src) || (!src.includes("/wp-content/uploads/") && !src.includes("i.ytimg.com"))) {
|
|
continue;
|
|
}
|
|
seen.add(src);
|
|
images.push({
|
|
src,
|
|
alt: alt || articleTitle,
|
|
caption: alt || articleTitle,
|
|
});
|
|
}
|
|
|
|
if (images.length === 0) {
|
|
for (const match of html.matchAll(imageTagWithoutAltPattern)) {
|
|
const src = match[1];
|
|
if (!src || seen.has(src) || !src.includes("/wp-content/uploads/")) {
|
|
continue;
|
|
}
|
|
seen.add(src);
|
|
images.push({
|
|
src,
|
|
alt: articleTitle,
|
|
caption: articleTitle,
|
|
});
|
|
}
|
|
}
|
|
|
|
return images;
|
|
}
|
|
|
|
function mapImportedArticle(entry: ImportedMeningerRecord): CourseVisitArticle | null {
|
|
const facilitySlug = entry.primaryFacilitySlug || entry.facilitySlugs?.[0];
|
|
if (!facilitySlug) {
|
|
return null;
|
|
}
|
|
|
|
const facilityMeta = getFacilityMeta(facilitySlug);
|
|
const normalizedHtml = normalizeInternalLinks(entry.contentHtml || "");
|
|
const extractedImages = extractImagesFromHtml(normalizedHtml, entry.title);
|
|
const featuredImage = entry.featuredImage?.url
|
|
? [
|
|
{
|
|
src: entry.featuredImage.url,
|
|
alt: entry.featuredImage.alt || entry.title,
|
|
caption: entry.featuredImage.caption || entry.title,
|
|
},
|
|
]
|
|
: [];
|
|
|
|
const heroImages = [...featuredImage, ...extractedImages]
|
|
.filter((image, index, list) => list.findIndex((candidate) => candidate.src === image.src) === index)
|
|
.slice(0, 6);
|
|
|
|
const excerpt = entry.excerpt || stripHtml(normalizedHtml).slice(0, 220);
|
|
const formattedPublishedAt = formatDate(entry.publishedAt);
|
|
|
|
return {
|
|
slug: entry.slug,
|
|
eyebrow: "Banebesøk",
|
|
title: entry.title,
|
|
description: excerpt,
|
|
excerpt,
|
|
locationLabel: facilityMeta.region,
|
|
facilityName: facilityMeta.name,
|
|
facilitySlug,
|
|
publishedAt: entry.publishedAt,
|
|
updatedAt: entry.updatedAt,
|
|
readingTime: getReadingTime(normalizedHtml),
|
|
heroImages:
|
|
heroImages.length > 0
|
|
? heroImages
|
|
: [
|
|
{
|
|
src: "/Toppbilde-standard.jpg",
|
|
alt: entry.title,
|
|
caption: entry.title,
|
|
},
|
|
],
|
|
quickFacts: [
|
|
{
|
|
label: "Baneprofil",
|
|
value: facilityMeta.name,
|
|
href: `/golfbaner/${facilitySlug}`,
|
|
},
|
|
{
|
|
label: "Publisert",
|
|
value: formattedPublishedAt,
|
|
},
|
|
{
|
|
label: "Forfatter",
|
|
value: entry.author?.name || "TeeOff",
|
|
},
|
|
{
|
|
label: "Kildespor",
|
|
value: "Importert fra gamle TeeOff",
|
|
},
|
|
],
|
|
highlights: [
|
|
"Originalartikkel importert fra gamle TeeOff.",
|
|
`Koblet til dagens baneprofil for ${facilityMeta.name}.`,
|
|
"Bevarer originaltekst, originale bilder og langlesingsformat.",
|
|
"Kan senere flyttes til database eller editor uten å kaste artikkel-UI-et.",
|
|
],
|
|
blocks: [
|
|
{
|
|
type: "richText",
|
|
title: "Original artikkel",
|
|
html: normalizedHtml,
|
|
},
|
|
],
|
|
sourceUrl: entry.link,
|
|
sourceLabel: "Importert fra gamle TeeOff",
|
|
};
|
|
}
|
|
|
|
function mapApiArticle(entry: CourseVisitApiRecord): CourseVisitArticle | null {
|
|
const facilitySlug = String(entry.facility_slug || "").trim();
|
|
if (!facilitySlug) {
|
|
return null;
|
|
}
|
|
|
|
const facilityMeta = getFacilityMeta(facilitySlug);
|
|
const facilityName = String(entry.facility_name || "").trim() || facilityMeta.name;
|
|
const locationLabel = String(entry.location_label || "").trim() || facilityMeta.region;
|
|
const normalizedHtml = normalizeInternalLinks(String(entry.content_html || ""));
|
|
const extractedImages = extractImagesFromHtml(normalizedHtml, entry.title);
|
|
const dbImages = Array.isArray(entry.hero_images) ? entry.hero_images : [];
|
|
const heroImages = [...dbImages, ...extractedImages]
|
|
.filter((image): image is CourseVisitImage => Boolean(image?.src))
|
|
.map((image) => ({
|
|
src: image.src,
|
|
alt: image.alt || entry.title,
|
|
caption: image.caption || image.alt || entry.title,
|
|
}))
|
|
.filter((image, index, list) => list.findIndex((candidate) => candidate.src === image.src) === index)
|
|
.slice(0, 6);
|
|
|
|
const publishedAt = String(entry.published_at || entry.updated_at || "");
|
|
const excerpt =
|
|
String(entry.excerpt || "").trim() ||
|
|
String(entry.description || "").trim() ||
|
|
stripHtml(normalizedHtml).slice(0, 220);
|
|
|
|
return {
|
|
slug: entry.slug,
|
|
eyebrow: String(entry.eyebrow || "").trim() || "Banebesøk",
|
|
title: entry.title,
|
|
description: String(entry.description || "").trim() || excerpt,
|
|
excerpt,
|
|
locationLabel,
|
|
facilityName,
|
|
facilitySlug,
|
|
publishedAt,
|
|
updatedAt: String(entry.updated_at || "").trim() || undefined,
|
|
readingTime: getReadingTime(normalizedHtml),
|
|
heroImages:
|
|
heroImages.length > 0
|
|
? heroImages
|
|
: [
|
|
{
|
|
src: "/Toppbilde-standard.jpg",
|
|
alt: entry.title,
|
|
caption: entry.title,
|
|
},
|
|
],
|
|
quickFacts: [
|
|
{
|
|
label: "Baneprofil",
|
|
value: facilityName,
|
|
href: `/golfbaner/${facilitySlug}`,
|
|
},
|
|
{
|
|
label: "Publisert",
|
|
value: publishedAt ? formatDate(publishedAt) : "Ikke datert",
|
|
},
|
|
{
|
|
label: "Forfatter",
|
|
value: String(entry.author_name || "").trim() || "TeeOff",
|
|
},
|
|
...(entry.source_label
|
|
? [
|
|
{
|
|
label: "Kildespor",
|
|
value: String(entry.source_label),
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
highlights: [
|
|
`Koblet til dagens baneprofil for ${facilityName}.`,
|
|
"Lagret som redaksjonell artikkel i TeeOff-admin.",
|
|
"Kan redigeres videre som HTML uten å miste artikkeloppsettet.",
|
|
"Beholder mobilvennlig hero, faktaboks og langlesingsstruktur.",
|
|
],
|
|
blocks: [
|
|
{
|
|
type: "richText",
|
|
title: "Artikkel",
|
|
html: normalizedHtml,
|
|
},
|
|
],
|
|
sourceUrl: String(entry.source_url || "").trim() || undefined,
|
|
sourceLabel: String(entry.source_label || "").trim() || undefined,
|
|
};
|
|
}
|
|
|
|
const fallbackCourseVisits = (importedMeninger as ImportedMeningerRecord[])
|
|
.map(mapImportedArticle)
|
|
.filter((article): article is CourseVisitArticle => Boolean(article))
|
|
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
|
|
|
|
export async function getCourseVisits() {
|
|
try {
|
|
const response = await fetch(`${API_URL}/course-visits`, { cache: "no-store" });
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (Array.isArray(data)) {
|
|
const mapped = data
|
|
.map((entry) => mapApiArticle(entry as CourseVisitApiRecord))
|
|
.filter((article): article is CourseVisitArticle => Boolean(article));
|
|
if (mapped.length > 0) {
|
|
return mapped;
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Faller tilbake til importerte artikler dersom DB/API ikke er klar.
|
|
}
|
|
|
|
return fallbackCourseVisits;
|
|
}
|
|
|
|
export async function getCourseVisitBySlug(slug: string) {
|
|
try {
|
|
const response = await fetch(`${API_URL}/course-visits/${slug}`, { cache: "no-store" });
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const mapped = mapApiArticle(data as CourseVisitApiRecord);
|
|
if (mapped) {
|
|
return mapped;
|
|
}
|
|
}
|
|
} catch {
|
|
// Faller tilbake til importerte artikler dersom DB/API ikke er klar.
|
|
}
|
|
|
|
return fallbackCourseVisits.find((article) => article.slug === slug);
|
|
}
|