/* API client + live data store ====================================== Bridges the static frontend to the PHP/MySQL backend under /api. Replaces the old localStorage + synthetic-data layer. All dashboard numbers now come from RelayLive (fed by /api/data/bootstrap); when the warehouse is empty the accessors return zeros/[] so the UI shows real empty states instead of fabricated data. =================================================================== */ /* Base URL = the directory index.html is served from, + "api". Works in a subfolder or at the domain root, same-origin (cookies flow automatically). */ const API_BASE = (function(){ let p = location.pathname; if (!p.endsWith('/')) p = p.replace(/[^/]*$/, ''); return p + 'api'; })(); async function apiFetch(path, opts){ opts = opts || {}; const res = await fetch(API_BASE + path, { credentials: 'include', headers: Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {}), method: opts.method || 'GET', body: opts.body, }); let data = null; try { data = await res.json(); } catch (e) { /* non-JSON */ } if (!res.ok) { const err = new Error((data && data.error) || ('Request failed (' + res.status + ')')); err.status = res.status; err.data = data; throw err; } return data; } const RelayAPI = { base: API_BASE, get: (p) => apiFetch(p), post: (p, b) => apiFetch(p, { method: 'POST', body: JSON.stringify(b || {}) }), put: (p, b) => apiFetch(p, { method: 'PUT', body: JSON.stringify(b || {}) }), del: (p) => apiFetch(p, { method: 'DELETE' }), auth: { me: () => apiFetch('/auth/me'), login: (email, password, remember) => apiFetch('/auth/login', { method: 'POST', body: JSON.stringify({ email, password, remember: !!remember }) }), logout: () => apiFetch('/auth/logout', { method: 'POST' }), }, users: { list: () => apiFetch('/users').then(r => r.users), create: (u) => apiFetch('/users', { method: 'POST', body: JSON.stringify(u) }).then(r => r.user), update: (id, u) => apiFetch('/users/' + id, { method: 'PUT', body: JSON.stringify(u) }).then(r => r.user), remove: (id) => apiFetch('/users/' + id, { method: 'DELETE' }), }, accounts: { list: () => apiFetch('/accounts').then(r => r.accounts), create: (a) => apiFetch('/accounts', { method: 'POST', body: JSON.stringify(a) }).then(r => r.account), update: (id, a) => apiFetch('/accounts/' + id, { method: 'PUT', body: JSON.stringify(a) }).then(r => r.account), remove: (id) => apiFetch('/accounts/' + id, { method: 'DELETE' }), }, integrations: { list: () => apiFetch('/integrations').then(r => r.integrations), create: (it) => apiFetch('/integrations', { method: 'POST', body: JSON.stringify(it) }).then(r => r.integration), update: (id, it) => apiFetch('/integrations/' + id, { method: 'PUT', body: JSON.stringify(it) }).then(r => r.integration), remove: (id) => apiFetch('/integrations/' + id, { method: 'DELETE' }), test: (id) => apiFetch('/integrations/' + id + '/test', { method: 'POST' }).then(r => r.log), sync: (id) => apiFetch('/integrations/' + id + '/sync', { method: 'POST' }).then(r => r.result), }, audit: { list: (filter) => apiFetch('/audit' + (filter && filter !== 'all' ? ('?filter=' + encodeURIComponent(filter)) : '')).then(r => r.audit), }, ingest: (payload) => apiFetch('/ingest', { method: 'POST', body: JSON.stringify(payload) }), }; /* ---------- Live data store (per account|platform bundle) ---------- */ const RelayLive = (function(){ const bundles = {}; // "acc|plat" -> bundle const inflight = {}; // "acc|plat|start|end" -> Promise const listeners = new Set(); let _version = 0; let _freshness = {}; const k = (a, p) => a + '|' + (p || 'overview'); const ymd = (ms) => { const d = new Date(ms); return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0'); }; const notify = () => { _version++; listeners.forEach(fn => fn(_version)); }; function ensure(acc, plat, dateRange){ if (!acc) return Promise.resolve(); plat = plat || 'overview'; const start = dateRange ? ymd(dateRange.start) : ''; const end = dateRange ? ymd(dateRange.end) : ''; const ik = k(acc, plat) + '|' + start + '|' + end; if (inflight[ik]) return inflight[ik]; const qs = new URLSearchParams({ account: acc, platform: plat }); if (start) qs.set('start', start); if (end) qs.set('end', end); const promise = RelayAPI.get('/data/bootstrap?' + qs.toString()) .then(b => { bundles[k(acc, plat)] = b; if (b && b.freshness) _freshness = b.freshness; notify(); return b; }) .catch(e => { bundles[k(acc, plat)] = { _error: e.message, kpis:{}, deltas:{}, spark:{}, timeseries:[], campaigns:[], creatives:[], alerts:[], platforms:[] }; notify(); }) .finally(() => { delete inflight[ik]; }); inflight[ik] = promise; return promise; } return { ensure, get: (a, p) => bundles[k(a, p)] || null, freshness: () => _freshness, subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); }, version: () => _version, clearAll: () => { Object.keys(bundles).forEach(key => delete bundles[key]); _freshness = {}; notify(); }, }; })(); /* Re-render any component when fresh data lands. */ function useDataVersion(){ const [, setV] = React.useState(RelayLive.version()); React.useEffect(() => RelayLive.subscribe(setV), []); return RelayLive.version(); } /* Ensure the bundles a view needs are loaded (overview always; + the scoped platform). Re-fetches when account, platform or date range change. */ function useEnsureData(acc, plat, dateRange){ const start = dateRange ? dateRange.start : null; const end = dateRange ? dateRange.end : null; React.useEffect(() => { RelayLive.ensure(acc, 'overview', dateRange); if (plat && plat !== 'overview') RelayLive.ensure(acc, plat, dateRange); }, [acc, plat, start, end]); } Object.assign(window, { API_BASE, RelayAPI, RelayLive, useDataVersion, useEnsureData });