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 = { "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 = /]*\bsrc=['"]([^'"]+)['"][^>]*\balt=['"]([^'"]*)['"][^>]*>/gi; const imageTagWithoutAltPattern = /]*\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(); 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); }