All files / components/legal LegalDocumentView.tsx

100% Statements 26/26
82.6% Branches 19/23
100% Functions 10/10
100% Lines 26/26

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169              613x               2x                   16x   16x               2x     1214x 1214x   1274x 1244x   30x 16x 16x           14x         1274x                   2x             1543x   472x 472x                           19x             162x                   1052x                                           15x 1543x     15x                                   141x                       1543x        
// src/components/legal/LegalDocumentView.tsx
import type {ReactElement, ReactNode} from 'react';
import {Box, Divider, Link, Typography} from '@mui/material';
import type {LegalBlock, LegalDocument} from '../../content/legal/types';
 
// Anchor slug for a heading so the table of contents can deep-link to it.
function anchorId(text: string): string {
	return text
		.toLowerCase()
		.replace(/[^a-z0-9]+/g, '-')
		.replace(/^-+|-+$/g, '')
		.slice(0, 64);
}
 
/** The site's own domain as it appears in the generated content (FSC-164). */
const SITE_DOMAIN = 'flowstatecapital.ai';
 
/**
 * The site domain to link to, following the environment serving the page.
 *
 * A policy read on test.flowstatecapital.ai should link back to test, not
 * bounce the reader to production. Anywhere else — localhost, a preview host —
 * falls back to the canonical domain the documents were written against.
 */
function currentSiteDomain(): string {
	const {hostname} = window.location;
 
	return hostname === SITE_DOMAIN || hostname.endsWith(`.${SITE_DOMAIN}`)
		? hostname
		: SITE_DOMAIN;
}
 
// Emails, http(s) URLs, bare www. links, and the site's own domain inside a
// run of body text. The email branch is first so an address at the site's
// domain is matched whole rather than split at the @.
const LINK_RE = /([\w.+-]+@[\w-]+\.[\w.-]+|https?:\/\/[^\s]+|www\.[^\s]+|flowstatecapital\.ai)/g;
 
function linkify(text: string): ReactNode {
	const parts = text.split(LINK_RE);
	return parts.map((part, i) => {
		// split() with a capturing group puts matches at odd indices
		if (i % 2 === 0) {
			return part;
		}
		if (part === SITE_DOMAIN) {
			const domain = currentSiteDomain();
			return (
				<Link key={i} href={`https://${domain}`} underline="hover" sx={{wordBreak: 'break-word'}}>
					{domain}
				</Link>
			);
		}
		const href = part.includes('@')
			? `mailto:${part}`
			: part.startsWith('http')
				? part
				: `https://${part}`;
		return (
			<Link key={i} href={href} underline="hover" sx={{wordBreak: 'break-word'}}>
				{part}
			</Link>
		);
	});
}
 
// Semantic tag + MUI variant per heading depth. The page title is the <h1>,
// so document sections start at <h2>.
const HEADING_STYLE = {
	1: {component: 'h2', variant: 'h5', mt: 4, mb: 1, fontWeight: 700},
	2: {component: 'h3', variant: 'h6', mt: 3, mb: 1, fontWeight: 700},
	3: {component: 'h4', variant: 'subtitle1', mt: 2, mb: 0.5, fontWeight: 600}
} as const;
 
function renderBlock(block: LegalBlock, index: number): ReactElement {
	switch (block.type) {
		case 'heading': {
			const s = HEADING_STYLE[block.level];
			return (
				<Typography
					key={index}
					id={anchorId(block.text)}
					component={s.component}
					variant={s.variant}
					sx={{mt: s.mt, mb: s.mb, fontWeight: s.fontWeight, scrollMarginTop: 80}}
				>
					{block.text}
				</Typography>
			);
		}
		case 'ul':
		case 'ol':
			return (
				<Box
					key={index}
					component={block.type}
					sx={{pl: 3, mb: 2, mt: 0, color: 'text.secondary', '& li': {mb: 0.75}}}
				>
					{block.items.map((item, i) => (
						<li key={i}>
							<Typography component="span" variant="body1" sx={{color: 'text.secondary'}}>
								{linkify(item)}
							</Typography>
						</li>
					))}
				</Box>
			);
		case 'p':
		default:
			return (
				<Typography
					key={index}
					variant="body1"
					sx={{color: 'text.secondary', mb: 2, whiteSpace: 'pre-line'}}
				>
					{linkify(block.text)}
				</Typography>
			);
	}
}
 
interface LegalDocumentViewProps {
	doc: LegalDocument;
}
 
/**
 * Renders a static legal/policy document from its structured content blocks:
 * an "Effective / Version" line, an in-page table of contents built from the
 * top-level sections, then the document body (headings, paragraphs, lists).
 */
export default function LegalDocumentView({doc}: LegalDocumentViewProps): ReactElement {
	const topSections = doc.blocks.filter(
		(b): b is Extract<LegalBlock, {type: 'heading'}> => b.type === 'heading' && b.level === 1
	);
 
	return (
		<Box>
			<Typography variant="body2" sx={{color: 'text.secondary'}}>
				Effective {doc.effectiveDate}
				{doc.version ? ` · Version ${doc.version}` : ''}
			</Typography>
 
			{topSections.length > 2 && (
				<Box
					component="nav"
					aria-label="Contents"
					sx={{my: 3, p: 2, borderRadius: 1, backgroundColor: 'action.hover'}}
				>
					<Typography variant="subtitle2" sx={{fontWeight: 700, mb: 1}}>
						Contents
					</Typography>
					<Box component="ul" sx={{listStyle: 'none', pl: 0, m: 0, '& li': {mb: 0.5}}}>
						{topSections.map((s) => (
							<li key={s.text}>
								<Link href={`#${anchorId(s.text)}`} underline="hover" variant="body2">
									{s.text}
								</Link>
							</li>
						))}
					</Box>
				</Box>
			)}
 
			<Divider sx={{mb: 2}}/>
 
			{doc.blocks.map((block, i) => renderBlock(block, i))}
		</Box>
	);
}