/* AAU CRM — Hệ thống: 7 màn quản trị port từ backend CQA gốc (Nguồn kết nối · AI Cost · Nhật ký hoạt động · MCP · Nhật ký thông báo · Agents · Dữ liệu mẫu) Mỗi màn map thẳng vào endpoint backend gốc — xem caption dưới tiêu đề. */ const FX_RATE = 25400; // USD→VND, đồng bộ với System Settings › Tỷ giá function nowMinus(min) { return new Date(Date.now() - min * 60000).toISOString(); } function fmtTime(iso) { const d = new Date(iso); const p = n => String(n).padStart(2, '0'); return p(d.getDate()) + '/' + p(d.getMonth() + 1) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); } /* ───────────────────────── 1 · NGUỒN KẾT NỐI (Channels) ───────────────────────── */ const SYNC_CHANNELS = [ { id: 'ch_fb', ch: 'fb', name: 'AAU Academy', handle: 'fb.com/aau.academy', status: 'connected', convs: 3420, lastSync: nowMinus(5), tokenDays: 58, mode: 'Mỗi 15 phút' }, { id: 'ch_zalo', ch: 'zalo', name: 'AAU Academy OA', handle: 'OA · 400089...', status: 'connected', convs: 1862, lastSync: nowMinus(12), tokenDays: 21, mode: 'Sau mỗi lần đồng bộ' }, { id: 'ch_ig', ch: 'instagram', name: 'aau.academy', handle: 'ig/aau.academy', status: 'error', convs: 740, lastSync: nowMinus(2880), tokenDays: -1, mode: 'Mỗi 30 phút' }, { id: 'ch_tiktok', ch: 'tiktok', name: 'TikTok @aauacademy', handle: 'chưa hỗ trợ ingest', status: 'disconnected', convs: 0, lastSync: null, tokenDays: null, mode: '—' }, ]; const SYNC_HISTORY = [ { t: nowMinus(5), kind: 'Tự động', added: 34, status: 'ok', dur: '4,2s' }, { t: nowMinus(20), kind: 'Sau đồng bộ', added: 12, status: 'ok', dur: '2,8s' }, { t: nowMinus(35), kind: 'Thủ công', added: 58, status: 'ok', dur: '6,1s' }, { t: nowMinus(95), kind: 'Tự động', added: 0, status: 'error', dur: '30s (timeout)' }, { t: nowMinus(140), kind: 'Tự động', added: 21, status: 'ok', dur: '3,5s' }, ]; const CH_STATUS = { connected: { tone: 'success', label: 'Đã kết nối', dot: true }, error: { tone: 'critical', label: 'Lỗi token' }, disconnected: { tone: 'neutral', label: 'Chưa kết nối' } }; // Form kết nối / sửa kênh — token thật do người dùng cấp; backend chỉ đánh dấu hasToken. // Có `channel` ⇒ chế độ SỬA (prefill + PUT); không có ⇒ tạo mới (POST). function ConnectChannelModal({ channel, onClose, onSubmit }) { const editing = !!channel; const [ch, setCh] = useState(channel ? channel.ch : 'zalo'); const [name, setName] = useState(channel ? channel.name || '' : ''); const [handle, setHandle] = useState(channel ? channel.handle || '' : ''); const [mode, setMode] = useState(channel ? channel.mode || 'Mỗi 15 phút' : 'Mỗi 15 phút'); const [token, setToken] = useState(''); const [busy, setBusy] = useState(false); const chOpts = [ { value: 'zalo', label: 'Zalo OA' }, { value: 'fb', label: 'Facebook Messenger' }, { value: 'instagram', label: 'Instagram' }, { value: 'tiktok', label: 'TikTok' }, ]; const modeOpts = ['Mỗi 15 phút', 'Mỗi 30 phút', 'Mỗi giờ', 'Sau mỗi lần đồng bộ', 'Thủ công'].map(m => ({ value: m, label: m })); const submit = async () => { if (!name.trim() || busy) return; setBusy(true); try { await onSubmit({ ch, name: name.trim(), handle: handle.trim(), mode, token: token.trim() }); onClose(); } finally { setBusy(false); } }; return ( }>
Token thật của Zalo/FB do bạn cấp. App chỉ đánh dấu đã có token (production chuyển cho CQA / secret store), không lưu chuỗi token trong DB.
); } function ChatChannels() { const live = !!(window.API && window.API.enabled); const [sel, setSel] = useState('ch_fb'); const [list, setList] = useState(() => SYNC_CHANNELS.map(c => ({ ...c }))); const [modal, setModal] = useState(null); // null = đóng · {} = tạo mới · {channel} = sửa const [testMsg, setTestMsg] = useState(null); const reload = () => { if (live && window.API) window.API.channels().then(cs => { if (Array.isArray(cs)) { setList(cs); setSel(cs[0] ? cs[0].id : null); } }).catch(() => {}); }; useEffect(() => { reload(); }, []); const cur = list.find(c => c.id === sel) || list[0]; const connected = list.filter(c => c.status === 'connected').length; const totalConvs = list.reduce((s, c) => s + (c.convs || 0), 0); const sync = id => { if (live) window.API.syncChannel(id).then(r => { if (r && r.channel) setList(ls => ls.map(c => c.id === id ? r.channel : c)); }).catch(() => {}); else setList(ls => ls.map(c => c.id === id ? { ...c, lastSync: new Date().toISOString(), convs: c.convs + Math.floor(Math.random() * 20) } : c)); }; const reauth = id => { if (live) window.API.reauthChannel(id).then(rc => setList(ls => ls.map(c => c.id === id ? rc : c))).catch(() => {}); else setList(ls => ls.map(c => c.id === id ? { ...c, status: 'connected', tokenDays: 90 } : c)); }; const test = id => { setTestMsg({ level: 'pending', text: 'Đang kiểm tra…' }); if (live) window.API.testChannel(id).then(r => setTestMsg({ level: r.level || (r.ok ? 'ok' : 'fail'), text: r.detail })).catch(e => setTestMsg({ level: 'fail', text: 'Lỗi: ' + e.message })); else setTestMsg({ level: 'warn', text: 'Chế độ mock — không kiểm tra kết nối thật được.' }); }; const purge = id => { const after = ls => { const n = ls.filter(c => c.id !== id); if (sel === id && n[0]) setSel(n[0].id); return n; }; if (live) window.API.purgeChannel(id).then(() => setList(after)).catch(() => {}); else setList(after); }; const create = async data => { if (live) { const nc = await window.API.createChannel(data); setList(ls => [...ls, nc]); setSel(nc.id); } else { const nc = { id: 'ch_' + Date.now(), ...data, status: 'connected', convs: 0, lastSync: null, tokenDays: 90, mode: data.mode || 'Mỗi 15 phút', syncHistory: [] }; setList(ls => [...ls, nc]); setSel(nc.id); } }; const saveEdit = async data => { const id = (modal && modal.channel && modal.channel.id) || sel; if (live) { const uc = await window.API.updateChannel(id, data); if (uc && uc.id) setList(ls => ls.map(c => c.id === uc.id ? uc : c)); } else setList(ls => ls.map(c => c.id === id ? { ...c, ...data } : c)); }; const chMeta = ch => (AAU.channels && AAU.channels[ch]) || { color: '#8a8f98', short: (ch || '?').toUpperCase(), label: ch }; const history = (cur && cur.syncHistory && cur.syncHistory.length) ? cur.syncHistory : SYNC_HISTORY; return (
{modal && setModal(null)} onSubmit={modal.channel ? saveEdit : create} />} setModal({})}>Kết nối kênh mới} />
{list.length === 0 ? ( setModal({})}>Kết nối kênh mới} /> ) : (
{list.map(c => { const st = CH_STATUS[c.status]; const meta = chMeta(c.ch); return ( setSel(c.id)} style={{ cursor: 'pointer', background: c.id === sel ? 'var(--p-bg-surface-secondary)' : undefined }}> ); })}
KênhTrạng tháiHội thoạiĐồng bộ gần nhất
{meta.short[0]}
{c.name}
{meta.label} · {c.handle}
{st.label} {c.convs ? AAU.fmtNum(c.convs) : '—'} {c.lastSync ? AAU.relTime ? relTime(c.lastSync) : fmtTime(c.lastSync) : '—'}
{CH_STATUS[cur.status].label}}>
Token
{cur.tokenDays == null ? '—' : cur.tokenDays < 0 ? 'Đã hết hạn' : 'Còn ' + cur.tokenDays + ' ngày'}
Lịch đồng bộ
{cur.mode}
{cur.status === 'error' &&
Token hết hạn. Cần Reauth để tiếp tục đồng bộ hội thoại.
}
{window.Perm && window.Perm.canDelete() && }
{testMsg && (() => { const M = { ok: ['checkCircle', '#0a7d4f', 'var(--p-success-bg)'], warn: ['alert', '#9a6700', 'var(--p-warning-bg)'], fail: ['alert', '#c4320a', 'var(--p-critical-bg)'], pending: ['refresh', '#6b7280', 'var(--p-bg-surface-secondary)'] }; const [ic, col, bg] = M[testMsg.level] || M.pending; return
{testMsg.text}
; })()}
LỊCH SỬ ĐỒNG BỘ
{history.map((h, i) => )}
Thời điểmLoạiTin mớiTrạng thái
{fmtTime(h.t)}{h.kind}{h.added}{h.status === 'ok' ? {h.dur} : {h.dur}}
)}
); } // Chọn NHIỀU nhân viên phụ trách 1 tài khoản Zalo cá nhân (popover checkbox). // Lưu ngay mỗi lần tick (gọi onSave với danh sách userId mới). function ZaloOwnerPicker({ acc, busy, onSave }) { const [open, setOpen] = useState(false); const ref = React.useRef(null); const owners = Array.isArray(acc.owners) ? acc.owners : (acc.owner ? [acc.owner] : []); useEffect(() => { if (!open) return; const onDoc = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, [open]); const users = (AAU.users || []).filter(u => u.active !== false); const nameOf = id => ((AAU.users || []).find(u => u.id === id) || {}).name || id; const toggle = id => onSave(owners.includes(id) ? owners.filter(x => x !== id) : [...owners, id]); const label = busy ? '…' : owners.length === 0 ? '— Chưa gán (chỉ admin thấy) —' : owners.length <= 2 ? owners.map(nameOf).join(', ') : owners.length + ' người phụ trách'; return (
{open && (
GÁN NHIỀU NGƯỜI PHỤ TRÁCH
{users.map(u => { const on = owners.includes(u.id); return (
toggle(u.id)} style={{ padding: '7px 8px', cursor: 'pointer', borderRadius: 7 }} onMouseEnter={e => e.currentTarget.style.background = 'var(--p-bg-surface-hover)'} onMouseLeave={e => e.currentTarget.style.background = ''}> {}} /> {u.name} {on && }
); })} {users.length === 0 &&
Chưa có người dùng.
}
)}
); } // Zalo cá nhân (2 chiều) — nối qua zalo-bridge (zca-js, KHÔNG chính thức). // Quét QR bằng app Zalo → giữ session → tin nhắn khách tự vào Inbox + tạo Lead. function ZaloPersonalSection() { const live = !!(window.API && window.API.enabled); const [accs, setAccs] = useState([]); const [offline, setOffline] = useState(false); const [modal, setModal] = useState(false); const reload = () => { if (!live || !window.API.zaloAccounts) return; window.API.zaloAccounts().then(r => { const list = Array.isArray(r) ? r : (r && r.accounts) || []; setAccs(list); setOffline(!!(r && r.bridgeOffline)); }).catch(() => { setAccs([]); setOffline(true); }); }; useEffect(() => { reload(); }, [live]); const [zbusy, setZbusy] = useState(''); const [zmsg, setZmsg] = useState(null); const remove = async (id) => { if (!window.confirm('Gỡ tài khoản Zalo này? (đăng xuất + xoá session)')) return; await window.API.zaloAccountDelete(id); reload(); }; const rename = async (id, cur) => { const name = window.prompt('Tên hiển thị cho tài khoản Zalo này:', cur || ''); if (name == null || !name.trim()) return; setZbusy('rn:' + id); try { await window.API.zaloRename(id, name.trim()); reload(); } finally { setZbusy(''); } }; const sync = async (id) => { setZbusy('sy:' + id); setZmsg(null); try { const r = await window.API.zaloSync(id); setZmsg({ level: r.ok ? 'ok' : 'fail', text: r.note || r.error || 'Đã làm mới kết nối.' }); reload(); } catch (e) { setZmsg({ level: 'fail', text: 'Đồng bộ lỗi: ' + ((e && e.message) || '') + ' — có thể cần quét QR lại.' }); } finally { setZbusy(''); } }; // Gán tài khoản Zalo cho NHIỀU nhân viên — sau khi gán, CHỈ những người đó (và // admin/manager) thấy + trả lời được hội thoại của số này trong Inbox. const assign = async (id, owners) => { const names = owners.map(oid => ((AAU.users || []).find(x => x.id === oid) || {}).name || ''); setZbusy('as:' + id); try { await window.API.zaloAssign(id, owners, names); reload(); } catch (e) { setZmsg({ level: 'fail', text: 'Gán phụ trách lỗi: ' + ((e && e.message) || '') }); } finally { setZbusy(''); } }; const STT = { connected: ['success', 'Đang nối'], error: ['critical', 'Lỗi'], expired: ['warning', 'Hết hạn — quét lại'], disconnected: ['neutral', 'Mất kết nối'] }; return ( setModal(true)} disabled={!live} title={live ? '' : 'Bật LIVE để kết nối'}>Thêm tài khoản Zalo}> {modal && setModal(false)} onConnected={reload} />} {!live ? (
Demo (MOCK). Mở app với ?api=… để kết nối.
) : (
Bản không chính thức (mô phỏng Zalo Web) — vi phạm ToS Zalo, có thể bị khóa số. Chỉ dùng số phụ chuyên sales. {offline && · zalo-bridge đang offline}
{accs.length === 0 ? ( setModal(true)}>Thêm tài khoản Zalo} /> ) : ( {accs.map(a => { const st = STT[a.status] || STT.disconnected; return ( ); })}
Tài khoảnPhụ tráchTrạng tháiĐồng bộ gần nhất
Z
{a.name || a.id}
Zalo cá nhân
assign(a.id, owners)} /> {st[1]} {a.lastSync ? fmtTime(a.lastSync) : '—'}{a.status === 'error' && a.lastError ? ' · ' + a.lastError : ''}
{a.status !== 'connected' && }
)} {zmsg && (() => { const M = { ok: ['checkCircle', '#0a7d4f', 'var(--p-success-bg)'], fail: ['alert', '#c4320a', 'var(--p-critical-bg)'] }; const [ic, col, bg] = M[zmsg.level] || M.fail; return
{zmsg.text}
; })()}
)}
); } // ZaloQRModal — bắt đầu QR login → poll trạng thái → hiện QR để quét → connected. function ZaloQRModal({ onClose, onConnected }) { const [state, setState] = useState({ status: 'starting', qr: null, error: '' }); const loginIdRef = React.useRef(null); const timerRef = React.useRef(null); useEffect(() => { let alive = true; const poll = async () => { if (!alive || !loginIdRef.current) return; try { const st = await window.API.zaloLoginStatus(loginIdRef.current); if (!alive) return; setState(st); if (st.status === 'connected') { setTimeout(() => { onConnected && onConnected(); onClose(); }, 900); return; } if (['error', 'expired', 'declined'].includes(st.status)) return; // dừng poll } catch (e) { if (alive) setState(s => ({ ...s, error: (e && e.message) || 'lỗi' })); } timerRef.current = setTimeout(poll, 1500); }; (async () => { try { const r = await window.API.zaloLoginStart(); if (!alive) return; loginIdRef.current = r.loginId; setState({ status: 'pending', qr: null, error: '' }); poll(); } catch (e) { if (alive) setState({ status: 'error', qr: null, error: (e && e.message) || 'không khởi tạo được' }); } })(); return () => { alive = false; if (timerRef.current) clearTimeout(timerRef.current); }; }, []); const LABEL = { starting: 'Đang khởi tạo…', pending: 'Đang lấy mã QR…', qr: 'Mở app Zalo → Quét QR', scanned: 'Đã quét — xác nhận trên điện thoại…', connected: 'Đã kết nối ✓', expired: 'Mã QR hết hạn', declined: 'Đã từ chối trên điện thoại', error: 'Lỗi kết nối' }; const failed = ['error', 'expired', 'declined'].includes(state.status); return ( {state.status === 'connected' ? 'Đóng' : 'Hủy'}}>
Dùng số phụ chuyên sales — bản không chính thức có rủi ro khóa số.
{state.qr && state.status === 'qr' ? ( QR Zalo ) : (
)}
{LABEL[state.status] || state.status}
{state.error &&
{state.error}
} {failed && }
App Zalo trên điện thoại → bấm + (góc phải) → Quét QR → quét mã trên. Sau khi nối, tin nhắn khách sẽ tự vào Hội thoại + tạo Lead.
); } // Form thêm/sửa 1 nguồn ads (Facebook Ad account). Có `source` ⇒ SỬA (prefill + // PUT); không có ⇒ thêm mới (POST). Test ngay khi lưu (gọi Graph API xác thực). function AdSourceModal({ source, onClose, onSaved }) { const editing = !!source; const [name, setName] = useState(source ? source.name || '' : ''); const [acct, setAcct] = useState(source ? source.adAccount || '' : ''); const [ver, setVer] = useState(source ? source.apiVersion || 'v21.0' : 'v21.0'); const [token, setToken] = useState(''); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const save = async () => { if (busy || !acct.trim()) return; if (!editing && !token.trim()) { setErr('Lần đầu cần dán Access Token (ads_read).'); return; } setBusy(true); setErr(null); try { const body = { platform: 'facebook', name: name.trim(), adAccount: acct.trim(), apiVersion: ver.trim() }; if (token.trim()) body.token = token.trim(); if (editing) await window.API.adSourceUpdate(source.id, body); else await window.API.adSourceCreate(body); onSaved(); onClose(); } catch (e) { setErr((e && e.message) || 'Không lưu được'); } finally { setBusy(false); } }; return ( }> {err &&
{err}
}
Bấm Test: app gọi Facebook xác thực token+account rồi lưu. Token cất ở kho riêng (không lộ ra API công khai).
); } // Nguồn quảng cáo (Ads) — danh sách NHIỀU nguồn FB Ads, thêm/sửa/test/đồng bộ/xóa // như kênh chat (Quyết định #2: chat = Inbox/QC · ads = đo hiệu quả Marketing). // Nguồn "Đang dùng" (active) nuôi report/sync của Ad Tracking. function AdsSourcesSection() { const live = !!(window.API && window.API.enabled); const [list, setList] = useState([]); const [sel, setSel] = useState(null); const [modal, setModal] = useState(null); // null=đóng · {}=thêm · {source}=sửa const [busy, setBusy] = useState(''); // id đang chạy thao tác const [testMsg, setTestMsg] = useState(null); const reload = () => { if (live && window.API.adSources) window.API.adSources().then(ls => { if (Array.isArray(ls)) { setList(ls); setSel(s => (ls.find(x => x.id === s) ? s : (ls[0] ? ls[0].id : null))); } }).catch(() => { }); }; useEffect(() => { reload(); }, [live]); const cur = list.find(s => s.id === sel) || list[0] || null; const afterChange = () => { setTestMsg(null); reload(); }; const sync = async (id) => { setBusy(id + ':sync'); setTestMsg(null); try { const r = await window.API.adSourceSync(id); if (window.API.hydrate) await window.API.hydrate(); setTestMsg({ level: 'ok', text: 'Đã đồng bộ' + (r && r.synced != null ? ' · ' + r.synced + ' campaign' : '') + ' từ Facebook.' }); reload(); } catch (e) { setTestMsg({ level: 'fail', text: 'Đồng bộ lỗi: ' + ((e && e.message) || 'không rõ') }); } finally { setBusy(''); } }; const test = async (id) => { setBusy(id + ':test'); setTestMsg({ level: 'pending', text: 'Đang kiểm tra…' }); try { const r = await window.API.adSourceTest(id); setTestMsg({ level: r.level || (r.ok ? 'ok' : 'fail'), text: r.detail }); reload(); } catch (e) { setTestMsg({ level: 'fail', text: 'Lỗi: ' + ((e && e.message) || 'không rõ') }); } finally { setBusy(''); } }; const activate = async (id) => { setBusy(id + ':act'); try { await window.API.adSourceActivate(id); afterChange(); } finally { setBusy(''); } }; const remove = async (id) => { if (!window.confirm('Gỡ nguồn ads này? (token sẽ bị xóa)')) return; setBusy(id + ':del'); try { await window.API.adSourceDelete(id); setSel(null); afterChange(); } finally { setBusy(''); } }; const STT = { connected: { tone: 'success', label: 'Đã kết nối', dot: true }, error: { tone: 'critical', label: 'Lỗi token' }, disconnected: { tone: 'neutral', label: 'Chưa kết nối' } }; return ( setModal({})} disabled={!live} title={live ? '' : 'Bật LIVE để thêm nguồn'}>Thêm nguồn ads}> {modal && setModal(null)} onSaved={afterChange} />} {!live ? (
Đang ở chế độ demo (MOCK). Mở app với ?api=http://localhost:8080 để quản lý nguồn ads thật.
) : list.length === 0 ? (
setModal({})}>Thêm nguồn ads} />
) : (
{list.map(s => { const st = STT[s.status] || STT.disconnected; return ( { setSel(s.id); setTestMsg(null); }} style={{ cursor: 'pointer', background: s.id === (cur && cur.id) ? 'var(--p-bg-surface-secondary)' : undefined }}> ); })}
NguồnTrạng tháiTài khoản
F
{s.name || s.adAccount}{s.active && Đang dùng}
Facebook Ads
{st.label} {s.adAccount}
{cur && ( {(STT[cur.status] || STT.disconnected).label}}>
Token
{cur.hasToken ? (cur.tokenMask || '••••') : '— chưa có'}
Đồng bộ gần nhất
{cur.lastSync ? fmtTime(cur.lastSync) : '—'}
{cur.status === 'error' && cur.lastError &&
Token lỗi. {cur.lastError} — bấm Sửa để dán token mới.
}
{!cur.active && }
{testMsg && (() => { const M = { ok: ['checkCircle', '#0a7d4f', 'var(--p-success-bg)'], warn: ['alert', '#9a6700', 'var(--p-warning-bg)'], fail: ['alert', '#c4320a', 'var(--p-critical-bg)'], pending: ['refresh', '#6b7280', 'var(--p-bg-surface-secondary)'] }; const [ic, col, bg] = M[testMsg.level] || M.pending; return
{testMsg.text}
; })()}
Token cất ở kho riêng, không lộ ra API công khai. Nguồn “Đang dùng” là tài khoản mà Ad Tracking kéo số. TikTok Ads ở card riêng bên dưới; Google sẽ thêm khi có connector.
)}
)}
); } // Nguồn TikTok Ads — kết nối qua OAuth (App TikTok for Business · Marketing API). // Kéo spend/lead cấp campaign → mktFacts (nguồn tiktok_ads) + comment dưới quảng // cáo. Organic (follower/video) ở card RIÊNG bên dưới (Display API). KHÔNG có DM. function TikTokSection() { const live = !!(window.API && window.API.enabled); const [conn, setConn] = useState(null); const [rep, setRep] = useState(null); const [busy, setBusy] = useState(''); const [msg, setMsg] = useState(null); const last30 = () => { const to = new Date(); const from = new Date(to.getTime() - 29 * 864e5); const iso = d => d.toISOString().slice(0, 10); return { from: iso(from), to: iso(to) }; }; const reload = () => { if (!live || !window.API.tiktokConn) return; window.API.tiktokConn().then(st => { setConn(st); if (st && st.connected) window.API.tiktokReport({ ...last30(), gran: 'day' }).then(r => setRep(r && r.current)).catch(() => { }); }).catch(() => { }); }; useEffect(() => { reload(); }, [live]); const connected = conn && conn.connected; const startOAuth = async () => { setBusy('oauth'); try { const r = await window.API.tiktokOAuthStart(location.href); if (r && r.authUrl) location.href = r.authUrl; else setMsg({ level: 'fail', text: 'Không lấy được link uỷ quyền (app chưa cấu hình?).' }); } catch (e) { setMsg({ level: 'fail', text: (e && e.message) || 'Lỗi mở OAuth' }); } finally { setBusy(''); } }; const switchAdv = async (id) => { setBusy('adv'); setMsg(null); try { await window.API.tiktokSetAdvertiser(id); reload(); } catch (e) { setMsg({ level: 'fail', text: 'Đổi tài khoản lỗi: ' + ((e && e.message) || '') }); } finally { setBusy(''); } }; const sync = async () => { setBusy('sync'); setMsg({ level: 'pending', text: 'Đang kéo số TikTok Ads…' }); try { const r = await window.API.tiktokSync(last30()); setMsg({ level: 'ok', text: 'Đã đồng bộ ' + (r.synced || 0) + ' campaign · chi tiêu ' + AAU.fmtNum(r.totalSpend || 0) + 'đ · ' + (r.totalLeads || 0) + ' chuyển đổi' }); reload(); } catch (e) { setMsg({ level: 'fail', text: 'Đồng bộ lỗi: ' + ((e && e.message) || '') }); } finally { setBusy(''); } }; const disconnect = async () => { if (!window.confirm('Gỡ kết nối TikTok Ads? (token bị xoá, số đã kéo vẫn giữ)')) return; setBusy('del'); try { await window.API.tiktokDisconnect(); setRep(null); reload(); } finally { setBusy(''); } }; const T = rep && rep.totals; return ( {connected ? (busy === 'sync' ? 'Đang đồng bộ…' : 'Đồng bộ ngay') : (busy === 'oauth' ? 'Đang mở…' : 'Kết nối TikTok Ads')}}> {!live ? (
Đang ở chế độ demo (MOCK). Mở app LIVE để kết nối TikTok thật.
) : !connected ? (
{busy === 'oauth' ? 'Đang mở…' : 'Kết nối TikTok Ads'}} />
) : (
Đang nối: {conn.advertiserName || conn.advertiserId} · token {conn.tokenMask}
{Array.isArray(conn.advertisers) && conn.advertisers.length > 1 && ( )} {T && (
)} {T && (T.spend || 0) === 0 &&
Tài khoản này chưa có chi tiêu trong 30 ngày qua. Nếu chạy ad ở tài khoản khác, chọn lại ở ô “Tài khoản quảng cáo đang dùng”.
} {msg && (() => { const M = { ok: ['checkCircle', '#0a7d4f', 'var(--p-success-bg)'], warn: ['alert', '#9a6700', 'var(--p-warning-bg)'], fail: ['alert', '#c4320a', 'var(--p-critical-bg)'], pending: ['refresh', '#6b7280', 'var(--p-bg-surface-secondary)'] }; const [ic, col, bg] = M[msg.level] || M.pending; return
{msg.text}
; })()}
Token cất kho riêng (không lộ ra API công khai). Đồng bộ kéo 30 ngày gần nhất (giới hạn TikTok theo ngày) vào mktFacts nguồn tiktok_ads.
)}
); } // Nguồn TikTok Organic (@kênh) — Display API (app riêng developers.tiktok.com, Login // Kit). Đọc số organic THẬT của kênh: follower/tổng like/số video + từng video // (views/like/comment/share). Token Bearer hết hạn 24h, tự refresh. KHÁC hẳn Ads. function TikTokOrganicSection() { const live = !!(window.API && window.API.enabled); const [conn, setConn] = useState(null); const [ov, setOv] = useState(null); const [vids, setVids] = useState(null); const [busy, setBusy] = useState(''); const [msg, setMsg] = useState(null); const reload = () => { if (!live || !window.API.tiktokOrganicConn) return; window.API.tiktokOrganicConn().then(st => { setConn(st); if (st && st.connected) { window.API.tiktokOrganicOverview().then(o => setOv(o && o.profile)).catch(() => { }); window.API.tiktokOrganicVideos().then(setVids).catch(() => { }); } }).catch(() => { }); }; useEffect(() => { reload(); }, [live]); const connected = conn && conn.connected; const startOAuth = async () => { setBusy('oauth'); try { const r = await window.API.tiktokOrganicOAuthStart(location.href); if (r && r.authUrl) location.href = r.authUrl; else setMsg({ level: 'fail', text: 'Chưa cấu hình app Display API (Client key/secret).' }); } catch (e) { setMsg({ level: 'fail', text: (e && e.message) || 'Lỗi mở OAuth' }); } finally { setBusy(''); } }; const refresh = async () => { setBusy('sync'); setMsg({ level: 'pending', text: 'Đang kéo số organic…' }); try { const [o, v] = await Promise.all([window.API.tiktokOrganicOverview(), window.API.tiktokOrganicVideos()]); setOv(o && o.profile); setVids(v); setMsg({ level: 'ok', text: 'Đã cập nhật: ' + AAU.fmtNum((o && o.profile && o.profile.followers) || 0) + ' follower · ' + (v && v.count || 0) + ' video gần đây' }); } catch (e) { setMsg({ level: 'fail', text: 'Kéo số lỗi: ' + ((e && e.message) || '') }); } finally { setBusy(''); } }; const disconnect = async () => { if (!window.confirm('Gỡ kết nối kênh TikTok organic?')) return; setBusy('del'); try { await window.API.tiktokOrganicDisconnect(); setOv(null); setVids(null); reload(); } finally { setBusy(''); } }; return ( {connected ? (busy === 'sync' ? 'Đang kéo…' : 'Cập nhật số') : (busy === 'oauth' ? 'Đang mở…' : 'Kết nối kênh TikTok')}}> {!live ? (
Đang ở chế độ demo (MOCK). Mở app LIVE để kết nối.
) : !connected ? (
{busy === 'oauth' ? 'Đang mở…' : 'Kết nối kênh TikTok'}} />
) : (
Đang nối kênh: {conn.displayName || conn.openId || 'TikTok'}{conn.tokenExpiresInH != null ? ' · token còn ' + conn.tokenExpiresInH + 'h (tự gia hạn)' : ''}
{ov && (
)} {vids && vids.count > 0 && ( )} {vids && Array.isArray(vids.videos) && vids.videos.length > 0 && ( {vids.videos.slice(0, 10).map((v, i) => ( ))}
VideoViewsLikeCommentShareER%
{v.url ? {v.title || '(không tiêu đề)'} : (v.title || '(không tiêu đề)')} {AAU.fmtNum(v.views || 0)} {AAU.fmtNum(v.likes || 0)} {AAU.fmtNum(v.comments || 0)} {AAU.fmtNum(v.shares || 0)} {(v.engRate || 0).toFixed(1)}
)} {msg && (() => { const M = { ok: ['checkCircle', '#0a7d4f', 'var(--p-success-bg)'], warn: ['alert', '#9a6700', 'var(--p-warning-bg)'], fail: ['alert', '#c4320a', 'var(--p-critical-bg)'], pending: ['refresh', '#6b7280', 'var(--p-bg-surface-secondary)'] }; const [ic, col, bg] = M[msg.level] || M.pending; return
{msg.text}
; })()}
Token Bearer cất kho riêng (private), hết hạn 24h → hệ thống tự refresh bằng refresh_token. Số organic đọc trực tiếp qua Display API (open.tiktokapis.com).
)}
); } // Nguồn Page & Nội dung (Organic) — 1 token System User → khám phá MỌI tài sản // (Page · Instagram · Ad account) rồi bật từng kết nối. Nuôi L3 Content Analytics // (Page/Post Insights). Tách khỏi "Nguồn quảng cáo" (chỉ ads) ở trên. function PageContentSection() { const live = !!(window.API && window.API.enabled); const [conn, setConn] = useState(null); const [modal, setModal] = useState(false); const [busy, setBusy] = useState(''); const [msg, setMsg] = useState(null); // {level, text} const reload = () => { if (live && window.API.connList) window.API.connList().then(setConn).catch(() => { }); }; useEffect(() => { reload(); }, [live]); const connected = conn && conn.connected; const assets = (conn && conn.assets) || []; const grp = { page: assets.filter(a => a.type === 'page'), ig: assets.filter(a => a.type === 'ig'), ad: assets.filter(a => a.type === 'ad') }; const disconnect = async () => { if (!window.confirm('Gỡ token Page và toàn bộ kết nối nội dung?')) return; await window.API.connDisconnect(); reload(); }; const removeOne = async (id) => { setBusy('rm:' + id); setMsg(null); try { await window.API.connSelect(assets.filter(a => a.id !== id)); reload(); } catch (e) { setMsg({ level: 'fail', text: 'Bỏ kết nối lỗi: ' + ((e && e.message) || '') }); } finally { setBusy(''); } }; const checkToken = async () => { setBusy('check'); setMsg({ level: 'pending', text: 'Đang kiểm tra token với Facebook…' }); try { const r = await window.API.connDiscover({}); // quét lại bằng token đã lưu setMsg({ level: r.permanent ? 'ok' : 'warn', text: r.permanent ? `Token còn tốt — vĩnh viễn · thấy ${r.counts.pages} Page · ${r.counts.instagram} IG · ${r.counts.ads} Ad.` : `Token còn dùng được nhưng CÓ HẠN — nên đổi sang System User. Thấy ${r.counts.pages} Page.` }); reload(); } catch (e) { setMsg({ level: 'fail', text: 'Token lỗi/hết hạn: ' + ((e && e.message) || '') + ' — bấm “Sửa / dán token” để cập nhật.' }); } finally { setBusy(''); } }; const syncContent = async () => { if (!grp.page.length) { setMsg({ level: 'warn', text: 'Chưa bật Page nào để đồng bộ.' }); return; } setBusy('sync'); setMsg({ level: 'pending', text: 'Đang kéo bài viết & insights (full) từ ' + grp.page.length + ' Page…' }); let total = 0, errs = 0; for (const pg of grp.page) { try { const r = await window.API.contentInsights({ pageId: pg.id, limit: 100 }); total += ((r && r.posts) || []).length; } catch (e) { errs++; } } setMsg({ level: errs ? 'warn' : 'ok', text: `Đã kéo ${total} bài từ ${grp.page.length} Page` + (errs ? ` · ${errs} page lỗi (token?)` : '') + '. (Sẽ cắm vào dashboard Content Analytics ở bản sau.)' }); setBusy(''); }; return ( setModal(true)} disabled={!live} title={live ? '' : 'Bật LIVE để kết nối'}>{connected ? 'Quét lại' : 'Khám phá kết nối'}}> {modal && setModal(false)} onSaved={reload} />} {!live ? (
Demo (MOCK). Mở app với ?api=… để kết nối Page thật.
) : !connected ? (
setModal(true)}>Khám phá kết nối} />
) : (
Token {conn.tokenMask} · {conn.tokenType || 'token'} · {conn.expiresAt === 0 ? Vĩnh viễn : Có hạn} · {assets.length} kết nối đã bật
{[['page', 'Page (Organic + Inbox)', '#0866ff', 'F'], ['ig', 'Instagram', '#d62976', 'IG'], ['ad', 'Ad account', '#0a7d4f', '$']].map(([k, label, col, ic]) => ( {grp[k].length === 0 ?
— chưa bật
: grp[k].map(a => (
{ic}
{a.name}
{(a.kinds || []).join(' · ')}
))}
))}
{msg && (() => { const M = { ok: ['checkCircle', '#0a7d4f', 'var(--p-success-bg)'], warn: ['alert', '#9a6700', 'var(--p-warning-bg)'], fail: ['alert', '#c4320a', 'var(--p-critical-bg)'], pending: ['refresh', '#6b7280', 'var(--p-bg-surface-secondary)'] }; const [ic, col, bg] = M[msg.level] || M.pending; return
{msg.text}
; })()}
)}
); } // DiscoverModal — dán token → quét tài sản → tick chọn → lưu registry. function DiscoverModal({ conn, onClose, onSaved }) { const [token, setToken] = useState(''); const [ver, setVer] = useState('v21.0'); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const [res, setRes] = useState(null); // kết quả quét const [picked, setPicked] = useState({}); // id → true const had = conn && conn.connected; const scan = async () => { if (busy || (!token.trim() && !had)) return; setBusy(true); setErr(null); try { const r = await window.API.connDiscover({ token: token.trim(), apiVersion: ver.trim() }); setRes(r); const p = {}; // Đang sửa (đã có kết nối) → pre-check theo lựa chọn hiện tại; lần đầu → bật hết page+IG. const enabled = had ? new Set(((conn && conn.assets) || []).map(a => a.id)) : null; [...(r.pages || []), ...(r.instagram || [])].forEach(a => { p[a.id] = enabled ? enabled.has(a.id) : true; }); (r.ads || []).forEach(a => { if (enabled && enabled.has(a.id)) p[a.id] = true; }); setPicked(p); } catch (e) { setErr((e && e.message) || 'Quét thất bại'); } finally { setBusy(false); } }; const save = async () => { if (busy || !res) return; setBusy(true); setErr(null); const all = [...(res.pages || []), ...(res.instagram || []), ...(res.ads || [])]; const sel = all.filter(a => picked[a.id]).map(a => ({ ...a, enabled: true })); try { await window.API.connSelect(sel); onSaved(); onClose(); } catch (e) { setErr((e && e.message) || 'Lưu thất bại'); setBusy(false); } }; const toggle = (id) => setPicked(p => ({ ...p, [id]: !p[id] })); const Group = ({ title, col, ic, items }) => !items || !items.length ? null : (
{title} · {items.length}
{items.map(a => ( ))}
); return ( {!res ? : } }> {!res ? ( <>
App gọi Facebook liệt kê mọi Page/Instagram/Ad account token nhìn thấy. Token lưu kho riêng (không lộ ra API công khai). Tạo System User token ở Business Settings → System Users → Generate (Never expire).
) : ( <>
{res.tokenType || 'token'} · {res.permanent ? 'KHÔNG hết hạn ✅' : 'CÓ hạn — nên đổi System User'} · tìm thấy {res.counts.pages} Page · {res.counts.instagram} IG · {res.counts.ads} Ad account
)} {err &&
{err}
}
); } // Nguồn Instagram (Organic) — CỔNG RIÊNG: 1 token IG riêng (instagram_basic + // instagram_manage_insights) → khám phá IG business account, đọc insight THẬT: // account (follower/reach/profile views) + per-post (like/comment/save/share) + // Reels & Story tách riêng. Tách hẳn cổng Page ở trên (token + registry riêng). function IGContentSection() { const live = !!(window.API && window.API.enabled); const [conn, setConn] = useState(null); const [modal, setModal] = useState(false); const [busy, setBusy] = useState(''); const [msg, setMsg] = useState(null); // {level, text} const [stats, setStats] = useState(null); // kết quả đồng bộ gần nhất (per igId) const reload = () => { if (live && window.API.igConnList) window.API.igConnList().then(setConn).catch(() => { }); }; useEffect(() => { reload(); }, [live]); const connected = conn && conn.connected; const accounts = (conn && conn.assets) || []; const disconnect = async () => { if (!window.confirm('Gỡ token Instagram và toàn bộ kết nối IG?')) return; await window.API.igDisconnect(); setStats(null); reload(); }; const removeOne = async (id) => { setBusy('rm:' + id); setMsg(null); try { await window.API.igSelect(accounts.filter(a => a.id !== id)); reload(); } catch (e) { setMsg({ level: 'fail', text: 'Bỏ kết nối lỗi: ' + ((e && e.message) || '') }); } finally { setBusy(''); } }; const checkToken = async () => { setBusy('check'); setMsg({ level: 'pending', text: 'Đang kiểm tra token IG với Facebook…' }); try { const r = await window.API.igDiscover({}); // quét lại bằng token đã lưu setMsg({ level: r.permanent ? 'ok' : 'warn', text: r.permanent ? `Token còn tốt — vĩnh viễn · thấy ${r.counts.instagram} tài khoản IG.` : `Token còn dùng được nhưng CÓ HẠN — nên đổi sang System User. Thấy ${r.counts.instagram} IG.` }); reload(); } catch (e) { setMsg({ level: 'fail', text: 'Token lỗi/hết hạn: ' + ((e && e.message) || '') + ' — bấm "Sửa / dán token" để cập nhật.' }); } finally { setBusy(''); } }; const syncIG = async () => { if (!accounts.length) { setMsg({ level: 'warn', text: 'Chưa bật tài khoản IG nào để đồng bộ.' }); return; } setBusy('sync'); setMsg({ level: 'pending', text: 'Đang kéo account + media + reels + story insights từ ' + accounts.length + ' IG…' }); setStats(null); const rows = []; let errs = 0; for (const ig of accounts) { try { const r = await window.API.igInsights({ igId: ig.id, limit: 50 }); rows.push({ id: ig.id, username: r.username || ig.username, acc: r.account || {}, c: r.counts || {} }); } catch (e) { errs++; } } setStats(rows); setMsg({ level: errs ? 'warn' : 'ok', text: `Đã kéo insight ${rows.length}/${accounts.length} IG` + (errs ? ` · ${errs} lỗi (token thiếu quyền insight?)` : '') + '.' }); setBusy(''); }; const fmtN = (n) => n == null ? '—' : Math.round(n).toLocaleString('vi-VN'); return ( setModal(true)} disabled={!live} title={live ? '' : 'Bật LIVE để kết nối'}>{connected ? 'Quét lại' : 'Khám phá IG'}}> {modal && setModal(false)} onSaved={reload} />} {!live ? (
Demo (MOCK). Mở app với ?api=… để kết nối Instagram thật.
) : !connected ? (
setModal(true)}>Khám phá IG} />
) : (
Token {conn.tokenMask} · {conn.tokenType || 'token'} · {conn.expiresAt === 0 ? Vĩnh viễn : Có hạn} · {accounts.length} tài khoản IG đã bật
{accounts.length === 0 ?
— chưa bật tài khoản IG nào (bấm "Sửa lựa chọn").
: (
{accounts.map(a => { const s = stats && stats.find(x => x.id === a.id); return ( removeOne(a.id)} disabled={busy === 'rm:' + a.id} title="Bỏ kết nối này" />}>
IG
{(a.kinds || []).join(' · ')} · {fmtN(a.followers)} follower
{s && (
Reach 30 ngày{fmtN(s.acc.reach_30d)}
Lượt xem hồ sơ 30 ngày{fmtN(s.acc.profile_views_30d)}
Click website 30 ngày{fmtN(s.acc.website_clicks_30d)}
Bài · Reels · Story{fmtN(s.c.posts)} · {fmtN(s.c.reels)} · {fmtN(s.c.stories)}
)}
); })}
)} {msg && (() => { const M = { ok: ['checkCircle', '#0a7d4f', 'var(--p-success-bg)'], warn: ['alert', '#9a6700', 'var(--p-warning-bg)'], fail: ['alert', '#c4320a', 'var(--p-critical-bg)'], pending: ['refresh', '#6b7280', 'var(--p-bg-surface-secondary)'] }; const [ic, col, bg] = M[msg.level] || M.pending; return
{msg.text}
; })()}
)}
); } // IGDiscoverModal — dán token IG → quét IG business account → tick chọn → lưu registry. function IGDiscoverModal({ conn, onClose, onSaved }) { const [token, setToken] = useState(''); const [ver, setVer] = useState('v21.0'); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const [res, setRes] = useState(null); const [picked, setPicked] = useState({}); const had = conn && conn.connected; const scan = async () => { if (busy || (!token.trim() && !had)) return; setBusy(true); setErr(null); try { const r = await window.API.igDiscover({ token: token.trim(), apiVersion: ver.trim() }); setRes(r); const enabled = had ? new Set(((conn && conn.assets) || []).map(a => a.id)) : null; const p = {}; (r.instagram || []).forEach(a => { p[a.id] = enabled ? enabled.has(a.id) : true; }); setPicked(p); } catch (e) { setErr((e && e.message) || 'Quét thất bại'); } finally { setBusy(false); } }; const save = async () => { if (busy || !res) return; setBusy(true); setErr(null); const sel = (res.instagram || []).filter(a => picked[a.id]).map(a => ({ ...a, enabled: true })); try { await window.API.igSelect(sel); onSaved(); onClose(); } catch (e) { setErr((e && e.message) || 'Lưu thất bại'); setBusy(false); } }; const toggle = (id) => setPicked(p => ({ ...p, [id]: !p[id] })); return ( {!res ? : } }> {!res ? ( <>
IG business account phải liên kết 1 Page Facebook. App đi qua Page để liệt kê IG. Token lưu kho RIÊNG (tách cổng Page, không lộ ra API công khai). Tạo System User token ở Business Settings → System Users → Generate (Never expire), gán quyền instagram_basic + instagram_manage_insights.
) : ( <>
{res.tokenType || 'token'} · {res.permanent ? 'KHÔNG hết hạn ✅' : 'CÓ hạn — nên đổi System User'} · tìm thấy {res.counts.instagram} tài khoản IG
{!res.instagram || !res.instagram.length ? (
Token không thấy IG business account nào. Kiểm tra: IG đã chuyển sang tài khoản Business/Creator và liên kết Page chưa? Token có quyền instagram_basic chưa?
) : (
Tài khoản Instagram · {res.instagram.length}
{res.instagram.map(a => ( ))}
)} )} {err &&
{err}
}
); } // Nguồn Threads (Organic) — CỔNG RIÊNG: host graph.threads.net, token riêng // (threads_basic + threads_manage_insights). KHÁC FB/IG: token Threads sống 60 // ngày (làm mới được), KHÔNG có System User vĩnh viễn. Đọc insight THẬT: account // (followers + views/likes/replies/reposts/quotes 30 ngày) + per-post. Threads // API KHÔNG có inbox/DM cho dev → cổng này chỉ organic; ads Threads đo qua cổng // FB Ads (breakdown nền tảng). function ThreadsContentSection() { const live = !!(window.API && window.API.enabled); const [conn, setConn] = useState(null); const [modal, setModal] = useState(false); const [busy, setBusy] = useState(''); const [msg, setMsg] = useState(null); // {level, text} const [stats, setStats] = useState(null); // kết quả đồng bộ gần nhất const [appId, setAppId] = useState(''); const [appSecret, setAppSecret] = useState(''); const reload = () => { if (live && window.API.threadsConnList) window.API.threadsConnList().then(c => { setConn(c); if (c && c.appId) setAppId(c.appId); }).catch(() => { }); }; useEffect(() => { reload(); }, [live]); // bắt kết quả OAuth khi Threads redirect về (?threads=connected|error) useEffect(() => { const q = new URLSearchParams(location.search); if (q.get('threads') === 'connected') setMsg({ level: 'ok', text: 'Đã kết nối Threads qua OAuth — token dài hạn đã lưu.' }); if (q.get('threads') === 'error') setMsg({ level: 'fail', text: 'Uỷ quyền Threads bị huỷ hoặc lỗi — thử lại.' }); }, []); const connected = conn && conn.connected; const accounts = (conn && conn.assets) || []; const startOAuth = async () => { setBusy('oauth'); setMsg(null); try { // lưu App ID/Secret trước (nếu vừa nhập), rồi lấy authUrl và chuyển hướng if (appId.trim() || appSecret.trim()) await window.API.threadsOAuthConfig({ appId: appId.trim(), appSecret: appSecret.trim() }); const r = await window.API.threadsOAuthStart(location.href); if (r && r.authUrl) location.href = r.authUrl; else setMsg({ level: 'fail', text: 'Không lấy được link uỷ quyền (thiếu App ID/Secret?).' }); } catch (e) { setMsg({ level: 'fail', text: (e && e.message) || 'Lỗi mở OAuth' }); setBusy(''); } }; const disconnect = async () => { if (!window.confirm('Gỡ token Threads và toàn bộ kết nối?')) return; await window.API.threadsDisconnect(); setStats(null); reload(); }; const removeOne = async (id) => { setBusy('rm:' + id); setMsg(null); try { await window.API.threadsSelect(accounts.filter(a => a.id !== id)); reload(); } catch (e) { setMsg({ level: 'fail', text: 'Bỏ kết nối lỗi: ' + ((e && e.message) || '') }); } finally { setBusy(''); } }; const refreshToken = async () => { setBusy('refresh'); setMsg({ level: 'pending', text: 'Đang làm mới token Threads (gia hạn 60 ngày)…' }); try { const r = await window.API.threadsRefresh(); const days = r.expiresIn ? Math.round(r.expiresIn / 86400) : 60; setMsg({ level: 'ok', text: `Đã làm mới token — còn ~${days} ngày.` }); reload(); } catch (e) { setMsg({ level: 'fail', text: 'Làm mới lỗi (token <24h tuổi hoặc đã hết hạn?): ' + ((e && e.message) || '') }); } finally { setBusy(''); } }; const syncThreads = async () => { if (!accounts.length) { setMsg({ level: 'warn', text: 'Chưa bật tài khoản Threads nào để đồng bộ.' }); return; } setBusy('sync'); setMsg({ level: 'pending', text: 'Đang kéo account + bài đăng insights từ ' + accounts.length + ' Threads…' }); setStats(null); const rows = []; let errs = 0; for (const t of accounts) { try { const r = await window.API.threadsInsights({ userId: t.id, limit: 50 }); rows.push({ id: t.id, username: r.username || t.username, acc: r.account || {}, c: r.counts || {} }); } catch (e) { errs++; } } setStats(rows); setMsg({ level: errs ? 'warn' : 'ok', text: `Đã kéo insight ${rows.length}/${accounts.length} Threads` + (errs ? ` · ${errs} lỗi (token thiếu quyền insight?)` : '') + '.' }); setBusy(''); }; const fmtN = (n) => n == null ? '—' : Math.round(n).toLocaleString('vi-VN'); const expLabel = conn && conn.expiresAt ? new Date(conn.expiresAt * 1000).toLocaleDateString('vi-VN') : null; return ( setModal(true)} disabled={!live} title={live ? '' : 'Bật LIVE để kết nối'}>{connected ? 'Quét lại' : 'Khám phá Threads'}}> {modal && setModal(false)} onSaved={reload} />} {!live ? (
Demo (MOCK). Mở app với ?api=… để kết nối Threads thật.
) : !connected ? (
Nối Threads bằng OAuth 1 bấm: dán ID + Khoá bí mật ứng dụng Threads (Meta App → use case "Threads API") → bấm Kết nối → uỷ quyền → token dài hạn tự lưu. Không cần lấy token tay.
Quan trọng: trong app Threads, ô "Chuyển hướng URL gọi lại" phải điền ĐÚNG: {(conn && conn.redirectUri) || 'https://crm.aauacademy.com/marketing/threads/oauth/callback'} — nếu lệch sẽ lỗi khi uỷ quyền. Trước khi nối, vào app bật quyền threads_basic + threads_manage_insights và thêm tài khoản Threads của bạn làm Tester.
{msg && (() => { const M = { ok: ['checkCircle', '#0a7d4f', 'var(--p-success-bg)'], warn: ['alert', '#9a6700', 'var(--p-warning-bg)'], fail: ['alert', '#c4320a', 'var(--p-critical-bg)'], pending: ['refresh', '#6b7280', 'var(--p-bg-surface-secondary)'] }; const [ic, col, bg] = M[msg.level] || M.pending; return
{msg.text}
; })()}
) : (
Token {conn.tokenMask} · {expLabel ? Hết hạn {expLabel} : 60 ngày (làm mới được)} · {accounts.length} tài khoản đã bật
{accounts.length === 0 ?
— chưa bật tài khoản Threads nào (bấm "Sửa lựa chọn").
: (
{accounts.map(a => { const s = stats && stats.find(x => x.id === a.id); return ( removeOne(a.id)} disabled={busy === 'rm:' + a.id} title="Bỏ kết nối này" />}>
@
@{a.username} · {(a.kinds || []).join(' · ')}
{s && (
Followers{fmtN(s.acc.followers)}
Lượt xem 30 ngày{fmtN(s.acc.views_30d)}
Like · Reply 30 ngày{fmtN(s.acc.likes_30d)} · {fmtN(s.acc.replies_30d)}
Số bài kéo về{fmtN(s.c.posts)}
)}
); })}
)} {msg && (() => { const M = { ok: ['checkCircle', '#0a7d4f', 'var(--p-success-bg)'], warn: ['alert', '#9a6700', 'var(--p-warning-bg)'], fail: ['alert', '#c4320a', 'var(--p-critical-bg)'], pending: ['refresh', '#6b7280', 'var(--p-bg-surface-secondary)'] }; const [ic, col, bg] = M[msg.level] || M.pending; return
{msg.text}
; })()}
)}
); } // ThreadsDiscoverModal — dán token Threads → /me lấy profile → tick chọn → lưu registry. function ThreadsDiscoverModal({ conn, onClose, onSaved }) { const [token, setToken] = useState(''); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const [res, setRes] = useState(null); const [picked, setPicked] = useState({}); const had = conn && conn.connected; const scan = async () => { if (busy || (!token.trim() && !had)) return; setBusy(true); setErr(null); try { const r = await window.API.threadsDiscover({ token: token.trim() }); setRes(r); const enabled = had ? new Set(((conn && conn.assets) || []).map(a => a.id)) : null; const p = {}; (r.threads || []).forEach(a => { p[a.id] = enabled ? enabled.has(a.id) : true; }); setPicked(p); } catch (e) { setErr((e && e.message) || 'Quét thất bại'); } finally { setBusy(false); } }; const save = async () => { if (busy || !res) return; setBusy(true); setErr(null); const sel = (res.threads || []).filter(a => picked[a.id]).map(a => ({ ...a, enabled: true })); try { await window.API.threadsSelect(sel); onSaved(); onClose(); } catch (e) { setErr((e && e.message) || 'Lưu thất bại'); setBusy(false); } }; const toggle = (id) => setPicked(p => ({ ...p, [id]: !p[id] })); return ( {!res ? : } }> {!res ? ( <>
Threads dùng OAuth RIÊNG (host graph.threads.net, KHÔNG phải graph.facebook.com). Lấy long-lived token ở Meta App → use case "Threads API" → đổi token short-lived sang long-lived (60 ngày). Token lưu kho RIÊNG (không lộ ra API công khai). Lưu ý: Threads KHÔNG có System User vĩnh viễn — dùng nút "Làm mới token" để gia hạn.
) : ( <>
Token Threads CÓ hạn 60 ngày (làm mới được) · tìm thấy {res.counts.threads} tài khoản
{!res.threads || !res.threads.length ? (
Token không thấy tài khoản Threads. Kiểm tra: token có quyền threads_basic chưa? Tài khoản đã bật Threads chưa?
) : (
Tài khoản Threads · {res.threads.length}
{res.threads.map(a => ( ))}
)} )} {err &&
{err}
}
); } // Nguồn Website (GA4) — đo TRAFFIC & HÀNH VI website thật qua Google Analytics 4 // Data API. Dán service account JSON + Property ID (dãy số, KHÔNG phải G-XXXX) → // backend ký JWT → kéo sessions/engagement/CVR về webFacts. Tách khỏi Ads/Page. function WebConnectModal({ status, onClose, onSaved }) { const editing = !!(status && status.connected); const [propertyId, setPropertyId] = useState(status ? (status.propertyId || '') : ''); const [sa, setSa] = useState(''); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const save = async () => { if (busy || !propertyId.trim()) return; setBusy(true); setErr(null); try { await window.API.webConnect({ propertyId: propertyId.trim(), serviceAccount: sa.trim() }); onSaved(); onClose(); } catch (e) { setErr((e && e.message) || 'Kết nối thất bại'); setBusy(false); } }; return ( }>