|
| 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