/* Campaign Table with nested Campaign → AdSet → Offer drilldown ===== */
const DEFAULT_COLS = [
{ id:'name', label:'Campaign', w:'minmax(280px,1.6fr)', align:'left', sortable:true },
{ id:'status', label:'Status', w:'110px', align:'left', sortable:true },
{ id:'platform',label:'Platform', w:'90px', align:'left', sortable:true },
{ id:'budget', label:'Budget', w:'120px', align:'right', sortable:true, fmt:'currency' },
{ id:'spend', label:'Spend · 7d', w:'140px', align:'right', sortable:true, fmt:'currency' },
{ id:'cpa', label:'CPA · 7d', w:'110px', align:'right', sortable:true, fmt:'currency2' },
{ id:'roas', label:'ROAS · 95% CI', w:'110px', align:'right', sortable:true, fmt:'x' },
{ id:'cpc', label:'CPC · 7d', w:'100px', align:'right', sortable:true, fmt:'currency2' },
{ id:'ctr', label:'CTR · 7d', w:'100px', align:'right', sortable:true, fmt:'pct' },
{ id:'conv', label:'Conv · 7d', w:'110px', align:'right', sortable:true, fmt:'int' },
{ id:'pacing', label:'Pacing', w:'130px', align:'left', sortable:true },
];
/* Grid template — ROAS column drops entirely for blocklisted accounts
(e.g. Al Munawarah). Header + campaign row + ad-set row share this. */
const _GRID_FULL = 'minmax(220px,1.6fr) 104px 86px 112px 132px 104px 104px 96px 96px 104px 120px';
const _GRID_NOROAS = 'minmax(220px,1.6fr) 104px 86px 112px 132px 104px 96px 96px 104px 120px';
const roasHidden = ()=> typeof metricHiddenHere === 'function' && metricHiddenHere('roas');
const campaignGrid = ()=> roasHidden() ? _GRID_NOROAS : _GRID_FULL;
/* Inline tiny sparkline (used inside table cells) */
function MicroSpark({ data, tone, width=36, height=14 }){
if (!data || !data.length) return null;
const vals = data.map(d=>d.v);
const min = Math.min(...vals), max = Math.max(...vals);
const x = i => (i*(width-2)/(data.length-1)) + 1;
const y = v => height - 1 - ((v-min)/((max-min)||1))*(height-2);
const path = data.map((d,i)=>(i===0?'M':'L')+x(i).toFixed(2)+','+y(d.v).toFixed(2)).join(' ');
return (
);
}
/* Numeric cell with optional sparkline */
function NumCell({ value, sparkData, tone='#8b8b80', className='', children }){
return (
{children ?? value}
{sparkData && }
);
}
/* ROAS cell with CI bar (small low-confidence indicator) */
function RoasCell({ roas, conv, target=1.5 }){
const ci = roasCI(roas || 0, conv || 0);
const r = roas || 0;
return (
=3?'text-emerald-600 dark:text-emerald-400':r<2?'text-rose-600 dark:text-rose-400':''}`}>
{r.toFixed(2)}×
{ci.wide && (
!
)}
{/* hover CI bar */}
95% CI: {ci.lo.toFixed(2)}× – {ci.hi.toFixed(2)}× · n={conv}
);
}
function PacingBar({ value }){
// value: ratio of pacing (1.0 = on pace); null → no budget set
if (value == null || Number.isNaN(value)) {
return — ;
}
const pct = Math.min(1.5, value);
const ok = value <= 1.05 && value >= 0.85;
const over = value > 1.10;
const tone = over ? '#ef4444' : ok ? '#10b981' : '#f59e0b';
return (
{(value*100).toFixed(0)}%
);
}
function SortHeader({ col, sort, setSort }){
const active = sort.id===col.id;
return (
col.sortable && setSort({ id:col.id, dir: active ? (sort.dir==='asc'?'desc':'asc') : 'desc' })}
className={`inline-flex items-center gap-1 text-[11px] uppercase tracking-wide font-semibold ${active?'text-ink-900 dark:text-white':'text-ink-500 dark:text-ink-300'}
${col.sortable?'hover:text-ink-800 dark:hover:text-white':''}`}>
{col.label}
{col.sortable && (
)}
);
}
function AdSetRow({ as, accent, toggleStatus, readOnly }){
const paused = as.status === 'paused';
return (
AS
{as.name}
toggleStatus(as.id)} size="sm"/>
—
{fmt(as.spend,'currency')}
{fmt(as.cpa,'currency2')}
{!roasHidden() &&
}
{fmt(as.cpc,'currency2')}
{(as.ctr||0).toFixed(2)}%
{(as.conv||0).toLocaleString()}
);
}
function CampaignRow({ c, expanded, toggleExpand, toggleStatus, readOnly }){
const paused = c.status === 'paused';
const accent = (PLATFORMS.find(p=>p.id===c.platform)||PLATFORMS[0]).tone;
/* No synthetic sparklines — cells show real range values only. */
const funnel = useMemo(()=>campaignFunnel(c), [c.id, c.spend, c.conv]);
return (
toggleExpand(c.id)} className="h-5 w-5 rounded grid place-items-center hover:bg-ink-100 dark:hover:bg-white/10">
{!paused && }
{paused && }
{c.name}
{(()=>{
const parts = String(c.id||'').split('_');
return parts.length >= 2 ? parts[parts.length-2] + '-' + parts[parts.length-1] : (c.id || '');
})()}
toggleStatus(c.id)} size="sm"/>
{paused?'Paused':'Active'}
{fmt(c.budget,'currency')}
{fmt(c.spend,'currency')}
{fmt(c.cpa,'currency2')}
{!roasHidden() &&
}
{fmt(c.cpc,'currency2')}
{(c.ctr||0).toFixed(2)}%
{(c.conv||0).toLocaleString()}
{expanded && (
{/* Funnel strip */}
{/* Ad sets */}
Ad sets · {(c.adsets||[]).length}
{(c.adsets||[]).map(as =>
)}
)}
);
}
function CampaignTable({ accountId, platformId, search='', onOpenColumns, canEdit=true }){
const readOnly = !canEdit;
const baseCampaigns = useMemo(()=>getCampaigns(accountId, platformId), [accountId, platformId]);
/* Local state: status & expansion */
const [statusMap, setStatusMap] = useState({}); // id -> 'active' | 'paused'
useEffect(()=>{ setStatusMap({}); setExpanded({}); }, [accountId, platformId]);
const [expanded, setExpanded] = useState({});
const toggleExpand = (id)=> setExpanded(s => ({...s, [id]: !s[id]}));
const toggleStatus = (id) => { if(readOnly) return; setStatusMap(s => {
const current = s[id];
const all = [...baseCampaigns, ...baseCampaigns.flatMap(c=>c.adsets||[])];
const it = all.find(x => x.id===id);
const base = it?.status;
const isActive = current ? current === 'active' : base === 'active';
return { ...s, [id]: isActive ? 'paused' : 'active' };
}); };
const applyStatus = (it) => {
const ov = statusMap[it.id];
return ov ? { ...it, status: ov } : it;
};
const campaigns = useMemo(()=>baseCampaigns.map(c=>{
const cc = applyStatus(c);
cc.adsets = (c.adsets||[]).map(applyStatus);
return cc;
}), [baseCampaigns, statusMap]);
/* Sort */
const [sort, setSort] = useState({ id:'spend', dir:'desc' });
const sorted = useMemo(()=>{
const arr = [...campaigns];
arr.sort((a,b)=>{
const va = a[sort.id], vb = b[sort.id];
if (typeof va === 'string') return sort.dir==='asc' ? va.localeCompare(vb) : vb.localeCompare(va);
return sort.dir==='asc' ? (va||0) - (vb||0) : (vb||0) - (va||0);
});
return arr;
}, [campaigns, sort]);
/* Search filter */
const filtered = useMemo(()=>{
if (!search.trim()) return sorted;
const q = search.toLowerCase();
return sorted.filter(c => (c.name||'').toLowerCase().includes(q) || (c.adsets||[]).some(a => (a.name||'').toLowerCase().includes(q)));
}, [sorted, search]);
/* Totals */
const totals = useMemo(()=>{
return filtered.reduce((t,c)=>({
spend: t.spend + (c.spend||0),
conv: t.conv + (c.conv||0),
budget:t.budget+ (c.budget||0),
}), { spend:0, conv:0, budget:0 });
}, [filtered]);
return (
{/* Header bar */}
Campaigns
{filtered.length} of {baseCampaigns.length}
Spend {fmt(totals.spend,'currency')}
·
Conv. {totals.conv.toLocaleString()}
Filters
Columns
New campaign
{/* Column headers + body share ONE horizontal scroll container so columns stay aligned */}
{DEFAULT_COLS.filter(col => !(col.id==='roas' && roasHidden())).map(col => (
))}
{/* Body */}
{filtered.length === 0 && (
{baseCampaigns.length === 0
? <>No campaigns yet — connect a data source or import data in Admin → Integrations .>
: <>No campaigns match "{search}" >}
)}
{filtered.map(c => (
))}
);
}
Object.assign(window, { CampaignTable, MicroSpark, RoasCell, NumCell });