// components.jsx — shared UI primitives for Haven const { useState, useRef, useEffect } = React; // ── Card ──────────────────────────────────────────────────────────── function Card({ children, style, pad = 22, className = '', hover, onClick, ...rest }) { const [h, setH] = useState(false); return (
setH(true)} onMouseLeave={() => setH(false)} style={{ background: 'var(--surface)', border: '1px solid var(--line)', borderRadius: 'var(--radius)', padding: pad, boxShadow: hover && h ? 'var(--shadow)' : 'var(--shadow-sm)', transition: 'box-shadow .2s, transform .2s, border-color .2s', transform: hover && h ? 'translateY(-2px)' : 'none', cursor: onClick ? 'pointer' : 'default', ...style, }} {...rest} > {children}
); } // ── Button ────────────────────────────────────────────────────────── function Button({ children, variant = 'primary', size = 'md', icon: Icon, iconRight, full, style, ...rest }) { const [h, setH] = useState(false); const sizes = { sm: { padding: '7px 13px', fontSize: 13.5, radius: 10, gap: 6, ic: 16 }, md: { padding: '11px 18px', fontSize: 15, radius: 12, gap: 8, ic: 18 }, lg: { padding: '14px 24px', fontSize: 16, radius: 14, gap: 9, ic: 20 }, }[size]; const variants = { primary: { bg: 'var(--primary)', color: '#fff', border: 'transparent', bgH: 'var(--primary-ink)' }, amber: { bg: 'var(--amber)', color: '#fff', border: 'transparent', bgH: 'var(--amber-ink)' }, soft: { bg: 'var(--primary-soft)', color: 'var(--primary-ink)', border: 'transparent', bgH: '#f3d9e4' }, ghost: { bg: 'transparent', color: 'var(--ink-2)', border: 'var(--line-2)', bgH: 'var(--surface-2)' }, white: { bg: 'var(--surface)', color: 'var(--ink)', border: 'var(--line-2)', bgH: 'var(--surface-2)' }, }[variant]; return ( ); } // ── Pill / Badge ──────────────────────────────────────────────────── function Pill({ children, color = 'var(--ink-2)', bg = 'var(--surface-2)', style }) { return ( {children} ); } const STATUS = { draft: { label: 'Draft', color: 'var(--ink-2)', bg: 'var(--surface-2)', icon: 'doc' }, submitted: { label: 'Submitted', color: 'var(--amber-ink)', bg: 'var(--amber-soft)', icon: 'clock' }, paid: { label: 'Paid', color: 'var(--good)', bg: 'var(--good-soft)', icon: 'checkCircle' }, rejected: { label: 'Needs a fix', color: 'var(--alert)', bg: 'var(--alert-soft)', icon: 'alert' }, pending: { label: 'To approve', color: 'var(--amber-ink)', bg: 'var(--amber-soft)', icon: 'clock' }, }; function StatusPill({ status }) { const s = STATUS[status] || STATUS.draft; const I = Icons[s.icon]; return {s.label}; } // ── Progress bar ──────────────────────────────────────────────────── function Bar({ pct, color = 'var(--primary)', track = 'var(--surface-2)', height = 9, marker }) { const p = Math.min(1, Math.max(0, pct)); return (
{marker != null && (
)}
); } // ── Donut ring ────────────────────────────────────────────────────── function Ring({ pct, size = 120, stroke = 12, color = 'var(--primary)', track = 'var(--surface-2)', children }) { const r = (size - stroke) / 2; const c = 2 * Math.PI * r; const p = Math.min(1, Math.max(0, pct)); return (
{children}
); } // ── Avatar ────────────────────────────────────────────────────────── function Avatar({ initials, size = 38, bg = 'var(--primary-soft)', color = 'var(--primary-ink)' }) { return (
{initials}
); } // ── Plain-English helper (jargon tooltip) ─────────────────────────── let PLAIN_ON = true; function PlainTip({ term, children }) { const [open, setOpen] = useState(false); const text = GLOSSARY[term] || children; if (!PLAIN_ON) return {term}; return ( setOpen(true)} onMouseLeave={() => setOpen(false)}> {term} {open && ( In plain English {text} )} ); } // ── Section heading ───────────────────────────────────────────────── function SectionTitle({ children, sub, action }) { return (

{children}

{sub &&

{sub}

}
{action}
); } // ── Modal ─────────────────────────────────────────────────────────── function Modal({ open, onClose, children, width = 560, title, sub }) { useEffect(() => { if (!open) return; const h = (e) => e.key === 'Escape' && onClose(); window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }, [open, onClose]); if (!open) return null; return (
e.stopPropagation()} style={{ background: 'var(--surface)', borderRadius: 'var(--radius-lg)', width, maxWidth: '100%', maxHeight: '90vh', overflow: 'auto', boxShadow: 'var(--shadow-lg)', animation: 'pop .2s ease both', }}> {title && (

{title}

{sub &&

{sub}

}
)}
{children}
); } // ── Empty / info note ─────────────────────────────────────────────── function Note({ children, tone = 'info', icon }) { const tones = { info: { bg: 'var(--surface-2)', color: 'var(--ink-2)', ic: 'var(--ink-3)' }, good: { bg: 'var(--good-soft)', color: '#3a7264', ic: 'var(--good)' }, amber: { bg: 'var(--amber-soft)', color: 'var(--amber-ink)', ic: 'var(--amber-ink)' }, primary: { bg: 'var(--primary-soft)', color: 'var(--primary-ink)', ic: 'var(--primary)' }, }[tone]; const I = Icons[icon || 'info']; return (
{children}
); } // number ticker function useCountUp(target, dur = 700) { const [v, setV] = useState(0); useEffect(() => { let raf, start; const tick = (t) => { if (!start) start = t; const p = Math.min(1, (t - start) / dur); const e = 1 - Math.pow(1 - p, 3); setV(target * e); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [target, dur]); return v; } function setPlain(on) { PLAIN_ON = on; } Object.assign(window, { Card, Button, Pill, StatusPill, STATUS, Bar, Ring, Avatar, PlainTip, SectionTitle, Modal, Note, useCountUp, setPlain, });