// ================= Shared UI primitives ================= const { useState, useEffect, useRef, useMemo, useCallback } = React; // ---------- Icons (tiny inline SVG set) ---------- const Icon = ({ name, size = 14, ...props }) => { const s = size; const common = { width: s, height: s, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", ...props }; const paths = { home: <>, brand: <>, pacing: <>, nonmedia: <>, alert: <>, search: <>, bell: <>, settings: <>, chev_r: , chev_d: , chev_u: , chev_l: , plus: <>, x: <>, sun: <>, moon: , refresh: <>, export: <>, filter: , copy: <>, external: <>, link: <>, dots: <>, check: , arrow_up: <>, arrow_dn: <>, user: <>, clock: <>, cmd: , bq: <>, eye: <>, eye_off: <>, google: <>, menu: <>, dollar: <>, layers: <>, trend_up: <>, trend_dn: <>, split: <>, folder: , logo: <>, }; return {paths[name] || null}; }; // ---------- Platform pill ---------- const PLAT_LOGOS = { meta: ( ), google: ( ), dv360: ( ), tiktok: ( ), ttd: ( ), youtube: ( ), }; const PlatPill = ({ platform, size = 20 }) => { const logo = PLAT_LOGOS[platform]; if (logo) { return ( {React.cloneElement(logo, { width: size, height: size })} ); } return ?; }; // ---------- Status ---------- const StatusDot = ({ status }) => { const tone = status?.tone || "default"; return ( {status?.label} ); }; // ---------- Sparkline ---------- const Sparkline = ({ data, width = 72, height = 22, color, showFill = true }) => { if (!data || data.length === 0) return null; const min = Math.min(...data); const max = Math.max(...data); const range = max - min || 1; const step = width / (data.length - 1); const pts = data.map((v, i) => [i * step, height - ((v - min) / range) * (height - 4) - 2]); const d = pts.map((p, i) => (i === 0 ? `M${p[0]},${p[1]}` : `L${p[0]},${p[1]}`)).join(" "); const fillD = `${d} L${width},${height} L0,${height} Z`; const stroke = color || "var(--accent)"; return ( {showFill && } ); }; // ---------- Pacing bar ---------- const Pacing = ({ pct, planPct = null, tone = "success", width = 80 }) => { const clamp = Math.max(0, Math.min(1.5, pct)); const fillPct = Math.min(1, clamp); return (
{planPct != null &&
}
); }; // ---------- KPI card ---------- const KPI = ({ label, value, sub, trend, spark, sparkColor }) => (
{label}
{value}
{trend != null && = 0 ? "up" : "down"}`}>{trend >= 0 ? "▲" : "▼"} {Math.abs(trend * 100).toFixed(1)}%} {sub && {sub}} {spark && }
); // ---------- Tabs ---------- const Tabs = ({ tabs, active, onChange }) => (
{tabs.map(t => (
onChange(t.id)}> {t.label} {t.count != null && {t.count}}
))}
); // ---------- Breadcrumbs ---------- const Crumbs = ({ items }) => (
{items.map((c, i) => ( {c.label} {i < items.length - 1 && /} ))}
); // ---------- Table ---------- const Table = ({ columns, rows, onRowClick, onRowContext, rowKey = (r) => r.id, selected, style, striped = false, lined = false }) => { const [sort, setSort] = useState(null); const sorted = useMemo(() => { if (!sort) return rows; const col = columns.find(c => c.key === sort.key); if (!col?.sort) return rows; const dir = sort.dir === "asc" ? 1 : -1; return [...rows].sort((a, b) => col.sort(a, b) * dir); }, [rows, sort, columns]); const toggleSort = (k) => { setSort(prev => { if (!prev || prev.key !== k) return { key: k, dir: "desc" }; if (prev.dir === "desc") return { key: k, dir: "asc" }; return null; }); }; return (
{columns.map(c => { const sorting = sort?.key === c.key; const cls = [c.sort && "sortable", c.right && "right", sorting && sort.dir].filter(Boolean).join(" "); return ( ); })} {sorted.map(r => ( onRowClick(r, e) : null} onContextMenu={onRowContext ? (e) => { e.preventDefault(); onRowContext(r, e); } : null}> {columns.map(c => ( ))} ))}
toggleSort(c.key) : null}> {c.header} {c.sort && {sorting ? (sort.dir === "asc" ? "▲" : "▼") : "◇"}}
{c.render ? c.render(r) : r[c.key]}
); }; // ---------- Context menu ---------- const CtxMenu = ({ items, pos, onClose }) => { const ref = useRef(null); useEffect(() => { const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); }; const onKey = (e) => { if (e.key === "Escape") onClose(); }; document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); }; }, [onClose]); if (!pos) return null; return (
{items.map((it, i) => it.divider ? (
) : (
{ it.onClick?.(); onClose(); }}> {it.icon && } {it.label} {it.shortcut && {it.shortcut}}
))}
); }; // ---------- Toast ---------- const useToast = () => { const [toasts, setToasts] = useState([]); const push = useCallback((msg, opts = {}) => { const id = Math.random().toString(36).slice(2); setToasts(t => [...t, { id, msg, icon: opts.icon || "check" }]); setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), opts.duration || 2400); }, []); const node = (
{toasts.map(t => (
{t.msg}
))}
); return [node, push]; }; // ---------- Donut ---------- const Donut = ({ items, size = 140, thick = 18 }) => { const total = items.reduce((s, it) => s + it.value, 0) || 1; const r = (size - thick) / 2; const cx = size / 2, cy = size / 2; const circ = 2 * Math.PI * r; let offset = 0; return ( {items.map((it, i) => { const frac = it.value / total; const len = frac * circ; const gap = circ - len; const el = ( ); offset += len; return el; })} ); }; // ---------- Line chart (small) ---------- const LineChart = ({ series, width = 760, height = 220, padding = { t: 12, r: 16, b: 24, l: 44 } }) => { // series: [{ label, color, data: number[] }] const days = series[0]?.data.length || 0; const all = series.flatMap(s => s.data); const min = 0; const max = Math.max(...all, 1) * 1.1; const innerW = width - padding.l - padding.r; const innerH = height - padding.t - padding.b; const x = (i) => padding.l + (i / Math.max(1, days - 1)) * innerW; const y = (v) => padding.t + innerH - ((v - min) / (max - min)) * innerH; const yTicks = 4; return ( {/* grid */} {Array.from({ length: yTicks + 1 }).map((_, i) => { const v = (max / yTicks) * i; const yy = y(v); return ( {window.fmt.hkdK(v)} ); })} {/* x ticks */} {Array.from({ length: Math.min(6, days) }).map((_, i) => { const idx = Math.round((days - 1) * (i / 5)); const xx = x(idx); return d{idx + 1}; })} {/* series */} {series.map((s, si) => { const d = s.data.map((v, i) => `${i === 0 ? "M" : "L"}${x(i)},${y(v)}`).join(" "); const fillD = `${d} L${x(s.data.length - 1)},${padding.t + innerH} L${x(0)},${padding.t + innerH} Z`; return ( {s.fill && } ); })} ); }; // ---------- Stacked bar ---------- const StackedBar = ({ items, width = 200, height = 8 }) => { const total = items.reduce((s, it) => s + it.value, 0) || 1; let offset = 0; return ( {items.map((it, i) => { const w = (it.value / total) * width; const rect = ; offset += w; return rect; })} ); }; Object.assign(window, { Icon, PlatPill, StatusDot, Sparkline, Pacing, KPI, Tabs, Crumbs, Table, CtxMenu, useToast, Donut, LineChart, StackedBar, });