Skip to content

Commit 6081d75

Browse files
feat(audience): sample app with tabbed UI, debug mirror, status bar (SDK-49)
Interactive sample app for @imtbl/audience: tabbed panels (Setup, Consent, Events, Identity), real-time event log with collapse/expand, status bar with consent/endpoint/cookie state, localStorage form persistence, ARIA keyboard nav, SDK debug console mirror. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0f3193a commit 6081d75

File tree

9 files changed

+2346
-125
lines changed

9 files changed

+2346
-125
lines changed
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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+
> **Pre-release.** Depends on `@imtbl/audience@0.0.0`. The SDK API is
9+
> stabilising but breaking changes may still land before the first
10+
> published release.
11+
12+
## Why vanilla JavaScript?
13+
14+
Every other sample app in this monorepo (`packages/passport/sdk-sample-app`,
15+
`packages/checkout/sdk-sample-app`, `packages/internal/dex/sdk-sample-app`,
16+
`packages/internal/bridge/bridge-sample-app`) is React + Next.js with the
17+
`@biom3` design system. This one is plain ES2020 served by a ~90-line Node
18+
stdlib HTTP server. Intentional: the whole point is to demonstrate how
19+
`@imtbl/audience` loads via a plain `<script>` tag — the pattern real
20+
studios use when they drop the SDK into existing pages. A React wrapper
21+
would hide `window.ImmutableAudience` behind JSX abstractions and require
22+
a build step, obscuring the loading pattern we're demonstrating. The SDK
23+
itself is framework-agnostic — wrap these calls in your framework of
24+
choice when you ship.
25+
26+
## Run it
27+
28+
```sh
29+
pnpm --filter @imtbl/audience-sdk-sample-app run dev
30+
```
31+
32+
Open http://localhost:3456/. The `dev` script builds `@imtbl/audience`
33+
first (which produces `dist/cdn/imtbl-audience.global.js`), then serves
34+
this package's files plus the CDN bundle over a small Node server.
35+
36+
## Publishable keys
37+
38+
You need a real publishable key from [Immutable Hub](https://hub.immutable.com/)
39+
there is no shared fixture key. Test keys start with `pk_imapik-test-` and
40+
route to `api.sandbox.immutable.com`; any other prefix routes to
41+
`api.immutable.com` (prod).
42+
43+
For dev-environment access, leave the key as a test key and set
44+
**Advanced → Base URL override** to `https://api.dev.immutable.com`. The
45+
SDK auto-derives sandbox vs prod from the key prefix; dev is not a
46+
first-class environment and must be reached via explicit override.
47+
48+
## Ten-step walkthrough
49+
50+
1. Paste a test key into **Setup**, leave Initial Consent at `none`, click **Init**.
51+
2. Open the **Consent** panel, click **anonymous**. Status bar updates.
52+
3. Click **Lifecycle → page()**. A page message is queued.
53+
4. Expand **Typed Events → purchase** (6th row in the accordion), fill in `currency=USD`, `value=9.99`, click **Send**. Watch the live TS snippet mirror the form as you type.
54+
5. Set Consent to **full**.
55+
6. In **Identity → Named identify**, enter `user@example.com`, type `email`, traits `{"name":"Jane"}`, click.
56+
7. In **Identity → Traits-only identify**, enter `{"plan":"pro"}`, click.
57+
8. In **Identity → Alias**, connect a Steam ID to the email above.
58+
9. Set Consent back to **none**. Notice the queue purge in the event log.
59+
10. In **Lifecycle → Simulate error**, pick `NETWORK_ERROR` from the dropdown and click **Fire onError**. The `onError` entry lands in the event log with the documented shape.
60+
61+
## `AudienceEvents` catalogue
62+
63+
These are the 11 predefined event names and their typed property shapes.
64+
Both the CDN bundle and the ESM package expose them:
65+
66+
```ts
67+
// ESM
68+
import { AudienceEvents } from '@imtbl/audience';
69+
70+
// CDN
71+
const { AudienceEvents } = window.ImmutableAudience;
72+
73+
audience.track(AudienceEvents.PURCHASE, {
74+
currency: 'USD',
75+
value: 9.99,
76+
itemId: 'sword',
77+
transactionId: 'tx_123',
78+
});
79+
```
80+
81+
The sample app reads event NAMES from `window.ImmutableAudience.AudienceEvents`
82+
at bootstrap and cross-checks them against the field-metadata array in
83+
`sample-app.js`. If the SDK grows a new event that the sample app hasn't
84+
picked up yet, the event log shows a `drift warn` entry.
85+
86+
| Event | Required props | Optional props |
87+
|---|---|---|
88+
| `sign_up` || `method` |
89+
| `sign_in` || `method` |
90+
| `wishlist_add` | `gameId` | `source`, `platform` |
91+
| `wishlist_remove` | `gameId` ||
92+
| `purchase` | `currency`, `value` | `itemId`, `itemName`, `quantity`, `transactionId` |
93+
| `game_launch` || `platform`, `version`, `buildId` |
94+
| `progression` | `status: 'start' \| 'complete' \| 'fail'` | `world`, `level`, `stage`, `score`, `durationSec` |
95+
| `resource` | `flow: 'sink' \| 'source'`, `currency`, `amount` | `itemType`, `itemId` |
96+
| `email_acquired` || `source` |
97+
| `game_page_viewed` | `gameId` | `gameName`, `slug` |
98+
| `link_clicked` | `url` | `label`, `source`, `gameId` |
99+
100+
Pass anything else as a custom event with the `string & {}` escape hatch:
101+
102+
```ts
103+
audience.track('my_custom_event', { foo: 'bar' });
104+
```
105+
106+
## Consent-aware UIs with `canTrack` / `canIdentify`
107+
108+
`canTrack(level)` and `canIdentify(level)` are the SDK's canonical consent
109+
predicates. Both the CDN bundle and the ESM package expose them:
110+
111+
```ts
112+
// ESM
113+
import { canTrack, canIdentify } from '@imtbl/audience';
114+
115+
// CDN
116+
const { canTrack, canIdentify } = window.ImmutableAudience;
117+
118+
if (canTrack(currentConsent)) {
119+
renderAnalyticsDashboard();
120+
}
121+
if (canIdentify(currentConsent)) {
122+
showLoginButton();
123+
}
124+
```
125+
126+
The rules themselves are simple:
127+
- `canTrack(level)` returns `true` iff `level !== 'none'`
128+
- `canIdentify(level)` returns `true` iff `level === 'full'`
129+
130+
The sample app's Identity and Alias buttons stay enabled whenever the SDK
131+
is initialised, but the handlers call `canIdentify(currentConsent)` before
132+
invoking `identify()` / `alias()`. When it returns `false`, the handler
133+
logs a `skipped — canIdentify(...) is false` line and returns without
134+
calling the SDK. This mirrors how the SDK itself handles these calls at
135+
lower consent levels: it no-ops rather than throwing, so the sample app
136+
avoids a misleading "ok" log entry for a call that did nothing.
137+
138+
## Error codes
139+
140+
`AudienceError.code` is a closed union: `'FLUSH_FAILED'`,
141+
`'CONSENT_SYNC_FAILED'`, `'NETWORK_ERROR'`, `'VALIDATION_REJECTED'`.
142+
Handle them in an `onError` callback passed at init time:
143+
144+
```js
145+
const audience = window.ImmutableAudience.init({
146+
publishableKey: 'pk_imapik-test-...',
147+
onError: (err) => {
148+
switch (err.code) {
149+
case 'FLUSH_FAILED': /* retryable; the queue will retry automatically */ break;
150+
case 'NETWORK_ERROR': /* usually transient; the queue will retry */ break;
151+
case 'CONSENT_SYNC_FAILED': /* consent PUT failed; state still stored locally */ break;
152+
case 'VALIDATION_REJECTED': /* terminal; messages were dropped */ break;
153+
}
154+
sentryClient.captureException(err);
155+
},
156+
});
157+
```
158+
159+
The sample app's **Lifecycle → Simulate error** dropdown fires `onError`
160+
with the documented shape for any of the four codes. These are client-side
161+
simulations — they don't go through the SDK — so you can verify your
162+
error-handling UI renders correctly for all four without needing a
163+
broken backend.
164+
165+
## CSP
166+
167+
The sample app serves under a tight CSP:
168+
169+
```
170+
default-src 'self';
171+
script-src 'self';
172+
style-src 'self';
173+
connect-src https://api.dev.immutable.com https://api.sandbox.immutable.com https://api.immutable.com
174+
```
175+
176+
No inline scripts, no inline styles, no third-party origins. If you
177+
adapt this sample app for a studio-owned page, keep the same posture —
178+
`@imtbl/audience` is designed to run under a strict CSP.

0 commit comments

Comments
 (0)