Skip to content

Commit 6befd37

Browse files
feat(audience): interactive sample app for @imtbl/audience (SDK-49)
Adds packages/audience/sdk-sample-app, a private workspace package that hosts an interactive harness for @imtbl/audience. Every public method, every typed track() event, and every reachable AudienceErrorCode has a dedicated UI control so studios can sanity-check SDK changes end-to-end against the real sandbox backend and copy working call sites. The sample app is vanilla ES2020 — no TypeScript, no bundler, no framework — and loads the SDK via the CDN IIFE bundle from the sibling @imtbl/audience package. A ~90-line Node stdlib HTTP server mounts the package's own files at / and the sibling SDK's dist/cdn/ at /vendor/ so everything is served from a single origin and the tight CSP in index.html stays happy. No extra build step is required beyond the existing pnpm --filter @imtbl/audience run build. Includes fresh READMEs for both @imtbl/audience and the new sample app, covering install, quickstart (ESM and CDN), error handling, the 11 typed track() events, and a 10-step walkthrough. Refs SDK-49 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6706e2e commit 6befd37

9 files changed

Lines changed: 1864 additions & 125 deletions

File tree

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# @imtbl/audience-sdk-sample-app
2+
3+
Interactive sample app for `@imtbl/audience`. Every public method, every
4+
typed `track()` event, and every reachable `AudienceErrorCode` has a
5+
dedicated UI control so you can sanity-check SDK changes end-to-end
6+
against the real sandbox backend and copy working call sites.
7+
8+
## Run it
9+
10+
```sh
11+
pnpm --filter @imtbl/audience-sdk-sample-app run dev
12+
```
13+
14+
Open http://localhost:3456/. The `dev` script builds `@imtbl/audience`
15+
first (which produces `dist/cdn/imtbl-audience.global.js`), then serves
16+
this package's files plus the CDN bundle over a small Node server.
17+
18+
## Test keys
19+
20+
These are publishable-key fixtures safe for local use:
21+
22+
- **Sandbox (default):** `pk_imapik-test-sample` — talks to `api.sandbox.immutable.com`
23+
- **Prod:** any non-`pk_imapik-test-` key — talks to `api.immutable.com`
24+
25+
For dev-environment access, leave the key as a test key and set
26+
**Advanced → Base URL override** to `https://api.dev.immutable.com`.
27+
The SDK auto-derives sandbox vs prod from the key prefix; dev is not a
28+
first-class environment and must be reached via explicit override.
29+
30+
## Ten-step walkthrough
31+
32+
1. Paste a test key into **Setup**, leave Initial Consent at `none`, click **Init**.
33+
2. Open the **Consent** panel, click **anonymous**. Status bar updates.
34+
3. Click **Lifecycle → page()**. A page message is queued.
35+
4. Expand **Typed Events → purchase**, fill in `currency=USD`, `value=9.99`, click **Send**. Watch the live TS snippet mirror the form as you type.
36+
5. Set Consent to **full**.
37+
6. In **Identity → Named identify**, enter `user@example.com`, type `email`, traits `{"name":"Jane"}`, click.
38+
7. In **Identity → Traits-only identify**, enter `{"plan":"pro"}`, click.
39+
8. In **Identity → Alias**, connect a Steam ID to the email above.
40+
9. Set Consent back to **none**. Notice the queue purge in the event log.
41+
10. In **Error Handling**, click **Force NETWORK_ERROR**. Watch the `onError` entry land with `code: NETWORK_ERROR`. The app auto-restores the Setup configuration afterwards.
42+
43+
## `AudienceEvents` catalogue
44+
45+
These are the 11 predefined event names and their typed property shapes.
46+
Because `AudienceEvents` is not attached to `window.ImmutableAudience` by
47+
the CDN bundle, the sample app hardcodes the names as a string array in
48+
`sample-app.js`. For TypeScript projects, import the constant instead:
49+
50+
```ts
51+
import { AudienceEvents } from '@imtbl/audience';
52+
53+
audience.track(AudienceEvents.PURCHASE, {
54+
currency: 'USD',
55+
value: 9.99,
56+
itemId: 'sword',
57+
transactionId: 'tx_123',
58+
});
59+
```
60+
61+
| Event | Required props | Optional props |
62+
|---|---|---|
63+
| `sign_up` || `method` |
64+
| `sign_in` || `method` |
65+
| `wishlist_add` | `gameId` | `source`, `platform` |
66+
| `wishlist_remove` | `gameId` ||
67+
| `purchase` | `currency`, `value` | `itemId`, `itemName`, `quantity`, `transactionId` |
68+
| `game_launch` || `platform`, `version`, `buildId` |
69+
| `progression` | `status: 'start' \| 'complete' \| 'fail'` | `world`, `level`, `stage`, `score`, `durationSec` |
70+
| `resource` | `flow: 'sink' \| 'source'`, `currency`, `amount` | `itemType`, `itemId` |
71+
| `email_acquired` || `source` |
72+
| `game_page_viewed` | `gameId` | `gameName`, `slug` |
73+
| `link_clicked` | `url` | `label`, `source`, `gameId` |
74+
75+
Pass anything else as a custom event with the `string & {}` escape hatch:
76+
77+
```ts
78+
audience.track('my_custom_event', { foo: 'bar' });
79+
```
80+
81+
## Consent-aware UIs with `canTrack` / `canIdentify`
82+
83+
The helpers `canTrack(level)` and `canIdentify(level)` are exported from
84+
`@imtbl/audience` but not attached to `window.ImmutableAudience`. TypeScript
85+
projects can import them:
86+
87+
```ts
88+
import { canTrack, canIdentify } from '@imtbl/audience';
89+
90+
if (canTrack(currentConsent)) {
91+
renderAnalyticsDashboard();
92+
}
93+
if (canIdentify(currentConsent)) {
94+
showLoginButton();
95+
}
96+
```
97+
98+
From a plain-JavaScript CDN-only context, the rules are:
99+
100+
- `canTrack(level)` is true iff `level !== 'none'`
101+
- `canIdentify(level)` is true iff `level === 'full'`
102+
103+
The sample app gates its Identity and Alias buttons on this rule locally.
104+
105+
## Error codes
106+
107+
`AudienceError.code` is a closed union: `'FLUSH_FAILED'`,
108+
`'CONSENT_SYNC_FAILED'`, `'NETWORK_ERROR'`, `'VALIDATION_REJECTED'`.
109+
Handle them in an `onError` callback passed at init time:
110+
111+
```ts
112+
audience = Audience.init({
113+
publishableKey: 'pk_imapik-test-...',
114+
onError: (err) => {
115+
switch (err.code) {
116+
case 'FLUSH_FAILED': /* retryable; the queue will retry automatically */ break;
117+
case 'NETWORK_ERROR': /* usually transient; the queue will retry */ break;
118+
case 'CONSENT_SYNC_FAILED': /* consent PUT failed; state still stored locally */ break;
119+
case 'VALIDATION_REJECTED': /* terminal; messages were dropped */ break;
120+
}
121+
sentryClient.captureException(err);
122+
},
123+
});
124+
```
125+
126+
The sample app's Error Handling panel triggers each reachable code by
127+
shutting down, re-initialising against a deliberately broken config, and
128+
logging the `AudienceError` shape it receives via `onError`.
129+
130+
`VALIDATION_REJECTED` is not triggerable from the browser because it
131+
requires the backend to accept the HTTPS POST and then report
132+
`rejected > 0` in the JSON body. The shape you'd see in that case is:
133+
134+
```ts
135+
{
136+
name: 'AudienceError',
137+
code: 'VALIDATION_REJECTED',
138+
message: 'Backend rejected N of M messages',
139+
status: 200, // or another 2xx
140+
endpoint: '...',
141+
responseBody: { accepted: M - N, rejected: N, ... },
142+
}
143+
```
144+
145+
## CSP
146+
147+
The sample app serves under a tight CSP:
148+
149+
```
150+
default-src 'self';
151+
script-src 'self';
152+
style-src 'self';
153+
connect-src https://api.dev.immutable.com https://api.sandbox.immutable.com https://api.immutable.com
154+
```
155+
156+
No inline scripts, no inline styles, no third-party origins. If you
157+
adapt this sample app for a studio-owned page, keep the same posture —
158+
`@imtbl/audience` is designed to run under a strict CSP.
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<meta http-equiv="Content-Security-Policy"
7+
content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src https://api.dev.immutable.com https://api.sandbox.immutable.com https://api.immutable.com">
8+
<title>Immutable Audience SDK — Sample App</title>
9+
<link rel="stylesheet" href="sample-app.css">
10+
</head>
11+
<body>
12+
<main>
13+
<header>
14+
<h1>Immutable Audience SDK — Sample App</h1>
15+
</header>
16+
17+
<div class="status-bar" id="status-bar">
18+
<div><span class="status-label">Endpoint</span> <span class="status-value dim" id="status-endpoint"></span></div>
19+
<div><span class="status-label">Consent</span> <span class="status-value dim" id="status-consent"></span></div>
20+
<div><span class="status-label">Anon ID</span> <span class="status-value dim" id="status-anon"></span></div>
21+
<div><span class="status-label">User ID</span> <span class="status-value dim" id="status-user"></span></div>
22+
</div>
23+
24+
<div class="sample-app-grid">
25+
<div class="controls">
26+
27+
<section class="panel" id="panel-setup">
28+
<h2 class="panel-title">Setup</h2>
29+
<div class="field">
30+
<label for="pk">Publishable Key</label>
31+
<input id="pk" type="text" placeholder="pk_imapik-test-yourkey" spellcheck="false">
32+
</div>
33+
<div class="field">
34+
<label for="initial-consent">Initial Consent</label>
35+
<select id="initial-consent">
36+
<option value="none" selected>none</option>
37+
<option value="anonymous">anonymous</option>
38+
<option value="full">full</option>
39+
</select>
40+
</div>
41+
<details class="advanced">
42+
<summary>Advanced options</summary>
43+
<div class="field">
44+
<label><input id="debug" type="checkbox"> Debug logging</label>
45+
</div>
46+
<div class="field">
47+
<label for="cookie-domain">Cookie domain</label>
48+
<input id="cookie-domain" type="text" placeholder=".studio.com">
49+
</div>
50+
<div class="field">
51+
<label for="flush-interval">Flush interval (ms)</label>
52+
<input id="flush-interval" type="number" placeholder="5000" min="100">
53+
</div>
54+
<div class="field">
55+
<label for="flush-size">Flush size</label>
56+
<input id="flush-size" type="number" placeholder="20" min="1">
57+
</div>
58+
<div class="field">
59+
<label for="base-url">Base URL override</label>
60+
<input id="base-url" type="text" placeholder="(derived from key)">
61+
</div>
62+
</details>
63+
<div class="field">
64+
<span class="status-label">Derived endpoint</span>
65+
<span class="status-value dim" id="derived-endpoint"></span>
66+
</div>
67+
<div class="actions">
68+
<button id="btn-init" disabled>Init</button>
69+
</div>
70+
</section>
71+
72+
<section class="panel" id="panel-lifecycle">
73+
<h2 class="panel-title">Lifecycle</h2>
74+
<div class="actions">
75+
<button id="btn-page" disabled>page()</button>
76+
<button id="btn-flush" disabled>flush()</button>
77+
<button id="btn-reset" disabled>reset()</button>
78+
<button id="btn-shutdown" disabled>shutdown()</button>
79+
</div>
80+
</section>
81+
82+
<section class="panel" id="panel-consent">
83+
<h2 class="panel-title">Consent</h2>
84+
<div class="actions">
85+
<button id="btn-consent-none" disabled>none</button>
86+
<button id="btn-consent-anon" disabled>anonymous</button>
87+
<button id="btn-consent-full" disabled>full</button>
88+
</div>
89+
</section>
90+
91+
<section class="panel" id="panel-typed-events">
92+
<h2 class="panel-title">Typed Events</h2>
93+
<div id="typed-events-accordion"></div>
94+
<details class="custom-event">
95+
<summary>Custom event (escape hatch)</summary>
96+
<div class="field">
97+
<label for="custom-event-name">Event name</label>
98+
<input id="custom-event-name" type="text" placeholder="my_custom_event">
99+
</div>
100+
<div class="field">
101+
<label for="custom-event-props">Properties (JSON)</label>
102+
<textarea id="custom-event-props" rows="3" placeholder='{"foo": "bar"}'></textarea>
103+
</div>
104+
<div class="actions">
105+
<button id="btn-custom-event" disabled>Send</button>
106+
</div>
107+
</details>
108+
</section>
109+
110+
<section class="panel" id="panel-identity">
111+
<h2 class="panel-title">Identity</h2>
112+
113+
<details open>
114+
<summary>Named identify</summary>
115+
<div class="field">
116+
<label for="id-id">ID</label>
117+
<input id="id-id" type="text" placeholder="user@example.com">
118+
</div>
119+
<div class="field">
120+
<label for="id-type">Identity type</label>
121+
<select id="id-type"></select>
122+
</div>
123+
<div class="field">
124+
<label for="id-traits">Traits (JSON, optional)</label>
125+
<textarea id="id-traits" rows="3" placeholder='{"name": "Jane"}'></textarea>
126+
</div>
127+
<div class="actions">
128+
<button id="btn-identify" disabled>identify(id, type, traits)</button>
129+
</div>
130+
</details>
131+
132+
<details>
133+
<summary>Traits-only identify</summary>
134+
<div class="field">
135+
<label for="traits-json">Traits (JSON)</label>
136+
<textarea id="traits-json" rows="3" placeholder='{"plan": "pro"}'></textarea>
137+
</div>
138+
<div class="actions">
139+
<button id="btn-identify-traits" disabled>identify(traits)</button>
140+
</div>
141+
</details>
142+
143+
<details>
144+
<summary>Alias</summary>
145+
<div class="field">
146+
<label for="alias-from-id">From ID</label>
147+
<input id="alias-from-id" type="text">
148+
</div>
149+
<div class="field">
150+
<label for="alias-from-type">From type</label>
151+
<select id="alias-from-type"></select>
152+
</div>
153+
<div class="field">
154+
<label for="alias-to-id">To ID</label>
155+
<input id="alias-to-id" type="text">
156+
</div>
157+
<div class="field">
158+
<label for="alias-to-type">To type</label>
159+
<select id="alias-to-type"></select>
160+
</div>
161+
<div class="actions">
162+
<button id="btn-alias" disabled>alias(from, to)</button>
163+
</div>
164+
</details>
165+
</section>
166+
167+
<section class="panel" id="panel-errors">
168+
<h2 class="panel-title">Error Handling</h2>
169+
<div class="actions">
170+
<button id="btn-init-empty-key">Init with empty key</button>
171+
<button id="btn-force-network" disabled>Force NETWORK_ERROR</button>
172+
<button id="btn-force-flush-failed" disabled>Force FLUSH_FAILED</button>
173+
<button id="btn-force-consent-failed" disabled>Force CONSENT_SYNC_FAILED</button>
174+
</div>
175+
<p class="note">
176+
VALIDATION_REJECTED fires when the backend returns 2xx with
177+
rejected &gt; 0. Not predictably triggerable from the browser;
178+
see the README for the expected shape.
179+
</p>
180+
</section>
181+
182+
</div>
183+
184+
<div class="gutter" id="gutter"></div>
185+
186+
<div class="log-pane">
187+
<div class="log-header">
188+
<span class="panel-title">Event log</span>
189+
<span class="log-actions">
190+
<button id="btn-copy-log">Copy</button>
191+
<button id="btn-clear-log">Clear</button>
192+
</span>
193+
</div>
194+
<div id="log" class="log"></div>
195+
</div>
196+
</div>
197+
198+
<footer>
199+
<span id="sdk-version">SDK version: —</span>
200+
</footer>
201+
</main>
202+
203+
<script src="vendor/imtbl-audience.global.js"></script>
204+
<script src="sample-app.js"></script>
205+
</body>
206+
</html>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "@imtbl/audience-sdk-sample-app",
3+
"description": "Interactive sample app for @imtbl/audience. Exercises every public method on the Audience class, every typed track() event, and every reachable AudienceErrorCode against the sandbox backend.",
4+
"version": "0.0.0",
5+
"author": "Immutable",
6+
"private": true,
7+
"engines": {
8+
"node": ">=20.11.0"
9+
},
10+
"devDependencies": {
11+
"@imtbl/audience": "workspace:*"
12+
},
13+
"scripts": {
14+
"dev": "pnpm --filter @imtbl/audience run build && node ./serve.mjs",
15+
"start": "pnpm dev"
16+
}
17+
}

0 commit comments

Comments
 (0)