/* AAU CRM — Marketing Hub part 2: Ad Tracking, Campaign Optimizer, Content Analytics, Source Performance */ // Cổng kết nối Facebook Ads — gắn 1 lần (token + ad account), test với Graph API rồi lưu. function FbAdsConnectModal({ conn, onClose, onSaved }) { const [token, setToken] = useState(''); const [acct, setAcct] = useState((conn && conn.adAccount) || ''); const [ver, setVer] = useState((conn && conn.apiVersion) || 'v21.0'); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const connected = conn && conn.connected; const save = async () => { if (!token.trim() || !acct.trim() || busy) return; setBusy(true); setErr(null); try { const r = await window.API.adsConnect({ token: token.trim(), adAccount: acct.trim(), apiVersion: ver.trim() }); onSaved(r); onClose(); } catch (e) { setErr((e && e.message) || 'Không kết nối được'); } finally { setBusy(false); } }; const disconnect = async () => { if (!window.confirm('Gỡ cổng FB Ads đã lưu?') || busy) return; setBusy(true); try { await window.API.adsDisconnect(); onSaved(null); onClose(); } finally { setBusy(false); } }; return ( {connected && } }> {connected &&
Đang nối: {conn.accountName || conn.adAccount}{conn.tokenMask ? ' · token ' + conn.tokenMask : ''}
} {err &&
{err}
}
Bấm Test & Lưu: app gọi Facebook xác thực rồi lưu 1 lần (sống qua restart, không cần env). Pilot lưu token trong DB; production chuyển secret store.
); } function AdTracking() { const t = useTimeRange('30d'); const [platform, setPlatform] = useState('all'); const [tick, setTick] = useState(0); const [rep, setRep] = useState(null); const [conn, setConn] = useState(null); const [showConn, setShowConn] = useState(false); const live = !!(window.API && window.API.enabled); useEffect(() => { if (live && window.API.adsConn) window.API.adsConn().then(setConn).catch(() => { }); }, [live, tick]); const iso = d => { const x = new Date(d); return x.getFullYear() + '-' + String(x.getMonth() + 1).padStart(2, '0') + '-' + String(x.getDate()).padStart(2, '0'); }; const fromISO = iso(t.cur.from), toISO = iso(t.cur.to); const pfromISO = iso(t.prev.from), ptoISO = iso(t.prev.to); // L1: kéo báo cáo ads thật từ CQA theo khoảng + so sánh kỳ; phản ứng theo TimeControl. useEffect(() => { if (!live || !window.API.adsReport) { setRep(null); return; } const q = { from: fromISO, to: toISO, gran: 'day' }; if (t.compare) { q.pfrom = pfromISO; q.pto = ptoISO; } let alive = true; window.API.adsReport(q).then(r => { if (alive) setRep(r); }).catch(() => { if (alive) setRep(null); }); return () => { alive = false; }; }, [live, fromISO, toISO, t.compare, pfromISO, ptoISO, tick]); // Nút "Đồng bộ Ads API": refresh read-model mktFacts (cho các dashboard khác) + báo cáo này. const syncAds = live ? async () => { await window.API.adsSync(); if (window.API.hydrate) await window.API.hydrate(); setTick(n => n + 1); } : null; // ---- nguồn dữ liệu: report thật nếu có, ngược lại mock ---- const useLive = !!rep; let spend, msgs, leads, won, imp, reach, clicks, rows, prevT, spendSeries, seriesLabels; if (useLive) { const T = rep.current.totals; spend = T.spend; msgs = T.messages; leads = T.leads; imp = T.impressions; reach = T.reach; clicks = T.clicks; won = 0; rows = rep.current.campaigns.filter(c => platform === 'all' || c.platform === platform).map(c => ({ ...c, status: 'active', course: '', won: 0 })); prevT = rep.previous ? rep.previous.totals : null; const ser = rep.current.series; spendSeries = { cur: ser.map(s => s.spend / 1e6), prev: rep.previous ? ser.map((_, i) => (rep.previous.series[i] ? rep.previous.series[i].spend / 1e6 : 0)) : [] }; seriesLabels = ser.map(s => s.bucket.slice(8) + '/' + s.bucket.slice(5, 7)); } else { const cps = AAU.campaigns.filter(c => platform === 'all' || c.platform === platform); const sum = k => cps.reduce((s, c) => s + (c[k] || 0), 0); spend = sum('spend'); msgs = sum('messages'); leads = sum('leads'); won = sum('won'); imp = sum('impressions'); reach = sum('reach'); clicks = sum('clicks'); rows = cps; prevT = null; spendSeries = AAU.mktTrend.spend; seriesLabels = AAU.mktTrend.labels; } const orgLeads = AAU.mktFacts.filter(f => !f.campaign).reduce((s, f) => s + (f.leadsOrg || 0), 0); // delta% so với kỳ trước (chỉ khi có report + compare). lowerBetter: chi phí giảm = tốt. const dl = (cur, prevVal, lowerBetter) => { if (prevVal == null || !prevVal) return null; const pct = Math.round((cur - prevVal) / prevVal * 100); return { d: Math.abs(pct), dd: (pct >= 0) === !lowerBetter ? 'up' : 'down', raw: pct }; }; const cpl = leads ? spend / leads : 0, ctr = imp ? clicks / imp * 100 : 0, cpm = imp ? spend / imp * 1000 : 0, cpMsg = msgs ? spend / msgs : 0; const kpis = [ { l: 'Tổng chi (Spend)', v: AAU.fmtVNDm(spend), c: prevT && dl(spend, prevT.spend) }, { l: 'Impressions', v: AAU.fmtNum(imp), c: prevT && dl(imp, prevT.impressions) }, { l: 'Link Clicks', v: AAU.fmtNum(clicks), c: prevT && dl(clicks, prevT.clicks) }, { l: 'Messages', v: AAU.fmtNum(msgs), c: prevT && dl(msgs, prevT.messages) }, { l: 'CTR', v: imp ? ctr.toFixed(2) + '%' : '—', c: prevT && dl(ctr, prevT.ctr) }, { l: 'CPM', v: imp ? AAU.fmtVND(cpm) : '—', c: prevT && dl(cpm, prevT.cpm, true) }, { l: 'Cost/Message', v: msgs ? AAU.fmtVND(cpMsg) : '—', c: prevT && dl(cpMsg, prevT.costPerMsg, true) }, { l: 'Cost/Lead', v: leads ? AAU.fmtVND(cpl) : '—', c: prevT && dl(cpl, prevT.cpl, true) }, { l: 'CAC — Cost/Won (CRM)', v: won ? AAU.fmtVND(spend / won) : '—', crm: true }, { l: 'ROAS (CRM)', v: (won && spend) ? (won * 14900000 / spend).toFixed(1) + '×' : '—', crm: true }, ]; const cols = [ { key: 'name', label: 'Campaign', render: c =>
{c.name}
{AAU.courseById(c.course)?.name || (useLive ? 'chưa gắn khóa' : '')}
, csv: c => c.name }, { key: 'platform', label: 'Nền tảng', render: c => }, { key: 'spend', label: 'Spend', num: true, render: c => AAU.fmtVNDm(c.spend) }, { key: 'impressions', label: 'Impr', num: true, render: c => AAU.fmtNum(c.impressions || 0) }, { key: 'clicks', label: 'Clicks', num: true, render: c => AAU.fmtNum(c.clicks || 0) }, { key: 'messages', label: 'Msg', num: true }, { key: 'leads', label: 'Lead', num: true }, { key: 'cpl', label: 'CPL', num: true, render: c => c.leads ? AAU.mktBudget.cplAlert ? '#c4320a' : undefined }}>{AAU.fmtVND(c.spend / c.leads)} : '—' }, { key: 'won', label: 'Won', num: true, render: c => {c.won || 0} }, { key: 'roas', label: 'ROAS', num: true, render: c => (c.won && c.spend) ? {(c.won * 14900000 / c.spend).toFixed(1)}× : '—' }, ]; return (
{live && (conn && conn.connected ? : )}