/* Report Builder — slide + block renderers ============================== Pure presentation: SlideRenderer(def, slide, dataset, theme) → 16:9 DOM. The SAME DOM is the live preview and the PDF snapshot source; the PPTX exporter re-derives the same numbers via reportMetrics selectors. Slides render at a fixed logical 1280×720 and scale via CSS transform. ======================================================================== */ const R_SLIDE_W = 1280, R_SLIDE_H = 720; const RCx = (typeof Recharts !== 'undefined') ? Recharts : window.Recharts; /* ---------- Theme from a brand kit ------------------------------------ */ function rTheme(kit, lang){ kit = kit || {}; const colors = (kit.colors && kit.colors.length===3) ? kit.colors : ['#233433','#45464B','#488D82']; /* Direction follows the REPORT language; kit.rtl only informs the default font (an English report with an Arabic brand kit stays LTR). 'both' (bilingual) lays out RTL — Arabic-first, Saudi audience. */ const rtl = lang === 'ar' || lang === 'both'; return { kit, rtl, lang: lang || 'en', main: colors[0], secondary: colors[1], tertiary: colors[2], colors, ramp: rRamp(colors, 8), onMain: rContrastText(colors[0]), onSecondary: rContrastText(colors[1]), font: kit.font || (rtl || kit.rtl ? "'Sakkal Majalla','Segoe UI',Tahoma,sans-serif" : "Inter,Helvetica,Arial,sans-serif"), logo: kit.logo_path || null, logoDark: kit.logo_dark_path || kit.logo_path || null, // for dark/branded slides bg: kit.bg_path || null, bgFlipped: kit.bg_path_flipped || null, }; } /* Branded background for cover/divider/thank_you. Illustration must sit OPPOSITE the text: LTR text-left → original (illustration right); RTL text-right → flipped (illustration left). */ function rBrandedBg(theme){ if (!theme.bg) return null; return theme.rtl ? (theme.bgFlipped || theme.bg) : theme.bg; } /* ---------- Shared bits ------------------------------------------------ */ function RNum({ children }){ // keep numerals LTR inside RTL text return {children}; } /* Official platform logo (user-supplied PNG); BrandIcon SVG as fallback. */ function RPlatLogo({ platform, size }){ const src = typeof rPlatformLogo==='function' ? rPlatformLogo(platform) : null; if (src) return {platform}; return ; } function RDeltaChip({ cur, prev, mkey, lang }){ const d = rDelta(cur, prev, mkey); if (!d) return null; const tones = { good:{bg:'#e7f6ee',fg:'#0c7a43'}, bad:{bg:'#fdecec',fg:'#b42323'}, neutral:{bg:'#f0f0ee',fg:'#5e5e55'} }; const t = tones[d.tone]; return ( {d.up?'▲':'▼'} {Math.abs(d.pct).toFixed(1)}% ); } function REmpty({ lang, note }){ return (
{note || rStr('no_data', lang)}
); } const R_AXIS = '#8b8b80', R_GRID = 'rgba(0,0,0,.07)'; /* ---------- Blocks ------------------------------------------------------ */ function RScorecard({ block, ds, theme, currency, tall }){ const lang = theme.lang; const b = block.binding || {}; const t = b.platform ? rTotals(ds, b.platform) : rTotals(ds); const metrics = (b.metrics && b.metrics.length ? b.metrics : ['spend','conversions','cpa','roas']); if (rMetricValue(t.cur,'spend')===0 && rMetricValue(t.cur,'impressions')===0) return ; const cols = metrics.length <= 4 ? metrics.length : Math.ceil(metrics.length/2); const big = tall && metrics.length <= 4; // solo scorecard row → fill the slide return (
{metrics.map((k,mi)=>{ const m = rMetric(k); if (!m) return null; /* Total Followers is a BRAND metric — comes from brand_metrics_daily (aggregated across platforms), not from ad-spend sums. */ if (k === 'followers'){ const br = rBrand(ds); const total = br.totalFollowers || 0; return (
{rMetricLabel('followers', lang)}
{total ? rCompact(total,'int',currency,lang) : (br.totalGained ? '+'+rCompact(br.totalGained,'int',currency,lang) : '—')}
{br.totalGained ? {'+'+br.totalGained.toLocaleString('en-US')} : } {rStr('followers_by_platform', lang)}
); } const cur = rMetricValue(t.cur,k), prev = rMetricValue(t.prev,k); return (
{rMetricLabel(k, lang)}
{rCompact(cur, m.fmt, currency, lang)}
{rStr('vs_prev', lang)}
); })}
); } function RTrend({ block, ds, theme, currency, editable, onBinding, tall }){ const lang = theme.lang; const b = block.binding || {}; const keys = (b.metrics && b.metrics.length ? b.metrics : ['spend']); const showCompare = block.options && block.options.showCompare !== false; const data = rSeries(ds, keys, b.platform); if (!data.length) return ; /* dual axis: money-scale metrics left, ratio/small metrics right */ const isBig = (k)=>{ const m=rMetric(k); return m && (m.fmt==='currency'||m.fmt==='int'); }; const left = keys.filter(isBig), right = keys.filter(k=>!isBig(k)); const colorOf = (k)=> theme.ramp[keys.indexOf(k) % theme.ramp.length]; const toggle = (k)=>{ if (!editable || !onBinding) return; const cur = new Set(keys); cur.has(k) && cur.size>1 ? cur.delete(k) : cur.add(k); onBinding({ metrics:[...cur] }); }; const all = rAllMetrics().filter(m=>m.tier!==3 && m.agg!=='last'); return (
{(editable ? all.map(m=>m.key) : keys).map(k=>{ const on = keys.includes(k); if (!editable && !on) return null; return ( ); })}
rCompact(v, 'int', currency, 'en')}/> {right.length>0 && } { const key = String(name).replace('_cmp_',''); const m = rMetric(key); return [rFmt(v, m?m.fmt:'int', currency, lang), rMetricLabel(key,lang) + (String(name).startsWith('_cmp_')?' (prev)':'')]; }}/> {keys.map(k=>( ))} {showCompare && keys.map(k=>( ))}
); } function RBar({ block, ds, theme, currency, tall }){ const lang = theme.lang; const b = block.binding || {}; const key = b.metric || 'spend'; const m = rMetric(key); let data; if ((b.dimension||'platform') === 'platform'){ data = rByPlatform(ds).map(p=>({ name: lang==='ar' ? rPlatform(p.platform).ar : rPlatform(p.platform).label, value: rMetricValue(p.cur, key), tone: rPlatform(p.platform).tone, })); } else { const { rows } = rEntityRows(ds, b.level||'campaign', { platform:b.platform, sortKey:key, topN:b.topN||8 }); data = rows.map(r=>({ name:r.name, value:rMetricValue(r.cur,key) })); } data = data.filter(d=>d.value>0); if (!data.length) return ; return (
6?-18:0} height={data.length>6?50:28}/> rCompact(v,'int',currency,'en')}/> [rFmt(v, m?m.fmt:'int', currency, lang), rMetricLabel(key,lang)]}/> {data.map((d,i)=>)}
); } function RDonut({ block, ds, theme, currency, tall }){ const lang = theme.lang; const key = (block.binding && block.binding.metric) || 'spend'; const m = rMetric(key); const data = rByPlatform(ds) .map(p=>({ id:p.platform, name: lang==='ar'?rPlatform(p.platform).ar:rPlatform(p.platform).label, value: rMetricValue(p.cur,key) })) .filter(d=>d.value>0); if (!data.length) return ; const total = data.reduce((a,b)=>a+b.value,0); return (
{rStr('spend_share',lang)}
{/* center total */}
{rCompact(total, m?m.fmt:'currency', currency, lang)}
{rMetricLabel(key,lang)}
{data.map((d,i)=>)} [rFmt(v, m?m.fmt:'currency', currency, lang)]}/>
{data.map((d,i)=>(
{d.name} {(d.value/total*100).toFixed(1)}% {rCompact(d.value, m?m.fmt:'currency', currency, lang)}
))}
); } function RBrandBlock({ ds, theme, lang }){ lang = lang || theme.lang; const b = rBrand(ds); if (!b.hasData) return ; return (
{rStr('followers_by_platform',lang)}
{b.platforms.map(p=>(
{lang==='ar'?rPlatform(p).ar:rPlatform(p).label} {b.gained[p] ? '+'+ (b.gained[p]).toLocaleString('en-US') : '—'} {b.latest[p] != null && ({Number(b.latest[p]).toLocaleString('en-US')})}
))}
{rStr('total_followers',lang)} {b.totalFollowers ? b.totalFollowers.toLocaleString('en-US') : '+' + b.totalGained.toLocaleString('en-US')}
); } function RTable({ block, ds, theme, currency, editable, onBinding }){ const lang = theme.lang; const b = block.binding || {}; const metrics = b.metrics && b.metrics.length ? b.metrics : ['spend','impressions','clicks','ctr','conversions','cpa']; const res = rEntityRows(ds, b.level||'campaign', { platform:b.platform, sortKey:b.sortKey||'spend', sortDir:b.sortDir||'desc', topN:b.topN||10, filters:b.filters||[] }); const rows = res.rows; if (!rows.length) return ; /* 👑 best performer of this level+scope (objective-aware), unless disabled */ const perf = (block.options && block.options.crown === false) ? null : rPerfInsights(ds, b.level||'campaign', { platform:b.platform, lang, currency }); const crownId = perf ? perf.bestId : null; const heat = (block.options && block.options.heatmap) || []; const maxOf = {}; heat.forEach(k=>{ maxOf[k] = Math.max(...rows.map(r=>rMetricValue(r.cur,k)), 0.0001); }); const sortOn = (k)=>{ if (!editable || !onBinding) return; onBinding(b.sortKey===k ? { sortDir: b.sortDir==='asc'?'desc':'asc' } : { sortKey:k, sortDir:'desc' }); }; const th = { fontSize:12, textTransform:'uppercase', letterSpacing:0.4, color:'#8b8b80', fontWeight:700, padding:'10px 12px', whiteSpace:'nowrap', textAlign: theme.rtl?'right':'left', cursor: editable?'pointer':'default' }; const allRows = res.others ? [...rows, res.others] : rows; return (
{metrics.map(k=>( ))} {allRows.map((r,ri)=>( {metrics.map(k=>{ const m = rMetric(k); const v = rMetricValue(r.cur,k); let bg; if (heat.includes(k) && !r._others){ const t = Math.min(1, v / maxOf[k]); bg = rMix('#ffffff', theme.tertiary, 0.14 + 0.5*t); } return ( ); })} ))}
{}}>{ b.level==='ad' ? rStr('ads',lang) : b.level==='adset' ? rStr('adsets',lang) : rStr('campaigns',lang) }sortOn(k)}> {rMetricLabel(k,lang)}{b.sortKey===k ? (b.sortDir==='asc'?' ↑':' ↓') : ''}
{r.id===crownId && 👑} {r.name} {rFmt(v, m?m.fmt:'int', currency, lang)}
{res.total > rows.length && !res.others &&
{lang==='ar' ? `عرض ${rows.length} من ${res.total}` : `Showing ${rows.length} of ${res.total}`}
}
); } /* Day-of-week × week heatmap (data is daily — honest granularity). */ function RHeatmap({ block, ds, theme, currency, tall }){ const lang = theme.lang; const key = (block.binding && block.binding.metric) || 'spend'; const m = rMetric(key); const series = rSeries(ds, [key], block.binding && block.binding.platform); if (!series.length) return ; const weeks = {}; // weekStart(iso) -> [7 values] series.forEach(r=>{ const d = new Date(r.date+'T00:00:00'); const dow = d.getDay(); const ws = new Date(d); ws.setDate(d.getDate()-dow); const wk = ws.toISOString().slice(0,10); (weeks[wk] = weeks[wk] || Array(7).fill(null))[dow] = r[key]; }); const wkeys = Object.keys(weeks).sort(); const max = Math.max(...series.map(r=>r[key]||0), 0.0001); const days = lang==='ar' ? ['أحد','إثن','ثلا','أرب','خمي','جمع','سبت'] : ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; return (
{rMetricLabel(key,lang)} — {rStr('daily',lang)}
{wkeys.map(w=>(
{new Date(w+'T00:00:00').toLocaleDateString('en-US',{month:'short',day:'numeric'})}
))} {days.map((dl,dow)=>(
{dl}
{wkeys.map(w=>{ const v = weeks[w][dow]; const bg = v==null ? '#f4f4f0' : rMix('#ffffff', theme.main, 0.12 + 0.72*Math.min(1, v/max)); return (
{v!=null && v/max>0.02 && {rCompact(v,'int',currency,'en')} }
); })}
))}
); } /* Ranked list — from the Al Munawarah deck: rank badge + name + share + secondary */ function RRankedList({ block, ds, theme, currency }){ const lang = theme.lang; const b = block.binding || {}; const key = b.metric || 'impressions', sec = b.secondary || 'ctr'; const mk = rMetric(key), ms = rMetric(sec); const res = rEntityRows(ds, b.level||'campaign', { platform:b.platform, sortKey:key, sortDir:'desc', topN:b.topN||5 }); const rows = res.rows; if (!rows.length) return ; const total = rows.reduce((a,r)=>a+rMetricValue(r.cur,key),0) + (res.others ? rMetricValue(res.others.cur,key) : 0); return (
{rows.map((r,i)=>{ const v = rMetricValue(r.cur,key), sv = rMetricValue(r.cur,sec); const share = total>0 ? v/total*100 : 0; return (
{i+1}
{r.name}
{rCompact(v, mk?mk.fmt:'int', currency, lang)} {' — '}{share.toFixed(1)}% {' • '}{rMetricLabel(sec,lang)} {rFmt(sv, ms?ms.fmt:'pct', currency, lang)}
); })}
); } /* Analysis flags — WHY the best ad/ad set wins and the worst is failing. */ function RPerfFlags({ block, ds, theme, currency }){ const lang = theme.lang; const b = block.binding || {}; const perf = rPerfInsights(ds, b.level||'ad', { platform:b.platform, lang, currency }); if (!perf) return ; const Card = ({ icon, tone, label, name, reasons })=>(
{icon} {label}
{name}
{reasons.map((t,i)=>( {t} ))}
); return (
); } /* Key Priorities — rich, data-driven action cards (Next Steps slide). */ function RPriorities({ block, ds, theme, currency, tall }){ const lang = theme.lang; const custom = block.options && block.options.items; const items = (custom && custom.length) ? custom : rRecommendations(ds, lang, currency); if (!items.length) return ; const PRI = { high:{ label: lang==='ar'?'عالية':'HIGH', bar:'#d9482b', bg:'#fdf3f0', icon:'#d9482b' }, med: { label: lang==='ar'?'متوسطة':'MED', bar:'#e2a615', bg:'#fdf9ee', icon:'#b3820a' }, low: { label: lang==='ar'?'منخفضة':'LOW', bar:'#4f9a67', bg:'#f2f8f3', icon:'#3d7d51' }, }; return (
{items.map((it,i)=>{ const p = PRI[it.pri] || PRI.med; return (
{it.title} {p.label}
{it.why}
{it.action}
); })}
); } function RText({ block, ds, theme, currency, editable, onOptions }){ const lang = theme.lang; const o = block.options || {}; let bullets = o.bullets && o.bullets.length ? o.bullets : null; if (!bullets && o.autoInsights) bullets = rAutoInsights(ds, lang, currency); const text = o.text || ''; if (!bullets && !text) return ; return (
{text &&

{text}

} {bullets && (
    {bullets.map((t,i)=>
  • {t}
  • )}
)} {o.autoInsights && !o.bullets?.length &&
{lang==='ar'?'نقاط مُولّدة تلقائياً من البيانات — قابلة للتحرير.':'Auto-generated from the data — editable.'}
}
); } function RImage({ block }){ const o = block.options || {}; if (!o.src) return ; return
; } const R_BLOCKS = { scorecard: { comp:RScorecard, span:12, label:'Scorecards', ar:'بطاقات المؤشرات', icon:'sparkles' }, metric_grid:{ comp:RScorecard, span:12, label:'Metric grid', ar:'شبكة مؤشرات', icon:'columns' }, trend: { comp:RTrend, span:12, label:'Trend chart', ar:'مخطط الاتجاه', icon:'zap' }, bar: { comp:RBar, span:6, label:'Bar chart', ar:'مخطط أعمدة', icon:'columns' }, donut: { comp:RDonut, span:6, label:'Share donut', ar:'مخطط دائري', icon:'sparkles' }, table: { comp:RTable, span:12, label:'Data table', ar:'جدول بيانات', icon:'columns' }, heatmap: { comp:RHeatmap, span:12, label:'Heatmap', ar:'خريطة حرارية', icon:'calendar' }, ranked_list:{ comp:RRankedList, span:12, label:'Ranked list', ar:'قائمة مرتبة', icon:'star' }, perf_flags: { comp:RPerfFlags, span:12, label:'Analysis flags', ar:'تحليل الأداء', icon:'sparkles' }, priorities: { comp:RPriorities, span:12, label:'Priorities', ar:'الأولويات', icon:'rocket' }, brand: { comp:RBrandBlock, span:6, label:'Followers', ar:'المتابعون', icon:'users' }, text: { comp:RText, span:12, label:'Text / insights', ar:'نص / رؤى', icon:'file' }, image: { comp:RImage, span:12, label:'Image', ar:'صورة', icon:'image' }, }; /* ---------- Slide chrome ------------------------------------------------ */ function RSlideHeader({ def, slide, theme }){ const lang = theme.lang; const periodStr = def.scope.start ? rDateLabel(def.scope.start,lang) + ' – ' + rDateLabel(def.scope.end,lang) : ''; return (
{slide.title}
{periodStr &&
{periodStr}
}
{slide.platform && } {theme.logo && }
); } /* Branded (cover / divider / thank_you) slide surface. Kits without a background image get brand-derived geometry instead of a flat fill. */ function RBrandedSurface({ theme, children }){ const bg = rBrandedBg(theme); return (
{bg && } {!bg && (
)}
{children}
); } /* Agency credit ("Prepared by FLNT") — separate from the client brand. */ function RAgencyCredit({ def, size, center }){ const a = def.agency; if (!a || !a.enabled) return null; return (
Prepared by {a.logo && } {a.name || 'FLNT'}
); } function RSlide({ def, slide, ds, theme, currency, editable, selectedBlockId, onSelectBlock, onBinding }){ const lang = theme.lang; const middle = def.slides.filter(s=>!['cover','agenda','thank_you'].includes(s.type)); const base = { width:R_SLIDE_W, height:R_SLIDE_H, background:'#fbfbf9', position:'relative', fontFamily: theme.font, overflow:'hidden', flexShrink:0, }; const pad = { position:'absolute', inset:0, padding:'40px 52px 50px', display:'flex', flexDirection:'column' }; const periodStr = def.scope.start ? rDateLabel(def.scope.start,lang) + ' – ' + rDateLabel(def.scope.end,lang) : ''; let content = null; if (slide.type === 'cover'){ content = (
{theme.logoDark && }
{def.brandName || ''}
{slide.title || rStr('performance_report', lang)}
{periodStr &&
{rStr('exec_summary',lang)} — {periodStr}
}
); } else if (slide.type === 'thank_you'){ content = (
{theme.logoDark && }
{slide.title || rStr('thank_you',lang)}
{def.brandName &&
{def.brandName}
}
); } else if (slide.type === 'divider'){ const dividers = def.slides.filter(s=>s.type==='divider'); const secNum = String(dividers.indexOf(slide)+1).padStart(2,'0'); content = ( {/* oversized section-number watermark */}
{secNum}
{slide.platform && ( )}
{(theme.rtl?'':'SECTION ')}{secNum}
{slide.title}
); } else if (slide.type === 'agenda'){ content = (
{middle.map((s,i)=>(
{String(i+1).padStart(2,'0')} {s.type==='divider' && s.platform && } {s.type==='divider' ? (lang==='ar'?'قسم: ':'Section: ') + s.title : s.title}
))}
); } else { /* exec_summary + content: block grid — a single block stretches to fill the slide (no dead space); multiple blocks distribute evenly. */ const blocksArr = slide.blocks || []; const solo = blocksArr.length === 1; content = (
{blocksArr.map(blk=>{ const meta = R_BLOCKS[blk.type]; if (!meta) return null; const Comp = meta.comp; const span = (blk.options && blk.options.span) || meta.span; const sel = editable && selectedBlockId === blk.id; return (
{ e.stopPropagation(); onSelectBlock && onSelectBlock(blk.id); } : undefined} style={{gridColumn:`span ${span}`, minWidth:0, position:'relative', borderRadius:12, outline: sel ? `2px solid ${theme.tertiary}` : 'none', outlineOffset:3, cursor: editable?'pointer':'default'}}> onBinding(blk.id, patch) : undefined}/>
); })} {(!slide.blocks || !slide.blocks.length) &&
}
); } const light = !['cover','thank_you','divider'].includes(slide.type); return (
{/* brand accent strip across the top of light slides */} {light &&
} {content} {/* footer: brand · agency credit · page number */} {light && (
{def.brandName} {def.agency && def.agency.enabled && ( Prepared by {def.agency.logo && } {def.agency.name || 'FLNT'} · )} {def.slides.indexOf(slide)+1} / {def.slides.length}
)}
); } /* Scaled preview wrapper: renders the 1280×720 slide at any width. */ function RSlideScaled({ width, children }){ const scale = width / R_SLIDE_W; return (
{children}
); } Object.assign(window, { R_SLIDE_W, R_SLIDE_H, R_BLOCKS, rTheme, rBrandedBg, RSlide, RSlideScaled, RDeltaChip, REmpty, RPlatLogo, });