/* Report Builder — starter templates + fixed-slide skeleton ============ Starter templates live in code (versioned); user-saved templates go to the `reports` table. Every reportDef enforces the locked skeleton: Cover → Agenda (auto) → Exec Summary → …content… → Thank You. Structures reverse-engineered from the user's two source decks (REPORT_BUILDER_SPEC.md → Starter templates). ======================================================================== */ let _rid = 0; const rNewId = (p)=> (p||'el') + '_' + Date.now().toString(36) + '_' + (++_rid); function rBlock(type, binding, options){ return { id:rNewId('blk'), type, binding: binding||{}, options: options||{} }; } function rSlide(type, title, blocks, extra){ return Object.assign({ id:rNewId('sl'), type, title: title||'', blocks: blocks||[], locked: ['cover','agenda','exec_summary','thank_you'].includes(type) }, extra||{}); } /* ---------- Fixed skeleton -------------------------------------------- */ function rCoverSlide(lang){ return rSlide('cover', rStr('performance_report', lang), []); } function rAgendaSlide(lang){ return rSlide('agenda', rStr('agenda', lang), []); // content auto-generated at render } function rExecSlide(lang, opts){ opts = opts || {}; /* default scorecards: Reach, Impressions, Cost/Result, Clicks, CPC, CTR, Total Followers, Video views — minus the account's blocked metrics */ const metrics = (opts.metrics || rDefaultExecMetrics(opts.accountId)) .filter(k=>!rMetricBlockedFor(opts.accountId, k)); const blocks = [ rBlock('scorecard', { metrics }) ]; if (!opts.noDonut) blocks.push(rBlock('donut', { metric:'spend', dimension:'platform' })); if (!opts.noBrand) blocks.push(rBlock('brand', {})); return rSlide('exec_summary', rStr('exec_summary', lang), blocks); } function rThankYouSlide(lang){ return rSlide('thank_you', rStr('thank_you', lang), []); } /* Per-platform analysis section: divider + overview + campaigns/adsets/ads */ function rPlatformSection(platform, lang, opts){ opts = opts || {}; const P = rPlatform(platform); const name = lang==='ar' ? P.ar : (lang==='both' ? P.ar + ' · ' + P.label : P.label); const mf = (arr)=>arr.filter(k=>!rMetricBlockedFor(opts.accountId, k)); const tableMetrics = mf(opts.tableMetrics || ['spend','impressions','clicks','ctr','cpc','conversions','cpa']); const slides = [ rSlide('divider', name, [], { platform }), rSlide('content', name + ' — ' + rStr('overview', lang), [ rBlock('scorecard', { platform, metrics: mf(opts.kpis || ['spend','impressions','clicks','ctr','cpc','conversions','cpa','roas']) }), rBlock('trend', { platform, metrics:['spend','conversions'] }, { showCompare:true }), ], { platform }), rSlide('content', name + ' — ' + rStr('campaigns', lang), [ rBlock('table', { platform, level:'campaign', metrics: tableMetrics, sortKey:'spend', sortDir:'desc', topN:10 }, { heatmap:['cpa'] }), ], { platform }), rSlide('content', name + ' — ' + rStr('adsets', lang), [ rBlock('table', { platform, level:'adset', metrics: tableMetrics, sortKey:'spend', sortDir:'desc', topN:8 }, { heatmap:['cpa'] }), rBlock('perf_flags', { platform, level:'adset' }), ], { platform }), rSlide('content', name + ' — ' + rStr('ads', lang), [ rBlock('table', { platform, level:'ad', metrics: tableMetrics, sortKey:'spend', sortDir:'desc', topN:8 }, { heatmap:['cpa'] }), rBlock('perf_flags', { platform, level:'ad' }), ], { platform }), ]; return slides; } /* Assemble a full def with the skeleton enforced. */ function rAssembleDef(opts){ const lang = opts.lang || 'en'; const middle = opts.middle || []; return { id: null, name: opts.name || 'Untitled report', accountId: opts.accountId || null, brandName: opts.brandName || '', lang, brandKitId: opts.brandKitId || null, agency: opts.agency || null, // { enabled, name, logo } — "Prepared by" credit scope: Object.assign({ source:'system' }, // 'system' | 'manual' — never both opts.scope || { platforms: [], start:null, end:null, compare:{ mode:'prev_period', start:null, end:null } }), slides: [ rCoverSlide(lang), rAgendaSlide(lang), opts.exec || rExecSlide(lang, { accountId: opts.accountId }), ...middle, rThankYouSlide(lang), ], }; } /* ---------- Starter templates ------------------------------------------ */ /* ctx: { accountId, brandName, scope:{platforms,start,end,compare}, brandKitId } */ const REPORT_TEMPLATES = [ { id:'tpl_monthly', name:'Monthly Client Report', ar:'التقرير الشهري', desc:'Full monthly deck: exec summary, trends, and campaign/ad set/ad analysis per platform.', icon:'calendar', lang:'en', build(ctx){ const lang = ctx.lang || 'en'; const plats = rSortPlatforms(ctx.scope.platforms || []); const middle = [ rSlide('content', rStr('trends', lang), [ rBlock('trend', { metrics:['spend','conversions','ctr'] }, { showCompare:true }), ]), rSlide('content', rStr('top_campaigns', lang), [ rBlock('table', { level:'campaign', metrics:['spend','impressions','clicks','ctr','conversions','cpa','roas'].filter(k=>!rMetricBlockedFor(ctx.accountId,k)), sortKey:'spend', sortDir:'desc', topN:10 }, { heatmap:['cpa','roas'].filter(k=>!rMetricBlockedFor(ctx.accountId,k)) }), ]), ...plats.flatMap(p=> rPlatformSection(p, lang, { accountId: ctx.accountId })), rSlide('content', rStr('daily', lang), [ rBlock('heatmap', { metric:'spend' }), ]), rSlide('content', rStr('next_steps', lang), [ rBlock('priorities', {}, {}), ]), ]; return rAssembleDef({ ...ctx, lang, name: (ctx.brandName||'Monthly') + ' — Monthly Report', middle }); }, }, { id:'tpl_wrapup', name:'Campaign Wrap-Up', ar:'تقرير نهاية الحملة', desc:'Post-campaign results deck: what ran, what it delivered, what to do next.', icon:'zap', lang:'en', build(ctx){ const lang = ctx.lang || 'en'; const plats = rSortPlatforms(ctx.scope.platforms || []); const middle = [ rSlide('content', rStr('trends', lang), [ rBlock('trend', { metrics:['spend','conversions'] }, { showCompare:false }), ]), rSlide('content', rStr('campaigns', lang), [ rBlock('table', { level:'campaign', metrics:['spend','impressions','clicks','ctr','conversions','cpa','roas'].filter(k=>!rMetricBlockedFor(ctx.accountId,k)), sortKey:'conversions', sortDir:'desc', topN:12 }, { heatmap:['cpa'] }), ]), rSlide('content', 'Top Ads', [ rBlock('ranked_list', { level:'ad', metric:'conversions', secondary:'cpa', topN:5 }), ]), ...plats.flatMap(p=> rPlatformSection(p, lang, { accountId: ctx.accountId })), rSlide('content', rStr('next_steps', lang), [ rBlock('priorities', {}, {}), ]), ]; return rAssembleDef({ ...ctx, lang, name:(ctx.brandName||'Campaign') + ' — Wrap-Up', middle }); }, }, { id:'tpl_weekly', name:'Weekly Pulse', ar:'التقرير الأسبوعي', desc:'Short weekly check-in: headline numbers, trend, and top movers.', icon:'sparkles', lang:'en', build(ctx){ const lang = ctx.lang || 'en'; const middle = [ rSlide('content', rStr('trends', lang), [ rBlock('trend', { metrics:['spend','conversions'] }, { showCompare:true }), rBlock('bar', { metric:'spend', dimension:'platform' }), ]), rSlide('content', rStr('top_campaigns', lang), [ rBlock('table', { level:'campaign', metrics:['spend','clicks','ctr','conversions','cpa'], sortKey:'spend', sortDir:'desc', topN:8 }, {}), ]), ]; return rAssembleDef({ ...ctx, lang, name:(ctx.brandName||'Weekly') + ' — Weekly Pulse', exec: rExecSlide(lang, { metrics:['spend','conversions','cpa','ctr'], noBrand:true }), middle }); }, }, { id:'tpl_almunawarah', name:'Al Munawarah', ar:'المنوّرة', desc:'Arabic RTL client deck — branded covers, KPI scorecards, and ranked breakdowns (from the client\'s own template).', icon:'star', lang:'ar', brandKitId:'bk_almunawarah', clientAccounts:['acc_almunawarah'], // client template → locked to its ad account(s) build(ctx){ const lang = ctx.lang || 'ar'; const plats = rSortPlatforms(ctx.scope.platforms || []); const middle = [ rSlide('content', 'أبرز الحملات (مرتبة حسب الظهور)', [ rBlock('ranked_list', { level:'campaign', metric:'impressions', secondary:'ctr', topN:5 }), ]), rSlide('content', rStr('trends', lang), [ rBlock('trend', { metrics:['spend','clicks'] }, { showCompare:true }), ]), ...plats.flatMap(p=> rPlatformSection(p, lang, { accountId: ctx.accountId })), ]; return rAssembleDef({ ...ctx, lang, brandKitId: ctx.brandKitId || 'bk_almunawarah', name:(ctx.brandName||'المنوّرة') + ' — ' + rStr('performance_report', lang), exec: rExecSlide(lang, { metrics:['spend','cpc','impressions','clicks'], noDonut:false }), middle }); }, }, { id:'tpl_blank', name:'Blank', ar:'فارغ', desc:'Just the fixed skeleton — build every content slide yourself.', icon:'file', lang:'en', build(ctx){ return rAssembleDef({ ...ctx, lang: ctx.lang||'en', name:(ctx.brandName||'New') + ' — Report', middle: [] }); }, }, ]; /* ---------- Skeleton guards (used by the builder UI) ------------------- */ /* Slides that can be reordered/removed = the non-locked middle. */ function rSlideBounds(def){ const first = def.slides.findIndex(s=>s.type==='exec_summary'); const last = def.slides.findIndex(s=>s.type==='thank_you'); return { firstMovable: first+1, lastMovable: last-1 }; } function rCanRemove(slide){ return !slide.locked; } function rMoveSlide(def, from, to){ const { firstMovable, lastMovable } = rSlideBounds(def); if (from < firstMovable || from > lastMovable) return def; const t = Math.max(firstMovable, Math.min(lastMovable, to)); const slides = [...def.slides]; const [s] = slides.splice(from, 1); slides.splice(t, 0, s); return { ...def, slides }; } Object.assign(window, { REPORT_TEMPLATES, rNewId, rBlock, rSlide, rAssembleDef, rPlatformSection, rCoverSlide, rAgendaSlide, rExecSlide, rThankYouSlide, rSlideBounds, rCanRemove, rMoveSlide, });