import { useMemo, type ReactNode } from "react"; import { documentToReactComponents, type Options, } from "@contentful/rich-text-react-renderer"; import { BLOCKS, INLINES, MARKS, type Document, } from "@contentful/rich-text-types"; import { Checklist } from "@shared/components/checklist"; import { Link } from "@shared/components/link"; import { MaterialIcon } from "@shared/components/material-icon"; import { Text } from "@shared/components/text"; import { toDocument } from "@shared/utils/contentful/to-document"; const isEmptyParagraph = (node: any): boolean => Array.isArray(node?.content) && node.content.every( (child: any) => child?.nodeType === "text" && (!child.value || child.value.trim() === "") ); // A link is "external" only when it belongs to a different app than the one // currently rendering this shared component (business vs. main/www are separate // apps). Matching is environment-agnostic so a prod link like // business.gokinetic.com is still "internal" while running on business-dev. const hostFromUrl = (value?: string): string => { if (!value) return ""; try { const withProtocol = /^https?:\/\//i.test(value) ? value : `https://${value}`; return new URL(withProtocol).hostname.toLowerCase(); } catch { return ""; } }; // e.g. "business-dev.gokinetic.com" -> "gokinetic.com|business", // "kinetic-dev.gokinetic.com" / "www.gokinetic.com" -> "gokinetic.com|main" const appIdentity = (host: string): string => { const clean = host.toLowerCase().replace(/\.$/, ""); const base = clean.split(".").slice(-2).join("."); const subdomain = clean .slice(0, clean.length - base.length) .replace(/\.$/, ""); const firstLabel = (subdomain.split(".")[0] || "").replace( /-(dev|development|staging|stage|qa|test|uat|prod|production)$/, "" ); const app = firstLabel === "" || firstLabel === "www" || firstLabel === "kinetic" ? "main" : firstLabel; return `${base}|${app}`; }; const getCurrentHost = (): string => { const envHost = hostFromUrl(process.env.NEXT_PUBLIC_SITE_URL) || hostFromUrl(process.env.NEXT_PUBLIC_MAIN_SITE_URL); if (envHost) return envHost; if (typeof window !== "undefined") return window.location.hostname.toLowerCase(); return ""; }; const isExternalUrl = (url: string): boolean => { if (!/^https?:\/\//i.test(url)) return false; // relative links are always internal try { const linkHost = new URL(url).hostname.toLowerCase(); const currentHost = getCurrentHost(); if (!currentHost) return true; // host unknown (SSR without env) → treat as external return appIdentity(linkHost) !== appIdentity(currentHost); } catch { return false; } }; const defaultOptions: Options = { renderMark: { [MARKS.BOLD]: text => {text}, [MARKS.ITALIC]: text => {text}, [MARKS.UNDERLINE]: text => {text}, [MARKS.CODE]: text => {text}, }, renderNode: { [BLOCKS.PARAGRAPH]: (node, children) => (
{isEmptyParagraph(node) ? "\n" : children}
), [BLOCKS.HEADING_1]: (node, children) => { return ( {children} ); }, [BLOCKS.HEADING_2]: (node, children) => ( {children} ), [BLOCKS.HEADING_3]: (node, children) => ( {children} ), [BLOCKS.HEADING_4]: (node, children) => ( {children} ), [BLOCKS.HEADING_5]: (node, children) => ( {children} ), [BLOCKS.HEADING_6]: (node, children) => ( {children} ), [BLOCKS.QUOTE]: (_node, children) =>
{children}
, [BLOCKS.UL_LIST]: (_node, children) => ( ), [BLOCKS.OL_LIST]: (_node, children) => (
    {children}
), [BLOCKS.LIST_ITEM]: (_node, children) =>
  • {children}
  • , [INLINES.HYPERLINK]: (node, children) => { const url = (node as any)?.data?.uri as string; const external = isExternalUrl(url); return ( {children} ); }, [BLOCKS.EMBEDDED_ASSET]: node => { const target = (node as any)?.data?.target; const fields = target?.fields; const url = target?.url || fields?.file?.url; const alt = fields?.title || fields?.description || "Embedded asset"; if (!url) return null; const src = url.startsWith("//") ? `https:${url}` : url; return {alt}; }, [BLOCKS.EMBEDDED_ENTRY]: node => { const entry = (node as any)?.data?.target; const ctid = entry?.sys?.contentType?.sys?.id; switch (ctid) { case "callout": return ( ); default: return null; } }, [INLINES.EMBEDDED_ENTRY]: node => { const entry = (node as any)?.data?.target; const ctid = entry?.sys?.contentType?.sys?.id; switch (ctid) { case "componentCheckList": return ( ✅ {entry.fields.title} ); default: return {entry?.fields?.title ?? ""}; } }, }, }; /** * Utility function to render Contentful Rich Text. * Safe to use inside .map() loops. */ export function renderContentfulRichText( doc: Document | null | undefined, target?: boolean, className: string = "body1", linkClassName: string = "body1 font-bold", options?: Options ): ReactNode | null { if (!doc || !Array.isArray(doc.content)) return null; const merged: Options = { ...defaultOptions, ...options, renderNode: { ...defaultOptions.renderNode, ...options?.renderNode, // Logic for links based on the isTargetBlank flag [BLOCKS.PARAGRAPH]: (node, children) => (
    {isEmptyParagraph(node) ? "\n" : children}
    ), [INLINES.HYPERLINK]: (node, children) => { const url = (node as any)?.data?.uri as string; const external = isExternalUrl(url); // Priority: 1. Manual flag from Contentful, 2. Same-domain URLs are internal const shouldOpenBlank = target ?? external; return ( {children} ); }, }, }; // Directly return the rendered components without useMemo return documentToReactComponents(doc, merged); } export function useContentfulRichText( doc: Document | null | undefined, options?: Options, config?: { boldClassName?: string } ): ReactNode | null { return useMemo(() => { if (!doc || !Array.isArray(doc.content)) return null; const merged: Options = { ...defaultOptions, renderMark: { ...defaultOptions.renderMark, ...(config?.boldClassName ? { [MARKS.BOLD]: text => ( {text} ), } : {}), ...options?.renderMark, }, renderNode: { ...defaultOptions.renderNode, ...options?.renderNode }, }; return documentToReactComponents(doc, merged); }, [doc, options, config?.boldClassName]); } // 1. Remove useMemo and React Hook imports from this specific function's logic export function renderContentfulRichTextTable( doc: Document | null | undefined, links?: any ) { if (!doc || !Array.isArray(doc.content)) return null; const options: Options = { ...defaultOptions, // Spread defaultOptions so

    and still work! renderMark: { ...defaultOptions.renderMark, [MARKS.BOLD]: text => ( {text} ), }, renderNode: { ...defaultOptions.renderNode, [BLOCKS.PARAGRAPH]: (_node, children) => <>{children}, [BLOCKS.TABLE]: (node: any, children) => (

    2 ? "min-w-[100.1%]" : "min-w-full" }`} > {children}
    ), [BLOCKS.TABLE_ROW]: (node, children) => ( {children} ), [BLOCKS.TABLE_HEADER_CELL]: (node: any, children) => { const isScrollable = node.parent?.content.length > 2; return ( {children} ); }, [BLOCKS.TABLE_CELL]: (node: any, children) => { const isScrollable = node.parent?.content.length > 2; // Logic to check for "yes" or "no" // children[0].props.children is usually where the text sits if it's in a

    tag const rawText = node.content[0]?.content[0]?.value ?.toLowerCase() .trim(); const renderContent = () => { if (rawText === "yes") { return ( ); } if (rawText === "no") { return ; } return children; }; return ( <> {renderContent()} ); }, [INLINES.EMBEDDED_ENTRY]: node => { const entryId = node.data.target.sys.id; const entry = links?.entries?.inline?.find( (e: any) => e.sys.id === entryId ); if (!entry) return null; if (entry.__typename === "ComponentCheckList") { // FIX: Don't use a Hook here. // Map the items manually or use a non-hook helper function. const items = entry.list?.items?.map((item: any) => renderContentfulRichText( toDocument(item?.checkListTitle ?? ""), true, "" ) ) || []; return ( ); } return {entry.title || ""}; }, }, }; return documentToReactComponents(doc, options); }