// ================= 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 ;
};
// ---------- 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 (
);
};
// ---------- 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 (
);
};
// ---------- 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 (
| toggleSort(c.key) : null}>
{c.header}
{c.sort && {sorting ? (sort.dir === "asc" ? "▲" : "▼") : "◇"}}
|
);
})}
{sorted.map(r => (
onRowClick(r, e) : null}
onContextMenu={onRowContext ? (e) => { e.preventDefault(); onRowContext(r, e); } : null}>
{columns.map(c => (
|
{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 (
);
};
// ---------- 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 (
);
};
// ---------- Stacked bar ----------
const StackedBar = ({ items, width = 200, height = 8 }) => {
const total = items.reduce((s, it) => s + it.value, 0) || 1;
let offset = 0;
return (
);
};
Object.assign(window, {
Icon, PlatPill, StatusDot, Sparkline, Pacing, KPI, Tabs, Crumbs, Table,
CtxMenu, useToast, Donut, LineChart, StackedBar,
});