// Dependency-free Mixpanel client for React Native, ported from the homepage // edge worker. TEMP: analytics is normally host-injected (CLAUDE.md → // native-only boundary); the transport lives here until base44-mobile owns an // adapter. const MIXPANEL_TRACK_ENDPOINT = 'https://api.mixpanel.com/track'; export interface MixpanelClientConfig { token: string; distinctId?: string | null; environment?: string; platform?: string; superProperties?: Record; onError?: (message: string) => void; } // Prefixes so native + web stitch in the same Mixpanel reports; applied by // `trackEditor` / `trackHome` so call sites pass a bare event name. export const AGENT_EDITOR_EVENT_PREFIX = 'Agent Editor: '; export const HOME_EVENT_PREFIX = 'Home: '; export interface MixpanelClient { /** Never throws; delivery failures route to `onError`. */ track(event: string, properties?: Record): Promise; /** `track` with the `Agent Editor: ` prefix applied to the event name (inside an agent). */ trackEditor(event: string, properties?: Record): Promise; /** `track` with the `Home: ` prefix applied to the event name (general home actions). */ trackHome(event: string, properties?: Record): Promise; identify(distinctId: string | null): void; register(props: Record): void; } // FNV-1a 32-bit: deterministic, no async crypto. function fnv1a(str: string): string { let h = 0x811c9dc5; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); } return (h >>> 0).toString(36); } const B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; // UTF-8 → base64, pure JS. Hermes lacks `TextEncoder` and its `btoa` only // handles Latin1, so we encode to UTF-8 bytes then base64 by hand. export function base64EncodeUtf8(input: string): string { const bytes: number[] = []; for (let i = 0; i < input.length; i++) { let code = input.charCodeAt(i); if (code < 0x80) { bytes.push(code); } else if (code < 0x800) { bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f)); } else if (code >= 0xd800 && code <= 0xdbff) { // High surrogate — combine with the following low surrogate. const low = input.charCodeAt(++i); code = 0x10000 + ((code & 0x3ff) << 10) + (low & 0x3ff); bytes.push( 0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f), ); } else { bytes.push( 0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f), ); } } let out = ''; for (let i = 0; i < bytes.length; i += 3) { const b0 = bytes[i]; const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0; const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0; out += B64_ALPHABET[b0 >> 2]; out += B64_ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)]; out += i + 1 < bytes.length ? B64_ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)] : '='; out += i + 2 < bytes.length ? B64_ALPHABET[b2 & 0x3f] : '='; } return out; } // Mixpanel returns HTTP 200 with `{ status: 0 }` on a rejected batch, so the // verbose body is checked too — response.ok alone would miss it. async function postEvents( events: unknown[], onError: (message: string) => void, ): Promise { const dataParam = base64EncodeUtf8(JSON.stringify(events)); const response = await fetch(`${MIXPANEL_TRACK_ENDPOINT}?verbose=1`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `data=${encodeURIComponent(dataParam)}`, }).catch((err: unknown) => { onError(`fetch failed: ${err instanceof Error ? err.message : String(err)}`); return undefined; }); if (!response) return; if (!response.ok) { onError(`rejected: status=${response.status}`); return; } const body = (await response.json().catch(() => null)) as | { status?: number; error?: string } | null; if (body && body.status !== 1) { onError(`ingestion error: ${body.error ?? 'unknown'}`); } } export function createMixpanelClient(config: MixpanelClientConfig): MixpanelClient { const { token, environment = 'prod', platform = 'mobile_native', onError = () => {}, } = config; let distinctId = config.distinctId ?? null; let superProperties = { ...(config.superProperties ?? {}) }; // Distinct $insert_id for same-millisecond events (Mixpanel dedups on it). let sequence = 0; function buildInsertId(event: string, timeSec: number): string { const seq = sequence++; const hash = fnv1a(`${distinctId ?? 'anon'}:${event}:${timeSec}:${seq}`); const prefix = (distinctId ?? 'anon').slice(0, 8); return `${prefix}-${hash}-${seq}`; } async function track(event: string, properties: Record = {}) { const timeSec = Math.floor(Date.now() / 1000); const payload = { event, properties: { token, distinct_id: distinctId, time: timeSec, $insert_id: buildInsertId(event, timeSec), environment, platform, ...superProperties, ...properties, }, }; await postEvents([payload], onError); } return { track, trackEditor(event, properties) { return track(`${AGENT_EDITOR_EVENT_PREFIX}${event}`, properties); }, trackHome(event, properties) { return track(`${HOME_EVENT_PREFIX}${event}`, properties); }, identify(next) { distinctId = next; }, register(props) { superProperties = { ...superProperties, ...props }; }, }; }