diff --git a/packages/audience/sdk-sample-app/README.md b/packages/audience/sdk-sample-app/README.md new file mode 100644 index 0000000000..97b9938db1 --- /dev/null +++ b/packages/audience/sdk-sample-app/README.md @@ -0,0 +1,65 @@ +# @imtbl/audience-sdk-sample-app + +Single-page interactive harness that exercises every public method on the `Audience` class against the real Immutable backend. + +## Run + +```sh +cd packages/audience/sdk-sample-app +pnpm dev +``` + +This builds `@imtbl/audience` (ESM + CDN bundle + types) and then serves this sample app on `http://localhost:3456`. Open that URL directly — `index.html` is the entry point. The sample-app's local `serve.mjs` routes `/vendor/imtbl-audience.global.js` to the CDN bundle in `../sdk/dist/cdn/`, so the sdk's build output stays the single source of truth. + +Stop the server with `Ctrl+C`. + +## Test publishable keys + +These are test-only keys registered for audience tracking. Safe to commit and share. + +| Environment | Key | +|---|---| +| `dev` | `pk_imapik-test-Xei06NzJZClzQogVXlKQ` | +| `sandbox` | `pk_imapik-test-5ss4GpFy-n@$$Ye3LSox` | + +## What to try + +1. Paste a key, pick `sandbox`, set initial consent to `anonymous`, click **Init**. +2. Watch the event log: you'll see `INIT`, `TRACK session_start`, and `FLUSH ok`. Check the browser DevTools Network tab — `POST /v1/audience/messages` should return 200. +3. Click **Call page()** with no properties → `PAGE` entry + 200 response. +4. Enter `{"section":"marketplace"}` in the page properties textarea → `PAGE {section: marketplace}`. +5. Track a custom event with properties → `TRACK`. +6. Set consent to `full` → `PUT /v1/audience/tracking-consent` returns 204. +7. Identify a user (any made-up ID, type `passport`, optional traits) → status bar's User ID updates. +8. Try Alias with a Steam ID → Passport ID → `ALIAS` entry. +9. Click **Reset** → anonymous ID rotates, session end + start fire. +10. Click **Shutdown** → session end fires, buttons flip off. + +## Environments + +| Env | API URL | Consent PUT | +|---|---|---| +| `dev` | `api.dev.immutable.com` | **known broken — returns 500.** Use `sandbox` to exercise consent sync. | +| `sandbox` | `api.sandbox.immutable.com` | works | + +## Troubleshooting + +- **`window.ImmutableAudience is undefined`** in the demo page: the CDN bundle failed to load. Re-run `pnpm dev` from `packages/audience/sdk-sample-app` and confirm `../sdk/dist/cdn/imtbl-audience.global.js` exists. +- **`POST /v1/audience/messages` returns 400**: the publishable key format is wrong. Must start with `pk_imapik-`. +- **`POST /v1/audience/messages` returns 403**: the key isn't registered for audience tracking on the backend. Use one of the keys in the table above. +- **Identify button is a no-op**: consent is not `full`. Click **Set full** first. +- **No events in BigQuery after 30s**: events go through SQS → Pub/Sub → BigQuery. BQ access requires `roles/bigquery.dataViewer` on `dev-im-cdp`. If you don't have it, the API ack (`POST /messages` 200) is your E2E confirmation. + +## Files + +``` +sdk-sample-app/ + index.html # single page, loads /vendor/imtbl-audience.global.js + demo.js # vanilla ES2020, no modules; reads window.ImmutableAudience + demo.css # light theme, hand-written CSS, no external deps + serve.mjs # tiny Node static server; routes /vendor/ to ../sdk/dist/cdn/ + package.json # private workspace package; @imtbl/audience as workspace dep + README.md # this file +``` + +Security: all user-controlled inputs (event names, traits, publishable keys) are rendered via `textContent` / `createElement`. No `innerHTML` anywhere on user data. The CSP meta tag restricts `connect-src` to the dev and sandbox audience API origins only (`api.dev.immutable.com`, `api.sandbox.immutable.com`). `@imtbl/metrics` SDK telemetry is bundled into the CDN and posts to `api.immutable.com`; those calls will be blocked by the browser with a CSP violation log, which is intentional and does not affect demo behavior. diff --git a/packages/audience/sdk-sample-app/demo.css b/packages/audience/sdk-sample-app/demo.css new file mode 100644 index 0000000000..276953615d --- /dev/null +++ b/packages/audience/sdk-sample-app/demo.css @@ -0,0 +1,396 @@ +:root { + /* Palette from @biom3/design-tokens base-onLight.global.css */ + --bg: #f7f8fa; /* was #ffffff — subtle cool off-white so panels can float */ + --panel-bg: #ffffff; /* was #f1f1f1 — panels are pure white to pop against bg */ + --panel-border: #e6e8ec; /* was #e7e7e7 — slightly cooler */ + --panel-border-hover: #0d0d0d1a; /* translucent-standard-200 */ + --text: #131313; /* brand-1 */ + --text-muted: #868686; /* brand-4 */ + --text-dim: #a8a8a8; /* mid-gray — brand-3 (#E0E0E0) is illegible on white */ + --accent: #131313; /* near-black, matches biom3 brand-1 / Button primary */ + --accent-hover: #2a2a2a; /* slightly lighter black on hover */ + --accent-active: #000000; /* pure black on active */ + --ok: #148530; /* status-success-bright (onLight) */ + --warn: #a07a00; /* attention amber — FBFF6D is illegible on white; dark amber for legibility */ + --err: #bb3049; /* status-fatal-bright (onLight) */ + --input-bg: #ffffff; /* was dark — inputs are white like cards */ + --input-border: #d4d7dd; /* was dark — light gray border */ + --mono: 'Roboto Mono', ui-monospace, 'SF Mono', Menlo, Monaco, Consolas, monospace; + --sans: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + + /* Elevation / shadow — synthesized to match biom3 light-mode aesthetic. + biom3 exposes these via component props, not design tokens, so we + approximate them with the conventional material-design-esque + subtle-light-blur values. */ + --shadow-panel: 0 1px 3px rgba(17, 24, 39, 0.06), 0 1px 2px rgba(17, 24, 39, 0.04); + --shadow-panel-hover: 0 4px 12px rgba(17, 24, 39, 0.08); + --shadow-button: 0 1px 2px rgba(17, 24, 39, 0.06); + --shadow-button-hover: 0 2px 6px rgba(17, 24, 39, 0.1); + --shadow-input-inset: inset 0 1px 2px rgba(17, 24, 39, 0.03); +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + line-height: 1.5; +} + +main { + max-width: 880px; + margin: 0 auto; + padding: 24px 16px 64px; +} + +header { + margin-bottom: 16px; +} + +h1 { + font-size: 28px; + font-weight: 700; + letter-spacing: -0.01em; + margin: 0 0 6px; +} + +.status-bar { + background: var(--panel-bg); + border: 1px solid var(--panel-border); + border-radius: 12px; + padding: 14px 20px; + margin-bottom: 16px; + display: flex; + flex-wrap: wrap; + gap: 20px; + font-size: 13px; + font-family: var(--mono); + box-shadow: var(--shadow-panel); +} + +.status-bar > div { + display: flex; + gap: 6px; + align-items: center; +} + +.status-label { + color: var(--text-muted); + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.04em; +} + +.status-value { + color: var(--text); + word-break: break-all; +} + +.status-value.dim { + color: var(--text-dim); +} + +.panel { + background: var(--panel-bg); + border: 1px solid var(--panel-border); + border-radius: 12px; + padding: 20px 24px; + margin-bottom: 16px; + box-shadow: var(--shadow-panel); +} + +.panel-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + margin: 0 0 16px; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; +} + +.field label { + font-size: 12px; + color: var(--text-muted); + font-weight: 500; + letter-spacing: 0.01em; + text-transform: uppercase; +} + +.field input[type="text"], +.field input[type="number"], +.field textarea, +.field select { + background: var(--input-bg); + border: 1px solid var(--input-border); + color: var(--text); + padding: 10px 12px; + border-radius: 8px; + font-family: var(--mono); + font-size: 13px; + box-shadow: var(--shadow-input-inset); + transition: border-color 0.15s ease, box-shadow 0.15s ease; + resize: vertical; +} + +.field input[type="text"]:focus, +.field input[type="number"]:focus, +.field textarea:focus, +.field select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(19, 19, 19, 0.1); +} + +.field textarea { + min-height: 60px; +} + +.radio-row { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.radio-row label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text); + cursor: pointer; +} + +.radio-row input[type="radio"], +.radio-row input[type="checkbox"] { + accent-color: var(--accent); +} + +.button-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 8px; +} + +.button-row.compact { + margin-top: 0; + margin-bottom: 12px; +} + +button { + background: var(--accent); + color: #ffffff; + border: none; + padding: 9px 18px; + border-radius: 8px; + font-family: var(--sans); + font-size: 13px; + font-weight: 600; + cursor: pointer; + box-shadow: var(--shadow-button); + transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.08s ease; +} + +button:hover:not(:disabled) { + background: var(--accent-hover); + box-shadow: var(--shadow-button-hover); +} + +button:active:not(:disabled) { + background: var(--accent-active); + box-shadow: var(--shadow-button); + transform: translateY(1px); +} + +button:disabled { + background: var(--panel-border); + color: var(--text-dim); + cursor: not-allowed; + box-shadow: none; +} + +button.secondary { + background: var(--panel-bg); + border: 1px solid var(--panel-border); + color: var(--text); + box-shadow: var(--shadow-button); +} + +button.secondary:hover:not(:disabled) { + background: #f3f4f6; + border-color: #d4d7dd; + box-shadow: var(--shadow-button-hover); +} + +button.active { + background: var(--accent-active); + color: #ffffff; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.25) inset, var(--shadow-button); +} + +.log { + background: var(--input-bg); + border: 1px solid var(--panel-border); + border-radius: 8px; + font-family: var(--mono); + font-size: 12px; + height: 360px; + min-height: 120px; + overflow: auto; + padding: 14px 16px; + resize: vertical; + box-shadow: var(--shadow-input-inset); +} + +.log-entry { + margin-bottom: 8px; + word-break: break-all; +} + +.log-entry:last-child { + margin-bottom: 0; +} + +.log-time { + color: var(--text-dim); + margin-right: 8px; +} + +.log-method { + color: var(--accent); + font-weight: 600; + margin-right: 8px; + text-transform: uppercase; +} + +.log-detail { + color: var(--text); + white-space: pre-wrap; +} + +.log-entry.ok .log-method { color: var(--ok); } +.log-entry.warn .log-method { color: var(--warn); } +.log-entry.err .log-method { color: var(--err); } + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.panel-header .panel-title { + margin: 0; +} + +.log-count { + color: var(--text-dim); + font-size: 11px; + font-family: var(--mono); +} + +.status-value.consent-none { color: var(--err); } +.status-value.consent-anonymous { color: var(--warn); } +.status-value.consent-full { color: var(--ok); } + +.demo-gutter { + display: none; +} + +/* Wide-viewport two-column layout: controls left, sticky event log right. + On narrow viewports (< 1024px) the default block layout applies and the + panels stack vertically as before. */ +@media (min-width: 1024px) { + main { + max-width: 1360px; + } + + .demo-grid { + display: grid; + grid-template-columns: 1fr 8px 1fr; + gap: 0; + align-items: start; + column-gap: 16px; + } + + .demo-gutter { + display: block; + width: 8px; + cursor: col-resize; + background: var(--panel-border); + border-radius: 4px; + align-self: stretch; + min-height: 100px; + transition: background 0.12s ease; + } + + .demo-gutter:hover, + .demo-gutter.active { + background: var(--accent); + } + + .controls > .panel { + margin-bottom: 16px; + } + + .controls > .panel:last-child { + margin-bottom: 0; + } + + .log-column { + position: sticky; + top: 16px; + max-height: calc(100vh - 32px); + } + + .log-column > .panel { + margin-bottom: 0; + } + + .log-column .log { + /* User-resizable at wide viewports too. Default height fills most of + the viewport; drag the bottom-right corner to shrink or grow. + max-height clamps the grow direction so the log can't push off the + bottom of the screen. */ + height: calc(100vh - 220px); + min-height: 120px; + max-height: calc(100vh - 80px); + resize: vertical; + } +} + +footer { + text-align: center; + color: var(--text-muted); + font-size: 12px; + padding: 24px 0 8px; + font-family: var(--mono); +} + +footer a { + color: var(--text-muted); + text-decoration: none; + border-bottom: 1px dotted var(--text-muted); + transition: color 0.12s ease, border-color 0.12s ease; +} + +footer a:hover { + color: var(--text); + border-bottom-color: var(--text); +} + +footer .footer-sep { + margin: 0 8px; + color: var(--text-dim); +} diff --git a/packages/audience/sdk-sample-app/demo.js b/packages/audience/sdk-sample-app/demo.js new file mode 100644 index 0000000000..735387b7e4 --- /dev/null +++ b/packages/audience/sdk-sample-app/demo.js @@ -0,0 +1,629 @@ +/* + * Demo script for @imtbl/audience. + * + * Vanilla ES2020 — no modules, no imports. Reads window.ImmutableAudience + * that the CDN bundle attaches. CSP-safe: all event wiring via + * addEventListener, all DOM rendering via createElement + textContent. + */ + +(function () { + 'use strict'; + + if (!window.ImmutableAudience) { + document.body.innerText = 'ERROR: window.ImmutableAudience is undefined. The CDN bundle did not load.'; + return; + } + + var Audience = window.ImmutableAudience.Audience; + var IdentityType = window.ImmutableAudience.IdentityType; + var SDK_VERSION = (window.ImmutableAudience && window.ImmutableAudience.version) || 'unknown'; + + // State + var audience = null; + var currentUserId = null; + var currentConsent = null; + var logEntries = []; + var MAX_LOG_ENTRIES = 500; + + // Track whether the user has scrolled up inside the event log. When true, + // renderLog stops auto-pinning to the bottom so new entries don't yank the + // user away from whatever they were reading. + var logAutoScroll = true; + var LOG_BOTTOM_THRESHOLD = 20; // px from bottom still counts as "at the bottom" + + // DOM helpers + function $(id) { return document.getElementById(id); } + + function text(el, val) { el.textContent = val; return el; } + + function create(tag, className) { + var el = document.createElement(tag); + if (className) el.className = className; + return el; + } + + function isLogAtBottom() { + var el = $('log'); + if (!el) return true; + return el.scrollHeight - el.scrollTop - el.clientHeight <= LOG_BOTTOM_THRESHOLD; + } + + function getRadio(name) { + var radios = document.querySelectorAll('input[name="' + name + '"]'); + for (var i = 0; i < radios.length; i++) { + if (radios[i].checked) return radios[i].value; + } + return null; + } + + function parseJsonOrWarn(txt, label) { + var trimmed = (txt || '').trim(); + if (!trimmed) return undefined; + try { + var parsed = JSON.parse(trimmed); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + log('WARN', label + ': JSON must be an object', 'warn'); + return null; + } + return parsed; + } catch (err) { + log('WARN', label + ': invalid JSON — ' + String(err && err.message || err), 'warn'); + return null; + } + } + + function populateIdentityDropdowns() { + var selectIds = ['identify-type', 'alias-from-type', 'alias-to-type']; + var values = Object.keys(IdentityType).map(function (key) { + return { key: key, value: IdentityType[key] }; + }); + + for (var i = 0; i < selectIds.length; i++) { + var sel = $(selectIds[i]); + if (!sel || sel.options.length > 0) continue; + for (var j = 0; j < values.length; j++) { + var opt = document.createElement('option'); + opt.value = values[j].value; + opt.textContent = values[j].value; + sel.appendChild(opt); + } + } + $('alias-to-type').value = IdentityType.Passport; + } + + function initDemoGutter() { + var gutter = $('demo-gutter'); + if (!gutter) return; + var grid = document.querySelector('.demo-grid'); + if (!grid) return; + + var STORAGE_KEY = '__imtbl_demo_split'; + var MIN_PCT = 25; + var MAX_PCT = 75; + + function applySplit(leftPct) { + var clamped = Math.max(MIN_PCT, Math.min(MAX_PCT, leftPct)); + grid.style.gridTemplateColumns = clamped + 'fr 8px ' + (100 - clamped) + 'fr'; + } + + // Restore saved split if any. + try { + var saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + var pct = parseFloat(saved); + if (!isNaN(pct)) applySplit(pct); + } + } catch (_err) { + // localStorage may be unavailable — ignore + } + + var dragging = false; + + gutter.addEventListener('mousedown', function (e) { + dragging = true; + gutter.classList.add('active'); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + e.preventDefault(); + }); + + document.addEventListener('mousemove', function (e) { + if (!dragging) return; + var rect = grid.getBoundingClientRect(); + var leftPx = e.clientX - rect.left; + var pct = (leftPx / rect.width) * 100; + applySplit(pct); + }); + + document.addEventListener('mouseup', function () { + if (!dragging) return; + dragging = false; + gutter.classList.remove('active'); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + + // Persist the current split ratio (recomputed from actual layout). + var rect = grid.getBoundingClientRect(); + var controlsEl = document.querySelector('.demo-grid > .controls'); + if (!controlsEl) return; + var controlsRect = controlsEl.getBoundingClientRect(); + var currentPct = (controlsRect.width / rect.width) * 100; + try { + localStorage.setItem(STORAGE_KEY, currentPct.toFixed(2)); + } catch (_err) { + // noop + } + }); + + // Keyboard support: left/right arrow when focused. + gutter.addEventListener('keydown', function (e) { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + var rect = grid.getBoundingClientRect(); + var controlsEl = document.querySelector('.demo-grid > .controls'); + if (!controlsEl) return; + var currentPct = (controlsEl.getBoundingClientRect().width / rect.width) * 100; + var delta = e.key === 'ArrowLeft' ? -2 : 2; + var next = currentPct + delta; + applySplit(next); + try { localStorage.setItem(STORAGE_KEY, next.toFixed(2)); } catch (_err) {} + e.preventDefault(); + }); + } + + // Log + function log(method, detail, type) { + type = type || 'info'; + var now = new Date().toISOString(); + logEntries.push({ time: now, method: method, detail: detail, type: type }); + if (logEntries.length > MAX_LOG_ENTRIES) logEntries.shift(); + renderLog(); + } + + function renderLog() { + var container = $('log'); + container.innerHTML = ''; + for (var i = 0; i < logEntries.length; i++) { + var e = logEntries[i]; + var entry = create('div', 'log-entry ' + (e.type === 'ok' ? 'ok' : e.type === 'err' ? 'err' : e.type === 'warn' ? 'warn' : '')); + + var timeEl = create('span', 'log-time'); + text(timeEl, e.time); + + var methodEl = create('span', 'log-method'); + text(methodEl, e.method); + + var detailStr; + if (e.detail == null) { + detailStr = ''; + } else if (typeof e.detail === 'object') { + try { + detailStr = JSON.stringify(e.detail, null, 2); + } catch (err) { + detailStr = '[unserializable detail: ' + String(err && err.message) + ']'; + } + } else { + detailStr = String(e.detail); + } + + entry.appendChild(timeEl); + entry.appendChild(methodEl); + + // For error entries with multi-line JSON, render a block; otherwise inline span. + var isMultiline = detailStr.indexOf('\n') !== -1; + if (isMultiline || e.type === 'err') { + var br = document.createElement('br'); + entry.appendChild(br); + var pre = create('span', 'log-detail'); + text(pre, detailStr); + entry.appendChild(pre); + } else { + var detailEl = create('span', 'log-detail'); + text(detailEl, detailStr); + entry.appendChild(detailEl); + } + + container.appendChild(entry); + } + if (logAutoScroll) { + container.scrollTop = container.scrollHeight; + } + var countText = logEntries.length + ' entries'; + if (logEntries.length >= MAX_LOG_ENTRIES) { + countText += ' (capped at ' + MAX_LOG_ENTRIES + ')'; + } + text($('log-count'), countText); + } + + function onCopyLog() { + var btn = $('btn-copy-log'); + if (!btn) return; + var originalText = btn.textContent; + function flashLabel(msg) { + btn.textContent = msg; + setTimeout(function () { + btn.textContent = originalText; + }, 1500); + } + + var payload; + try { + payload = JSON.stringify(logEntries, null, 2); + } catch (err) { + log('WARN', 'Copy session failed: ' + String(err && err.message || err), 'warn'); + flashLabel('Copy failed'); + return; + } + + if (!navigator.clipboard || !navigator.clipboard.writeText) { + log('WARN', 'Clipboard API unavailable in this browser', 'warn'); + flashLabel('Not supported'); + return; + } + + navigator.clipboard.writeText(payload).then(function () { + flashLabel('Copied!'); + }).catch(function (err) { + log('WARN', 'Copy session failed: ' + String(err && err.message || err), 'warn'); + flashLabel('Copy failed'); + }); + } + + function clearLog() { + logEntries = []; + logAutoScroll = true; + renderLog(); + } + + // Status bar + function updateStatus() { + var env = audience ? getRadio('env') : '—'; + text($('status-env'), env || '—'); + $('status-env').className = 'status-value' + (audience ? '' : ' dim'); + + var consent = audience ? (currentConsent || '—') : '—'; + var consentEl = $('status-consent'); + text(consentEl, consent); + // Rebuild className fresh each update so we don't accumulate stale state classes. + var consentClass = 'status-value'; + if (!audience || !currentConsent) { + consentClass += ' dim'; + } else { + consentClass += ' consent-' + currentConsent; + } + consentEl.className = consentClass; + + var anonCookie = document.cookie.match(/imtbl_anon_id=([^;]*)/); + text($('status-anon'), anonCookie ? decodeURIComponent(anonCookie[1]) : '—'); + $('status-anon').className = 'status-value' + (anonCookie ? '' : ' dim'); + + text($('status-user'), currentUserId || '—'); + $('status-user').className = 'status-value' + (currentUserId ? '' : ' dim'); + } + + // Sync the Init button's enabled state based on the publishable key field content. + // Declared as a function declaration so it is hoisted and can be called from + // setInitState below without a forward-reference guard. + function syncInitEnabled() { + if (audience) return; + var initBtn = $('btn-init'); + var pkInput = $('pk'); + initBtn.disabled = pkInput.value.trim().length === 0; + } + + // Sync the Alias button's enabled state based on from/to inputs. + // Mirrors core's isAliasValid: button is disabled if the SDK is not initialised, + // if either ID is empty, or if (fromId, fromType) === (toId, toType). + function syncAliasButton() { + var btn = $('btn-alias'); + if (!audience) { btn.disabled = true; return; } + var fromId = $('alias-from-id').value.trim(); + var toId = $('alias-to-id').value.trim(); + var fromType = $('alias-from-type').value; + var toType = $('alias-to-type').value; + if (!fromId || !toId) { btn.disabled = true; return; } + if (fromId === toId && fromType === toType) { btn.disabled = true; return; } + btn.disabled = false; + } + + // Enable/disable controls based on init state + function setInitState(on) { + $('btn-init').disabled = on; + $('btn-shutdown').disabled = !on; + $('btn-reset').disabled = !on; + $('btn-flush').disabled = !on; + $('btn-page').disabled = !on; + $('btn-track').disabled = !on; + $('btn-identify').disabled = !on; + $('btn-identify-traits').disabled = !on; + $('btn-alias').disabled = !on; + $('pk').disabled = on; + $('flush-interval').disabled = on; + $('flush-size').disabled = on; + var envRadios = document.querySelectorAll('input[name="env"]'); + for (var i = 0; i < envRadios.length; i++) envRadios[i].disabled = on; + var consentRadios = document.querySelectorAll('input[name="initial-consent"]'); + for (var j = 0; j < consentRadios.length; j++) consentRadios[j].disabled = on; + if (!on) syncInitEnabled(); + // Alias button needs the finer-grained check (inputs + equality). Called + // unconditionally because syncAliasButton handles both the enabled and + // disabled cases internally. + syncAliasButton(); + } + + // onError handler passed to Audience.init + function handleError(err) { + var code = err && err.code ? err.code : 'UNKNOWN'; + var summary = code + (err && err.status ? ' (' + err.status + ')' : ''); + log('ERROR ' + summary, err && err.responseBody != null ? err.responseBody : String(err && err.message), 'err'); + } + + // Button handlers + function onInit() { + var pk = $('pk').value.trim(); + var env = getRadio('env'); + var consent = getRadio('initial-consent'); + var debug = $('debug').checked; + + // Optional advanced config: flushInterval / flushSize. + // Empty input → omit → SDK uses its defaults (5000ms / 20 items). + var flushIntervalRaw = $('flush-interval').value.trim(); + var flushSizeRaw = $('flush-size').value.trim(); + var flushInterval; + var flushSize; + + if (flushIntervalRaw) { + flushInterval = parseInt(flushIntervalRaw, 10); + if (isNaN(flushInterval) || flushInterval <= 0) { + log('WARN', 'Flush interval must be a positive integer in milliseconds', 'warn'); + return; + } + } + if (flushSizeRaw) { + flushSize = parseInt(flushSizeRaw, 10); + if (isNaN(flushSize) || flushSize <= 0) { + log('WARN', 'Flush batch size must be a positive integer', 'warn'); + return; + } + } + + var config = { + publishableKey: pk, + environment: env, + consent: consent, + debug: debug, + onError: handleError, + }; + if (flushInterval !== undefined) config.flushInterval = flushInterval; + if (flushSize !== undefined) config.flushSize = flushSize; + + try { + audience = Audience.init(config); + setInitState(true); + currentConsent = consent; + log('INIT', { + environment: env, + consent: consent, + debug: debug, + flushInterval: flushInterval, + flushSize: flushSize, + }, 'ok'); + } catch (err) { + log('INIT', String(err && err.message || err), 'err'); + return; + } + updateConsentButtons(); + updateStatus(); + } + + function onShutdown() { + if (!audience) return; + try { + audience.shutdown(); + log('SHUTDOWN', 'ok', 'ok'); + } catch (err) { + log('SHUTDOWN', String(err && err.message || err), 'err'); + } + audience = null; + currentUserId = null; + currentConsent = null; + setInitState(false); + updateConsentButtons(); + updateStatus(); + } + + function onReset() { + if (!audience) return; + try { + audience.reset(); + currentUserId = null; + log('RESET', 'ok', 'ok'); + } catch (err) { + log('RESET', String(err && err.message || err), 'err'); + } + updateStatus(); + } + + function onFlush() { + if (!audience) return; + log('FLUSH', 'flushing\u2026', 'info'); + audience.flush().then(function () { + log('FLUSH', 'complete', 'ok'); + }).catch(function (err) { + log('FLUSH', String(err && err.message || err), 'err'); + }); + } + + function onSetConsent(level) { + if (!audience) return; + try { + audience.setConsent(level); + currentConsent = level; + log('CONSENT', 'set to ' + level, 'ok'); + if (level === 'none') currentUserId = null; + } catch (err) { + log('CONSENT', String(err && err.message || err), 'err'); + } + updateConsentButtons(); + updateStatus(); + } + + function onPage() { + if (!audience) return; + var props = parseJsonOrWarn($('page-props').value, 'page properties'); + if (props === null) return; + + try { + audience.page(props); + log('PAGE', props || '(no properties)', 'ok'); + } catch (err) { + log('PAGE', String(err && err.message || err), 'err'); + } + } + + function onTrack() { + if (!audience) return; + var name = $('track-name').value.trim(); + if (!name) { + log('WARN', 'Track: event name is required', 'warn'); + return; + } + var props = parseJsonOrWarn($('track-props').value, 'track properties'); + if (props === null) return; + + try { + audience.track(name, props); + log('TRACK', { eventName: name, properties: props }, 'ok'); + } catch (err) { + log('TRACK', String(err && err.message || err), 'err'); + } + } + + function onIdentify() { + if (!audience) return; + var id = $('identify-id').value.trim(); + if (!id) { + log('WARN', 'Identify: ID is required', 'warn'); + return; + } + var identityType = $('identify-type').value; + var traits = parseJsonOrWarn($('identify-traits').value, 'identify traits'); + if (traits === null) return; + + try { + if (traits !== undefined) { + audience.identify(id, identityType, traits); + } else { + audience.identify(id, identityType); + } + currentUserId = id; + log('IDENTIFY', { id: id, identityType: identityType, traits: traits }, 'ok'); + } catch (err) { + log('IDENTIFY', String(err && err.message || err), 'err'); + } + updateStatus(); + } + + function onIdentifyTraits() { + if (!audience) return; + var traits = parseJsonOrWarn($('identify-traits').value, 'identify traits'); + if (traits === null || traits === undefined) { + log('WARN', 'Traits-only identify: traits are required', 'warn'); + return; + } + try { + audience.identify(traits); + log('IDENTIFY', { traitsOnly: traits }, 'ok'); + } catch (err) { + log('IDENTIFY', String(err && err.message || err), 'err'); + } + } + + function onAlias() { + if (!audience) return; + var fromId = $('alias-from-id').value.trim(); + var toId = $('alias-to-id').value.trim(); + var fromType = $('alias-from-type').value; + var toType = $('alias-to-type').value; + + if (!fromId || !toId) { + log('WARN', 'Alias: both IDs are required', 'warn'); + return; + } + if (fromId === toId && fromType === toType) { + log('WARN', 'Alias: from and to are identical', 'warn'); + return; + } + + try { + audience.alias( + { id: fromId, identityType: fromType }, + { id: toId, identityType: toType }, + ); + log('ALIAS', { from: { id: fromId, type: fromType }, to: { id: toId, type: toType } }, 'ok'); + } catch (err) { + log('ALIAS', String(err && err.message || err), 'err'); + } + } + + function updateConsentButtons() { + var btns = [ + { el: $('btn-consent-none'), level: 'none' }, + { el: $('btn-consent-anon'), level: 'anonymous' }, + { el: $('btn-consent-full'), level: 'full' }, + ]; + for (var i = 0; i < btns.length; i++) { + btns[i].el.disabled = !audience; + if (audience && currentConsent === btns[i].level) { + btns[i].el.classList.add('active'); + } else { + btns[i].el.classList.remove('active'); + } + } + } + + // Wire up + document.addEventListener('DOMContentLoaded', function () { + $('btn-init').addEventListener('click', onInit); + $('btn-shutdown').addEventListener('click', onShutdown); + $('btn-reset').addEventListener('click', onReset); + $('btn-flush').addEventListener('click', onFlush); + $('btn-copy-log').addEventListener('click', onCopyLog); + $('btn-clear-log').addEventListener('click', clearLog); + $('log').addEventListener('scroll', function () { + logAutoScroll = isLogAtBottom(); + }); + + $('btn-consent-none').addEventListener('click', function () { onSetConsent('none'); }); + $('btn-consent-anon').addEventListener('click', function () { onSetConsent('anonymous'); }); + $('btn-consent-full').addEventListener('click', function () { onSetConsent('full'); }); + + $('btn-page').addEventListener('click', onPage); + $('btn-track').addEventListener('click', onTrack); + + var versionEl = $('sdk-version'); + if (versionEl) versionEl.textContent = SDK_VERSION; + + populateIdentityDropdowns(); + initDemoGutter(); + $('btn-identify').addEventListener('click', onIdentify); + $('btn-identify-traits').addEventListener('click', onIdentifyTraits); + $('btn-alias').addEventListener('click', onAlias); + + // Real-time alias validity: disable the button when either ID is empty or + // when (fromId, fromType) === (toId, toType). Matches the design spec and + // mirrors the SDK's isAliasValid() — user gets immediate feedback instead + // of discovering the problem only after clicking. + $('alias-from-id').addEventListener('input', syncAliasButton); + $('alias-to-id').addEventListener('input', syncAliasButton); + $('alias-from-type').addEventListener('change', syncAliasButton); + $('alias-to-type').addEventListener('change', syncAliasButton); + + // Enable Init only when the publishable key input has non-whitespace content. + $('pk').addEventListener('input', syncInitEnabled); + syncInitEnabled(); + + setInterval(updateStatus, 1000); + updateStatus(); + log('READY', 'Demo loaded. Paste a publishable key and click Init.', 'info'); + }); +})(); diff --git a/packages/audience/sdk-sample-app/index.html b/packages/audience/sdk-sample-app/index.html new file mode 100644 index 0000000000..65a33d3e02 --- /dev/null +++ b/packages/audience/sdk-sample-app/index.html @@ -0,0 +1,175 @@ + + +
+ + + +