@joryio/react-native-sdk 0.0.0-stage → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,2355 @@
1
+ import { NativeModules, NativeEventEmitter, Platform } from 'react-native';
2
+
3
+ const { JoryioModule } = NativeModules;
4
+
5
+ /**
6
+ * Mirrors the backend's SUBSCRIPTION_CHANNELS and the web SDK. Five, including
7
+ * viber - it was added to the backend and to no SDK.
8
+ */
9
+ export type SubscriptionChannel = 'email' | 'sms' | 'whatsapp' | 'push' | 'viber';
10
+
11
+ /**
12
+ * Wire values are camelCase. The backend 400s on `opted_in` - a
13
+ * NormalizeEnumCasing migration renamed the stored value and the enum NAME,
14
+ * not the wire string, is what changed.
15
+ */
16
+ export type SubscriptionStatus = 'optedIn' | 'subscribed' | 'unsubscribed';
17
+
18
+
19
+ const MODULE_MISSING_MESSAGE =
20
+ '@joryio/react-native-sdk: NativeModule not found. ' +
21
+ 'Make sure you ran `pod install` (iOS) / rebuilt the app (Android). ' +
22
+ 'All Joryio calls will be no-ops until the native module is linked.';
23
+
24
+ // Degrade instead of crashing the whole JS bundle on import: if the native
25
+ // module isn't linked, warn once and turn every method into a safe no-op.
26
+ const isModuleAvailable = !!JoryioModule;
27
+ if (!isModuleAvailable) {
28
+ // eslint-disable-next-line no-console
29
+ console.warn(MODULE_MISSING_MESSAGE);
30
+ }
31
+
32
+ // ─── Types ───────────────────────────────────────────────────────────────────
33
+
34
+ export interface JoryioConfig {
35
+ /**
36
+ * Ask for notification permission automatically, soon after initialize().
37
+ *
38
+ * **Off by default, and that default is the recommendation.** The system
39
+ * prompt can be shown exactly once and a refusal is permanent short of a trip
40
+ * to Settings, so firing it at launch - before the user has any reason to
41
+ * want notifications from you - is how apps spend their single ask for
42
+ * nothing. Prefer a primer (the `request_push_permission` in-app button) and
43
+ * call `requestPushPermission()` when the user says yes.
44
+ *
45
+ * Turn this on only when notifications ARE the product.
46
+ *
47
+ * Works on both platforms. On iOS the prompt fires straight after
48
+ * initialize(); on Android it waits for the first foreground, because the
49
+ * system prompt there needs an Activity.
50
+ */
51
+ requestPushPermissionAtLaunch?: boolean;
52
+ /** Enable debug logging (default: false) */
53
+ enableDebug?: boolean;
54
+ /**
55
+ * Log level: 'debug' | 'info' | 'warn' | 'error'.
56
+ *
57
+ * No fixed default: left unset, both native bridges derive it from
58
+ * `enableDebug` - 'debug' when that is true, 'error' otherwise. Set it only
59
+ * to override that derivation (for example 'warn' with `enableDebug: true`).
60
+ */
61
+ logLevel?: 'debug' | 'info' | 'warn' | 'error';
62
+ /** Event batch size before auto-flush. Omit to use the native default (50). */
63
+ batchSize?: number;
64
+ /** Event flush interval in ms. Omit to use the native default (5000). */
65
+ flushInterval?: number;
66
+ /**
67
+ * Send each event as soon as it is tracked instead of batching (default:
68
+ * false). Not recommended for production - one request per event.
69
+ */
70
+ sendImmediately?: boolean;
71
+ /**
72
+ * Most events the local queue holds while flushes fail; beyond it the oldest
73
+ * are dropped. Omit to use the native default (1000).
74
+ */
75
+ maxQueueSize?: number;
76
+ /** Per-request HTTP timeout in ms. Omit to use the native default (10000). */
77
+ requestTimeout?: number;
78
+ /** Session timeout in ms. Omit to use the native default (1800000 / 30 min). */
79
+ sessionTimeout?: number;
80
+ /** Automatically track session start (default: true) */
81
+ trackSessionStart?: boolean;
82
+ /** Initial user ID if known at init time */
83
+ userId?: string;
84
+ /**
85
+ * Your own anonymous id, if you already have one for this device.
86
+ *
87
+ * Used for this run and stored only when nothing is stored yet, exactly as
88
+ * the native SDKs and the web SDK treat it - so an id set once at init
89
+ * survives the app dropping the option later. Omit to let the SDK generate
90
+ * one.
91
+ */
92
+ anonymousId?: string;
93
+ /**
94
+ * Enable JWT-based SDK Authentication (default: false). When enabled, supply a
95
+ * customer-minted JWT via `sdkAuthenticationToken` and/or
96
+ * `setSdkAuthenticationToken()`; it is attached to every ingest request.
97
+ */
98
+ enableSdkAuthentication?: boolean;
99
+ /** Initial SDK-authentication JWT, if known at init time. */
100
+ sdkAuthenticationToken?: string;
101
+
102
+ /**
103
+ * Allow in-app messages authored as HTML, which can execute JavaScript
104
+ * inside your app.
105
+ *
106
+ * OFF BY DEFAULT. The server strips `<script>` and every `on*` handler
107
+ * before sending, so author JavaScript does not run in normal operation. The
108
+ * document is still rendered in a web view that can execute script, so the
109
+ * residual risk is a sanitiser bypass - and because the document is authored
110
+ * server-side, a compromised marketing account would then mean code
111
+ * executing in every user's session, which is why regulated apps often
112
+ * prohibit it outright.
113
+ *
114
+ * Leaving this off does NOT disable in-app messaging: native messages still
115
+ * display, because they are data your app renders with its own components.
116
+ */
117
+ allowHtmlJsInAppMessages?: boolean;
118
+
119
+ /**
120
+ * Tracking consent at start-up. Same option and values as the web, iOS,
121
+ * Android and Unity SDKs.
122
+ *
123
+ * Leave it UNSET unless your consent banner has an answer for this launch.
124
+ * Unset means "no decision here": with nothing stored the SDK runs as
125
+ * `'granted'`, and a `'denied'` stored by an earlier launch stays in force.
126
+ * Passing `'granted'` is an explicit decision and lifts a stored denial.
127
+ *
128
+ * - `'granted'` - normal operation.
129
+ * - `'pending'` - nothing is SENT: events and attribute writes queue on the
130
+ * device (bounded by `maxQueueSize`), and there is no session start, no
131
+ * in-app sync and no push registration until you call
132
+ * `updateConsent('granted')`. `updateConsent('denied')` discards them.
133
+ * Applies to this launch only and never replaces a stored `'denied'`.
134
+ * - `'denied'` - nothing is collected and queued events are cleared. Stored,
135
+ * so it holds on later launches until `updateConsent('granted')` or an
136
+ * explicit `trackingConsent: 'granted'`.
137
+ *
138
+ * Consent and the opt-out ({@link JoryioSDK.optOut}) are separate switches,
139
+ * and any "no" wins: `'granted'` never cancels an opt-out.
140
+ */
141
+ trackingConsent?: TrackingConsent;
142
+ /**
143
+ * Start opted out of tracking (default: false). The same as calling
144
+ * {@link JoryioSDK.optOut} straight after initialize(), without the window in
145
+ * between. An opt-out is stored on the device and survives restarts, so
146
+ * passing `false` here does not undo an earlier `optOut()` - call `optIn()`.
147
+ */
148
+ optOut?: boolean;
149
+ /**
150
+ * Android only: the name of the notification channel Joryio push uses, as
151
+ * people see it in system Settings (Android 8+). Pass it localized - for
152
+ * example from your app's own strings. Omit to use the native default,
153
+ * "Notifications". Ignored on iOS, which has no notification channels.
154
+ */
155
+ notificationChannelName?: string;
156
+ }
157
+
158
+ /** Tracking consent values, identical on every Joryio SDK. */
159
+ export type TrackingConsent = 'granted' | 'denied' | 'pending';
160
+
161
+ /**
162
+ * Attribute values. `null` UNSETS the attribute - the same as
163
+ * `unsetAttribute(key)` - on both platforms.
164
+ */
165
+ export type UserAttributes = Record<string, string | number | boolean | null>;
166
+
167
+ /**
168
+ * Notification permission state, identical on iOS and Android.
169
+ *
170
+ * `unavailable` means we could not ask at all (no Activity, SDK not ready, or a
171
+ * platform with no notification support) - distinct from `denied`, which means
172
+ * the user said no and the prompt will not appear again.
173
+ */
174
+ export type PushPermissionStatus =
175
+ | 'granted'
176
+ | 'denied'
177
+ | 'notDetermined'
178
+ | 'unavailable';
179
+
180
+ /** Reason an ingest request was rejected with a 401 `sdk_authentication_error`. */
181
+ export interface SdkAuthError {
182
+ reason: 'missing' | 'invalid' | 'expired' | 'sub_mismatch' | 'unknown';
183
+ /**
184
+ * Raw wire `reason` string, for logging / forward-compat.
185
+ *
186
+ * Worth reading whenever `reason` is `'unknown'`: that value means only that
187
+ * THIS SDK version did not recognise what the server sent, and this is the
188
+ * only place the server's actual word survives.
189
+ */
190
+ rawReason?: string;
191
+ /** The ingest endpoint whose request was rejected. */
192
+ endpoint?: string;
193
+ /**
194
+ * The SDK has STOPPED trying for this request - either no fresh token
195
+ * arrived, or one did and was rejected as well.
196
+ *
197
+ * `false` means the SDK is asking you for a token; `true` means it gave up.
198
+ * Branch on this rather than on the arrival of an error, or a handler that
199
+ * signs the user out fires on the first recoverable rejection.
200
+ *
201
+ * Both platforms emit all four fields. Until 2026-08-31 iOS sent
202
+ * {reason, endpoint, refreshExhausted} and Android sent {reason, rawReason},
203
+ * so half of any handler was dead on whichever platform was not tested.
204
+ */
205
+ refreshExhausted?: boolean;
206
+ }
207
+
208
+ /**
209
+ * An in-app message to display.
210
+ *
211
+ * `kind` discriminates the content. BOTH kinds reach JS: a React Native app
212
+ * renders its own UI, so dropping native here would mean native campaigns never
213
+ * display in RN apps while still counting as delivered server-side.
214
+ *
215
+ * Native fields are TEXT, not markup - the server does not HTML-escape them
216
+ * (escaping would show the user `A &amp; B`), so rendering one into a WebView
217
+ * or `dangerouslySetInnerHTML` would reintroduce the injection native avoids.
218
+ * Put them in a <Text>.
219
+ */
220
+ export type InAppMessage = InAppMessageBase &
221
+ (
222
+ | { kind: 'html'; html: string; css: string }
223
+ | {
224
+ kind: 'native';
225
+ title?: string;
226
+ body: string;
227
+ imageUrl?: string;
228
+ buttons: Array<{
229
+ id: string;
230
+ text: string;
231
+ /**
232
+ * What the button does when your renderer draws it:
233
+ *
234
+ * - `dismiss` - close the message.
235
+ * - `url` - open `url` in the browser.
236
+ * - `deep_link` - route to `url` inside your app.
237
+ * - `request_push_permission` - a push primer's "yes" button: close
238
+ * the message and call `Joryio.requestPushPermission()`.
239
+ *
240
+ * Report the tap with `trackInAppImpression(message.id, 'clicked')`
241
+ * in every case.
242
+ */
243
+ action: InAppButtonAction;
244
+ url?: string;
245
+ }>;
246
+ closeButton: boolean;
247
+ backdropDismissible: boolean;
248
+ /**
249
+ * Optional presentation overrides set by the campaign author.
250
+ *
251
+ * Every field is optional and an absent one means INHERIT your app's
252
+ * own styling - which is why a message with no overrides looks like the
253
+ * rest of your UI. Apply the ones that are present; do not substitute
254
+ * defaults of your own for the ones that are not.
255
+ *
256
+ * `fontFamily` is best effort: apply it only if your app bundles that
257
+ * font, and keep your own typeface when it does not - substituting an
258
+ * arbitrary font looks worse than ignoring the override.
259
+ */
260
+ style?: {
261
+ backgroundColor?: string;
262
+ textColor?: string;
263
+ primaryButtonColor?: string;
264
+ primaryButtonTextColor?: string;
265
+ cornerRadius?: number;
266
+ /** Body size; scale the headline from it to keep the hierarchy. */
267
+ fontSize?: number;
268
+ titleWeight?: 'regular' | 'medium' | 'semibold' | 'bold';
269
+ /**
270
+ * `auto` (or absent) means align by the MESSAGE's own language, not
271
+ * the device locale - `I18nManager.isRTL` is the wrong test, because
272
+ * one workspace sends Hebrew and English to the same app. React
273
+ * Native's `writingDirection: 'auto'` does this for you.
274
+ */
275
+ textAlign?: 'auto' | 'start' | 'center' | 'end';
276
+ /** Apply only if your app bundles it; otherwise keep your own. */
277
+ fontFamily?: string;
278
+ };
279
+ }
280
+ );
281
+
282
+ /** Native in-app button actions, spelled as both native bridges send them. */
283
+ export type InAppButtonAction = 'dismiss' | 'url' | 'deep_link' | 'request_push_permission';
284
+
285
+ export interface InAppMessageBase {
286
+ id: string;
287
+ name: string;
288
+ type: 'modal' | 'banner' | 'slideup' | 'fullscreen' | 'custom';
289
+ priority: number;
290
+ }
291
+
292
+ // ─── Event Emitter ───────────────────────────────────────────────────────────
293
+
294
+ // Only construct the emitter when the native module exists — NativeEventEmitter
295
+ // throws if handed a null module.
296
+ const emitter = isModuleAvailable ? new NativeEventEmitter(JoryioModule) : null;
297
+
298
+ /** The server's verdict on a form submitted from an HTML in-app message. */
299
+ export interface InAppFormResult {
300
+ ok: boolean;
301
+ message?: string;
302
+ redirect?: string;
303
+ errors?: string[];
304
+ }
305
+
306
+ /**
307
+ * One meaning for "no capabilities" on both platforms: an omitted or empty
308
+ * list is NOT DECLARED (null). Android already read `[]` that way; iOS
309
+ * forwarded it as "this app can draw nothing", which targets every campaign
310
+ * away from the device.
311
+ */
312
+ function normalizeCapabilities(capabilities?: readonly string[] | null): string[] | null {
313
+ const declared = (Array.isArray(capabilities) ? capabilities : []).filter(
314
+ (c) => typeof c === 'string' && c.length > 0,
315
+ );
316
+ return declared.length > 0 ? declared : null;
317
+ }
318
+
319
+ // ─── Host-input guards (contract item 19) ────────────────────────────────────
320
+ //
321
+ // Null or undefined from host code must never crash the app and never disable
322
+ // the SDK. JavaScript does not enforce the TypeScript signatures, and a null
323
+ // reaching a non-null Kotlin parameter is a NullPointerException on the native
324
+ // modules thread - a crash in a release build, not a red box. So every value
325
+ // is checked HERE, before the bridge; the bridges also take nullable
326
+ // parameters, as the second line of defence.
327
+
328
+ function warn(message: string): void {
329
+ // eslint-disable-next-line no-console
330
+ console.warn(`[Joryio] ${message}`);
331
+ }
332
+
333
+ /** A non-empty string, or a warning naming the call and the argument. */
334
+ function requireString(caller: string, name: string, value: unknown): value is string {
335
+ if (typeof value === 'string' && value.length > 0) return true;
336
+ warn(`${caller}() needs a non-empty string ${name}; ignored.`);
337
+ return false;
338
+ }
339
+
340
+ /** A plain object (not null, not an array). */
341
+ function isRecord(value: unknown): value is Record<string, unknown> {
342
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
343
+ }
344
+
345
+ /**
346
+ * An e-commerce id or label: not empty and not only whitespace, the same as
347
+ * the native trackers. (Event names are different: a whitespace-only name is
348
+ * the server's to judge - requireString does not trim.)
349
+ */
350
+ function isNonBlankString(value: unknown): value is string {
351
+ return typeof value === 'string' && value.trim().length > 0;
352
+ }
353
+
354
+ /** Properties as a plain object; null, undefined or a non-object means none. */
355
+ function plainObject(value: unknown): Record<string, unknown> {
356
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
357
+ ? (value as Record<string, unknown>)
358
+ : {};
359
+ }
360
+
361
+ /**
362
+ * A tracking consent value in the one spelling every SDK uses, or null.
363
+ *
364
+ * Case-insensitive, because the two bridges were not: Android matched
365
+ * `'Denied'` and iOS ignored it, so the same config started one platform
366
+ * denied and the other with no decision at all.
367
+ */
368
+ function parseConsent(value: unknown): TrackingConsent | null {
369
+ if (typeof value !== 'string') return null;
370
+ const lower = value.trim().toLowerCase();
371
+ return lower === 'granted' || lower === 'denied' || lower === 'pending' ? lower : null;
372
+ }
373
+
374
+ /**
375
+ * The config as the bridges receive it: null values left out (see
376
+ * withoutNullValues) and `trackingConsent` in its lowercase spelling. An
377
+ * unrecognised consent is left out with a warning - UNSET, never a guess -
378
+ * so the native SDK keeps whatever decision it has stored.
379
+ */
380
+ function nativeConfig(config: unknown): Record<string, unknown> {
381
+ const out = withoutNullValues(config);
382
+ // Lower-cased like trackingConsent: iOS matched 'Warn' against nothing and
383
+ // fell back to the enableDebug level, while Android upper-cased it.
384
+ if (typeof out.logLevel === 'string') out.logLevel = out.logLevel.trim().toLowerCase();
385
+ if ('trackingConsent' in out) {
386
+ const parsed = parseConsent(out.trackingConsent);
387
+ if (parsed === null) {
388
+ warn(
389
+ `initialize() trackingConsent takes 'granted', 'denied' or 'pending', not ` +
390
+ `'${String(out.trackingConsent)}'; left unset.`,
391
+ );
392
+ delete out.trackingConsent;
393
+ } else {
394
+ out.trackingConsent = parsed;
395
+ }
396
+ }
397
+ return out;
398
+ }
399
+
400
+ /**
401
+ * The config with every null / undefined value left out, so the native SDK
402
+ * applies its own default for it. A present-but-null key used to reach the
403
+ * Android bridge, whose `getInt` threw - initialize() failed and the SDK was
404
+ * off for the whole session because of a `batchSize: null`.
405
+ */
406
+ function withoutNullValues(config: unknown): Record<string, unknown> {
407
+ const out: Record<string, unknown> = {};
408
+ for (const [key, value] of Object.entries(plainObject(config))) {
409
+ if (value !== null && value !== undefined) out[key] = value;
410
+ }
411
+ return out;
412
+ }
413
+
414
+ /** A number that is NaN or +/-Infinity. */
415
+ function isNonFiniteNumber(value: unknown): boolean {
416
+ return typeof value === 'number' && !Number.isFinite(value);
417
+ }
418
+
419
+ /**
420
+ * An object the bridge serializes by its own enumerable keys - a class
421
+ * instance, a MobX observable, a model object - but not a Date, which it
422
+ * serializes as a value. Scanned for NaN like a plain object.
423
+ */
424
+ function isKeyedObject(value: unknown): value is Record<string, unknown> {
425
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date);
426
+ }
427
+
428
+ /** Plain `{}` / `Object.create(null)` objects - not Dates, class instances or arrays. */
429
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
430
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
431
+ const proto = Object.getPrototypeOf(value);
432
+ return proto === Object.prototype || proto === null;
433
+ }
434
+
435
+ /** Nesting followed when dropping non-finite numbers; the backend allows 5. */
436
+ const MAX_FINITE_SCAN_DEPTH = 10;
437
+
438
+ /**
439
+ * The value with every NaN / Infinity left out (contract v5 49): an object
440
+ * key holding one is omitted, an array element is removed, and the call goes
441
+ * ahead with the rest. Each drop is reported once per call through `dropped`.
442
+ *
443
+ * Not left to the natives, because the bridge changes them on the way: the
444
+ * JSON bridge turns NaN into `null`, which means UNSET to an attribute write,
445
+ * so `setAttribute('score', NaN)` erased the stored score.
446
+ */
447
+ function withoutNonFinite(value: unknown, path: string, dropped: string[], depth = 0): unknown {
448
+ if (depth > MAX_FINITE_SCAN_DEPTH) return value;
449
+ if (Array.isArray(value)) {
450
+ const out: unknown[] = [];
451
+ value.forEach((item, i) => {
452
+ if (isNonFiniteNumber(item)) dropped.push(`${path}[${i}]`);
453
+ else out.push(withoutNonFinite(item, `${path}[${i}]`, dropped, depth + 1));
454
+ });
455
+ return out;
456
+ }
457
+ if (isKeyedObject(value)) {
458
+ const droppedBefore = dropped.length;
459
+ const out: Record<string, unknown> = {};
460
+ for (const [key, item] of Object.entries(value)) {
461
+ const at = path ? `${path}.${key}` : key;
462
+ if (isNonFiniteNumber(item)) dropped.push(at);
463
+ else out[key] = withoutNonFinite(item, at, dropped, depth + 1);
464
+ }
465
+ // A class instance (a cart line built from `class Line`, a MobX
466
+ // observable) is scanned too: it reached the bridge with its NaN, which
467
+ // Android read as a quantity of 0 and iOS as none. Copied into a plain
468
+ // object only when something was dropped - the bridge sends the same own
469
+ // keys either way - and otherwise passed on untouched.
470
+ if (!isPlainObject(value) && dropped.length === droppedBefore) return value;
471
+ return out;
472
+ }
473
+ return value;
474
+ }
475
+
476
+ /** {@link withoutNonFinite} for a properties object, warning once naming the keys. */
477
+ function finiteProperties(caller: string, properties: Record<string, unknown>): Record<string, unknown> {
478
+ const dropped: string[] = [];
479
+ const out = withoutNonFinite(properties, '', dropped) as Record<string, unknown>;
480
+ if (dropped.length > 0) {
481
+ warn(`${caller}() left out ${dropped.join(', ')}: NaN and Infinity are not valid values.`);
482
+ }
483
+ return out;
484
+ }
485
+
486
+ /**
487
+ * A host callback that can never throw into the event dispatch (contract
488
+ * items 34 and 56). React Native runs every listener of an event in one loop:
489
+ * a callback that threw - a renderer meeting a message shape it did not
490
+ * expect - was a fatal JS error in a release build and skipped every other
491
+ * listener of the same event. A returned promise that rejects is caught too.
492
+ */
493
+ function guardedCallback<T>(name: string, callback: (event: T) => unknown): (event: T) => void {
494
+ const report = (err: unknown) => {
495
+ // eslint-disable-next-line no-console
496
+ console.warn(`[Joryio] your ${name} callback threw; the SDK carries on.`, err);
497
+ };
498
+ return (event: T) => {
499
+ try {
500
+ const result = callback(event);
501
+ if (result !== null && typeof result === 'object' && typeof (result as Promise<unknown>).then === 'function') {
502
+ (result as Promise<unknown>).then(undefined, report);
503
+ }
504
+ } catch (err) {
505
+ report(err);
506
+ }
507
+ };
508
+ }
509
+
510
+ /**
511
+ * An APNs device token: hex, 32 bytes or more. Joryio sends iOS push through
512
+ * APNs, so an FCM registration token registered on iOS replaces the device's
513
+ * working APNs token with one APNs refuses - and React Native Firebase's
514
+ * onTokenRefresh hands the FCM token over at every app start on iOS.
515
+ */
516
+ const APNS_TOKEN = /^(?:[0-9a-fA-F]{2}){32,100}$/;
517
+
518
+ /** The highest |rating| both native trackers can take: they hold it as a 32-bit Int. */
519
+ const MAX_RATING_MAGNITUDE = 2147483647;
520
+
521
+ // ─── SDK Class ───────────────────────────────────────────────────────────────
522
+
523
+ /**
524
+ * How long a value-returning call waits for initialize() before giving up and
525
+ * returning its documented "not available" value. See _awaitReady.
526
+ */
527
+ const READY_TIMEOUT_MS = 5000;
528
+
529
+ /**
530
+ * Most fire-and-forget calls held while initialize() has not completed. Beyond
531
+ * it the OLDEST are dropped. The same cap and policy as every native SDK's
532
+ * pre-initialization buffer (contract item 20), so a call is not kept by one
533
+ * layer and dropped by the next. Before 1.3.0 the hold was unbounded: an app
534
+ * that never called initialize() grew it for the life of the process.
535
+ */
536
+ export const MAX_PRE_INIT_CALLS = 1000;
537
+
538
+ /**
539
+ * What a held call is, which decides what a later privacy decision discards
540
+ * and what a full hold drops first.
541
+ *
542
+ * - `data`: collects something about the person (an event, an attribute, an
543
+ * alias, a subscribe). Discarded by an optOut, a denial and a wipe.
544
+ * - `identity`: identify(). Discarded by a wipe only. Under an opt-out or a
545
+ * denial native keeps the user id on the device, unsent, so that
546
+ * withdrawals carry the right person (contract v5 D-b).
547
+ * - `token`: setSdkAuthenticationToken(). Discarded by a wipe: replayed after
548
+ * it, the erased person's token was handed to the next visitor.
549
+ * - `control`: listeners, reset, flush, sync. Kept.
550
+ * - `withdrawal`: an unsubscribe, leaving a group, a push unregister. Kept
551
+ * through everything and delivered (contract v4 36).
552
+ * - `decision`: optOut, optIn, updateConsent, wipeData made while an
553
+ * initialize() is in flight. Kept.
554
+ */
555
+ type CallKind = 'data' | 'identity' | 'token' | 'control' | 'withdrawal' | 'decision';
556
+
557
+ /**
558
+ * A call held until initialize() succeeds. `tag` marks the two held calls
559
+ * whose identity effect matters to ordering: a wipe (see
560
+ * _handHeldDecisionsToNative) and a reset (see setSdkAuthenticationToken).
561
+ */
562
+ interface HeldCall {
563
+ fn: () => void;
564
+ kind: CallKind;
565
+ tag?: 'wipe' | 'reset';
566
+ /**
567
+ * A `data` call made before the decisions STORED by an earlier launch were
568
+ * known: the decisions made in this process by then (null = none). Judged
569
+ * against the stored ones once native has answered (see _settleStored).
570
+ */
571
+ madeUnder?: PrivacySnapshot;
572
+ /**
573
+ * A `data` call made while an initialize() was in flight and judged with
574
+ * that init's config (contract v9 91): the decisions made in this process
575
+ * by then. If that init fails, native never applied the config, so the
576
+ * call is judged again without it (see _rejudgeWithoutInitConfig).
577
+ */
578
+ madeInWindow?: PrivacySnapshot;
579
+ }
580
+
581
+ /** The two privacy switches as decided in this process; null = not decided here. */
582
+ interface PrivacySnapshot {
583
+ optedOut: boolean | null;
584
+ denied: boolean | null;
585
+ }
586
+
587
+ /**
588
+ * Longest wait for native's answer about the decisions stored by an earlier
589
+ * launch before initialize() goes ahead without it. Native answers from
590
+ * storage at once; this only guards against a bridge that never settles.
591
+ */
592
+ const STORED_DECISIONS_TIMEOUT_MS = 3000;
593
+
594
+ /** A full pre-init hold drops the oldest of these first, in this order (contract v5 51). */
595
+ const EVICTION_ORDER: readonly CallKind[] = ['data', 'identity', 'token', 'control'];
596
+ const DISCARDED_BY_WITHDRAWN_CONSENT: readonly CallKind[] = ['data'];
597
+ const DISCARDED_BY_WIPE: readonly CallKind[] = ['data', 'identity', 'token'];
598
+
599
+ /**
600
+ * Outcome of `registerPushToken`. Same four fields as the native
601
+ * `PushRegistrationResult` on Android and iOS.
602
+ */
603
+ export interface PushRegistrationResult {
604
+ success: boolean;
605
+ /** Provider or transport message on failure; absent on success. */
606
+ message?: string;
607
+ /** A bad or missing SDK key - retrying will not fix it. */
608
+ isUnauthorized: boolean;
609
+ /** Transient (offline, DNS, 5xx, not initialized yet): worth another try. */
610
+ isRetryable: boolean;
611
+ }
612
+
613
+ class JoryioSDK {
614
+ /**
615
+ * Resolves once initialize() has completed. This is the SDK's readiness
616
+ * primitive, and BOTH call shapes are built on it:
617
+ *
618
+ * - fire-and-forget calls (track, identify, …) chain onto it, so they run
619
+ * after init instead of hitting an uninitialized native module;
620
+ * - value-returning calls (getAnonymousId, getUserId, …) AWAIT it, which a
621
+ * callback queue could never do — you cannot buffer something the caller
622
+ * is waiting on the answer to.
623
+ *
624
+ * That gap is what broke React Native in-app messaging: the queue covered the
625
+ * void calls, so every getter called before init threw "Joryio SDK not
626
+ * initialized" straight through to a red box.
627
+ */
628
+ private _ready: Promise<void>;
629
+ private _markReady!: () => void;
630
+ /** True once initialize() has completed SUCCESSFULLY. */
631
+ private _initialized = false;
632
+ /**
633
+ * True when initialize() ran and FAILED. Distinct from "not yet initialized":
634
+ * that one is temporary and worth waiting for, this one never resolves, so
635
+ * every subsequent call must degrade instead of waiting or throwing.
636
+ */
637
+ private _initFailed = false;
638
+ /**
639
+ * Fire-and-forget calls made before initialize() succeeded, in call order,
640
+ * each with its {@link CallKind}. An opt-out or a denial made later
641
+ * discards the held `data` calls, and a wipe also the held `identity` and
642
+ * `token` calls, exactly as the native SDKs discard what they hold. A
643
+ * `withdrawal` is kept through all three and delivered (contract v4 36).
644
+ */
645
+ private _pending: Array<HeldCall> = [];
646
+ /** The initialize() call in progress, if any. A second call joins it. */
647
+ private _initInFlight: Promise<void> | null = null;
648
+ private _warnedPendingOverflow = false;
649
+ /**
650
+ * A wipeData() was handed to native before its initialize(), which runs it
651
+ * inside init. A token set after it must stay held here and follow init:
652
+ * native would apply it before that wipe, which retires it.
653
+ */
654
+ private _wipeHandedToNative = false;
655
+ /**
656
+ * The two privacy switches as decided by calls made in this process
657
+ * before initialize() succeeded (contract v7 73): set by optOut()/optIn()
658
+ * and updateConsent('denied')/'granted' exactly as native records them -
659
+ * 'pending' never reopens a denial. null = no decision made here yet.
660
+ */
661
+ private _preInitOptedOut: boolean | null = null;
662
+ private _preInitDenied: boolean | null = null;
663
+ /**
664
+ * The two switches as the config of the initialize() IN FLIGHT sets them
665
+ * (contract v9 91): `trackingConsent: 'granted'` lifts a stored denial and
666
+ * 'denied' refuses; `optOut: true` refuses, `optOut: false` lifts nothing.
667
+ * Native applies them when that init runs, so a call made meanwhile is
668
+ * judged by them, between this process's decisions and the stored ones.
669
+ * null = the config says nothing; both cleared when that init settles.
670
+ */
671
+ private _initConfigOptedOut: boolean | null = null;
672
+ private _initConfigDenied: boolean | null = null;
673
+ /**
674
+ * The same two switches as STORED on the device by an earlier launch
675
+ * (contract v8 83), read from native when this module loads - both natives
676
+ * answer isUserOptedOut()/getTrackingConsent() from storage before their own
677
+ * initialize() (contract item 44). null until native has answered.
678
+ *
679
+ * Without them, `track('x'); optIn(); initialize()` for a person whose
680
+ * opt-out was stored last launch sent x: the call was held here, optIn went
681
+ * to native straight away, and x reached native only after init, when the
682
+ * opt-out was already lifted.
683
+ */
684
+ private _storedOptedOut: boolean | null = null;
685
+ private _storedDenied: boolean | null = null;
686
+ /** Settles once the stored decisions are known (or could not be read). */
687
+ private _storedKnown: Promise<void> = Promise.resolve();
688
+ private _warnedDroppedWhileOptedOut = false;
689
+ /** Live onInAppMessage subscriptions. Rendering is handed to JS only while > 0. */
690
+ private _inAppListeners = 0;
691
+
692
+ constructor() {
693
+ this._ready = new Promise<void>((resolve) => {
694
+ this._markReady = resolve;
695
+ });
696
+ if (isModuleAvailable) this._storedKnown = this._readStoredDecisions();
697
+ }
698
+
699
+ /**
700
+ * Ask native, first thing, for the decisions an earlier launch stored. The
701
+ * first call this module makes, so native answers before any decision made
702
+ * in this process reaches it. Never rejects and never waits forever: an
703
+ * unreadable answer counts as "nothing stored", which leaves native's own
704
+ * judgement at replay - the behaviour before this read existed.
705
+ */
706
+ private async _readStoredDecisions(): Promise<void> {
707
+ let timer: ReturnType<typeof setTimeout> | undefined;
708
+ try {
709
+ // Both asked synchronously, here in the constructor: native serves
710
+ // calls in the order JS makes them, so these reads are answered before
711
+ // any decision or initialize() made in this process reaches it.
712
+ const asked = Promise.all([JoryioModule.isUserOptedOut?.(), JoryioModule.getTrackingConsent?.()]);
713
+ const read = asked.then(([optedOut, consent]): [boolean, boolean] => [
714
+ optedOut === true,
715
+ parseConsent(consent) === 'denied',
716
+ ]);
717
+ const answer = await Promise.race([
718
+ read,
719
+ new Promise<null>((resolve) => {
720
+ timer = setTimeout(() => resolve(null), STORED_DECISIONS_TIMEOUT_MS);
721
+ // Never keeps a host app's Jest run (or Node) alive on its own.
722
+ (timer as { unref?: () => void }).unref?.();
723
+ }),
724
+ ]);
725
+ if (answer === null) {
726
+ // eslint-disable-next-line no-console
727
+ console.warn('[Joryio] The stored privacy decisions could not be read in time; continuing without them.');
728
+ }
729
+ [this._storedOptedOut, this._storedDenied] = answer ?? [false, false];
730
+ } catch (e) {
731
+ // eslint-disable-next-line no-console
732
+ console.warn('[Joryio] The stored privacy decisions could not be read.', e);
733
+ this._storedOptedOut = false;
734
+ this._storedDenied = false;
735
+ } finally {
736
+ if (timer !== undefined) clearTimeout(timer);
737
+ }
738
+ this._settleStored();
739
+ }
740
+
741
+ /**
742
+ * Whether collecting is refused before initialize(): a decision made in this
743
+ * process wins over the stored one (it is later). 'unknown' while the stored
744
+ * one is still being read and could make the answer yes.
745
+ */
746
+ private _refusedBeforeInit(at: PrivacySnapshot): boolean | 'unknown' {
747
+ const optedOut = at.optedOut ?? this._storedOptedOut;
748
+ const denied = at.denied ?? this._storedDenied;
749
+ if (optedOut === true || denied === true) return true;
750
+ if (optedOut === null || denied === null) return 'unknown';
751
+ return false;
752
+ }
753
+
754
+ /**
755
+ * Native has answered: drop the `data` calls held while its answer was
756
+ * unknown that were made while refused, as they would have been dropped
757
+ * at call time had the answer been there (contract v7 73).
758
+ */
759
+ private _settleStored(): void {
760
+ const kept: HeldCall[] = [];
761
+ let dropped = false;
762
+ for (const call of this._pending) {
763
+ if (call.madeUnder === undefined) {
764
+ kept.push(call);
765
+ continue;
766
+ }
767
+ if (this._refusedBeforeInit(call.madeUnder) === true) {
768
+ dropped = true;
769
+ continue;
770
+ }
771
+ delete call.madeUnder;
772
+ kept.push(call);
773
+ }
774
+ this._pending = kept;
775
+ if (dropped) this._warnDroppedWhileRefused();
776
+ }
777
+
778
+ /**
779
+ * An initialize() failed, so native never applied its config: the `data`
780
+ * calls judged with it while it was in flight are judged again by this
781
+ * process's decisions and the stored ones alone (contract v9 91).
782
+ */
783
+ private _rejudgeWithoutInitConfig(): void {
784
+ const kept: HeldCall[] = [];
785
+ let dropped = false;
786
+ for (const call of this._pending) {
787
+ const at = call.madeInWindow;
788
+ if (at !== undefined) {
789
+ delete call.madeInWindow;
790
+ const refused = this._refusedBeforeInit(at);
791
+ if (refused === true) {
792
+ dropped = true;
793
+ continue;
794
+ }
795
+ if (refused === 'unknown') call.madeUnder = at;
796
+ }
797
+ kept.push(call);
798
+ }
799
+ this._pending = kept;
800
+ if (dropped) this._warnDroppedWhileRefused();
801
+ }
802
+
803
+ private _warnDroppedWhileRefused(): void {
804
+ if (this._warnedDroppedWhileOptedOut) return;
805
+ this._warnedDroppedWhileOptedOut = true;
806
+ // eslint-disable-next-line no-console
807
+ console.warn(
808
+ '[Joryio] A call that collects data was made while tracking is opted out or consent is ' +
809
+ 'denied; it is dropped and a later optIn() or grant does not send it.',
810
+ );
811
+ }
812
+
813
+ /**
814
+ * Wait for initialize(), but never forever.
815
+ *
816
+ * An unbounded await would be WORSE than the throw it replaces: a host app
817
+ * that forgets initialize() (or whose init failed) would hang on the first
818
+ * getter with no error to search for. On timeout the caller gets the same
819
+ * "not available" value it already gets when the native module is missing,
820
+ * plus a warning naming the actual cause.
821
+ *
822
+ * @returns true if the SDK became ready, false if it timed out.
823
+ */
824
+ private async _awaitReady(caller: string): Promise<boolean> {
825
+ if (this._initialized) return true;
826
+ if (this._initFailed) return false;
827
+ const timedOut = Symbol('timeout');
828
+ let timer: ReturnType<typeof setTimeout> | undefined;
829
+ const result = await Promise.race([
830
+ this._ready,
831
+ new Promise<typeof timedOut>((resolve) => {
832
+ timer = setTimeout(() => resolve(timedOut), READY_TIMEOUT_MS);
833
+ }),
834
+ ]);
835
+ // Cleared either way: a timer left running after initialize() won the
836
+ // race keeps a host app's Jest run alive ("worker failed to exit").
837
+ if (timer !== undefined) clearTimeout(timer);
838
+ if (result === timedOut) {
839
+ // eslint-disable-next-line no-console
840
+ console.warn(
841
+ `[Joryio] ${caller}() was called but initialize() has not completed after ` +
842
+ `${READY_TIMEOUT_MS}ms - returning a default. Call Joryio.initialize() at app start.`,
843
+ );
844
+ return false;
845
+ }
846
+ // Readiness settles on a FAILED initialize() too (it must, or every waiter
847
+ // would hang). Settling is not success: answering true here sent a caller
848
+ // that was waiting when init failed on to the native module, which was
849
+ // never initialized - and a native promise that is never settled there
850
+ // (registerPushToken's completion, for one) hung the caller forever.
851
+ return this._initialized && !this._initFailed;
852
+ }
853
+
854
+ /**
855
+ * A privacy getter (isUserOptedOut, getTrackingConsent). Both native SDKs
856
+ * answer these from STORED state before their own initialize() (contract
857
+ * item 44), so before initialize() has been called - or after one that
858
+ * failed - native is asked straight away. They used to wait up to five
859
+ * seconds for an initialize() that might never come and then answer
860
+ * `false` / `null`, which reads as "not opted out" for a person whose
861
+ * opt-out is stored on the device.
862
+ *
863
+ * While an initialize() is in flight the answer waits for it, because a
864
+ * decision made in that window is queued behind it and native would not
865
+ * know it yet. Native is asked whatever the outcome (contract v6 65): when
866
+ * that init FAILS, the decisions held meanwhile have just been handed to
867
+ * native (_handHeldDecisionsToNative), so its stored answer is current. It
868
+ * used to answer the default `false` / `null` then without asking native.
869
+ */
870
+ private async _queryDecision<T>(caller: string, fallback: T, fn: () => Promise<T>): Promise<T> {
871
+ if (!isModuleAvailable) return fallback;
872
+ if (this._initInFlight !== null && !this._initialized) await this._awaitReady(caller);
873
+ try {
874
+ const value = await fn();
875
+ return value ?? fallback;
876
+ } catch (e) {
877
+ // eslint-disable-next-line no-console
878
+ console.warn(`[Joryio] ${caller}() failed`, e);
879
+ return fallback;
880
+ }
881
+ }
882
+
883
+ /**
884
+ * Route a fire-and-forget native call safely:
885
+ * - no-op when the native module isn't linked,
886
+ * - defer until initialize() resolves so we never hit native before init,
887
+ * - swallow (and warn) native errors so a bad call can't crash the host app.
888
+ */
889
+ private _dispatch(fn: () => void, kind: CallKind = 'control', tag?: HeldCall['tag']): void {
890
+ if (!isModuleAvailable) return;
891
+ if (this._initialized) {
892
+ this._invoke(fn);
893
+ return;
894
+ }
895
+ // Not ready yet - or initialize() FAILED: hold the call, in order, for the
896
+ // initialize() that succeeds. A failed init used to DROP every call made
897
+ // after it and discard everything held before it, so an app whose first
898
+ // initialize() failed (a wrong key from a remote config) and whose retry
899
+ // worked had lost its onInAppMessage hand-off (native kept drawing and JS
900
+ // never received a message), its SDK-auth error listener and every
901
+ // unsubscribe made meanwhile. Both native SDKs keep what they hold for
902
+ // an initialize() that succeeds; this does the same.
903
+ //
904
+ // Except a data call made while opted out or denied (contract v7 73) -
905
+ // by a decision made in this process or one an earlier launch stored
906
+ // (contract v8 83): dropped now, so a later optIn()/grant never sends
907
+ // it. Identity, tokens, control calls, withdrawals and decisions are kept
908
+ // (D-b keeps a held identify for the withdrawals). While the stored
909
+ // decisions are still being read, the call is held with the decisions
910
+ // made so far and judged when native answers (_settleStored), before any
911
+ // initialize() replays it.
912
+ let madeUnder: PrivacySnapshot | undefined;
913
+ let madeInWindow: PrivacySnapshot | undefined;
914
+ if (kind === 'data') {
915
+ // In-process decision, else the in-flight init's config, else stored.
916
+ const at: PrivacySnapshot = {
917
+ optedOut: this._preInitOptedOut ?? this._initConfigOptedOut,
918
+ denied: this._preInitDenied ?? this._initConfigDenied,
919
+ };
920
+ const refused = this._refusedBeforeInit(at);
921
+ if (refused === true) {
922
+ this._warnDroppedWhileRefused();
923
+ return;
924
+ }
925
+ if (refused === 'unknown') madeUnder = at;
926
+ if (this._initConfigOptedOut !== null || this._initConfigDenied !== null) {
927
+ madeInWindow = { optedOut: this._preInitOptedOut, denied: this._preInitDenied };
928
+ }
929
+ }
930
+ if (this._pending.length >= MAX_PRE_INIT_CALLS) {
931
+ this._evictOneHeld();
932
+ if (!this._warnedPendingOverflow) {
933
+ this._warnedPendingOverflow = true;
934
+ // eslint-disable-next-line no-console
935
+ console.warn(
936
+ `[Joryio] More than ${MAX_PRE_INIT_CALLS} calls were made before initialize() ` +
937
+ 'succeeded; the oldest are being dropped (withdrawals and privacy decisions last). ' +
938
+ 'Call Joryio.initialize() at app start.',
939
+ );
940
+ }
941
+ }
942
+ const call: HeldCall = { fn, kind };
943
+ if (tag) call.tag = tag;
944
+ if (madeUnder) call.madeUnder = madeUnder;
945
+ if (madeInWindow) call.madeInWindow = madeInWindow;
946
+ this._pending.push(call);
947
+ }
948
+
949
+ /**
950
+ * Make room in a full pre-init hold (contract v5 51): the oldest call of the
951
+ * first kind present in EVICTION_ORDER goes. An unsubscribe, a push
952
+ * unregister or a privacy decision is dropped only when the hold holds
953
+ * nothing else. It used to drop the oldest call whatever it was, so an
954
+ * unsubscribe followed by a thousand track() calls lost the unsubscribe.
955
+ */
956
+ private _evictOneHeld(): void {
957
+ for (const kind of EVICTION_ORDER) {
958
+ const i = this._pending.findIndex((call) => call.kind === kind);
959
+ if (i >= 0) {
960
+ this._pending.splice(i, 1);
961
+ return;
962
+ }
963
+ }
964
+ this._pending.shift();
965
+ }
966
+
967
+ /**
968
+ * A privacy decision - optOut, optIn, updateConsent, wipeData.
969
+ *
970
+ * Made BEFORE initialize() (contract item 25), it goes to the native module
971
+ * straight away instead of waiting in the queue above. Both native SDKs
972
+ * record a decision made before their own initialize() and apply it INSIDE
973
+ * it, ahead of the Session Start, the in-app sync and the push
974
+ * re-registration. Queued here, it reached native only after all three had
975
+ * run - so an app that called optOut() before initialize() still sent a
976
+ * Session Start for someone who had said no, and then a `tracking_opted_out`
977
+ * marker for a person who had never consented to anything.
978
+ *
979
+ * The native module processes calls in the order JS makes them, so the
980
+ * decision is recorded before the initialize() that follows it.
981
+ *
982
+ * @param discards the kinds of held call the decision drops instead of
983
+ * replaying after it: `data` for an opt-out or a denial, and also
984
+ * `identity` and `token` for a wipe. Nothing for optIn or a grant.
985
+ */
986
+ private _decide(fn: () => void, discards: readonly CallKind[]): void {
987
+ if (!isModuleAvailable) return;
988
+ if (discards.length > 0) this._discardHeld(discards);
989
+ if (!this._initialized && this._initInFlight === null) {
990
+ // A wipe erases the identity the withdrawals held here were made under.
991
+ // They go to native first, so native buffers them AHEAD of the wipe and
992
+ // runs them under that identity before erasing it (Unity scan 5, N4).
993
+ // Held here, they replayed after native's init had already wiped, under
994
+ // the new anonymous id - a profile that never received anything.
995
+ if (discards === DISCARDED_BY_WIPE) {
996
+ this._sendHeldWithdrawalsNow();
997
+ this._wipeHandedToNative = true;
998
+ }
999
+ // Not started (or a failed start): native records it for its next init.
1000
+ this._invoke(fn);
1001
+ return;
1002
+ }
1003
+ // initialize() already called: the decision comes after it, in order.
1004
+ // If that initialize() fails, _handHeldDecisionsToNative passes it on.
1005
+ this._dispatch(fn, 'decision', discards === DISCARDED_BY_WIPE ? 'wipe' : undefined);
1006
+ }
1007
+
1008
+ /**
1009
+ * After a FAILED initialize(), hand the privacy decisions held while it was
1010
+ * in flight to native now, in call order (contract v6 65). Nothing is in
1011
+ * flight any more, so they take the pre-init path of _decide: native
1012
+ * records each for its next initialize() and applies it ahead of that
1013
+ * init's Session Start, in-app sync and push registration.
1014
+ *
1015
+ * Left held, they replayed only after the RETRY's initialize() and its side
1016
+ * effects - a person who declined while the first init was failing got a
1017
+ * Session Start - and after any decision made since the failure, which goes
1018
+ * to native directly: an optIn() held from the failing window overrode a
1019
+ * later optOut(), so the person ended opted in.
1020
+ *
1021
+ * Withdrawals go too, in place, while nothing but decisions and
1022
+ * withdrawals precede them. One held behind a data, identity or control
1023
+ * call stays held with it: handed ahead of an earlier subscribe for the
1024
+ * same channel, the subscribe would replay later and supersede it; ahead of
1025
+ * an identify, it would be filed under the wrong person. A wipe takes the
1026
+ * withdrawals held before it along first, as _decide does, so native runs
1027
+ * them under the identity being erased.
1028
+ */
1029
+ private _handHeldDecisionsToNative(): void {
1030
+ const held = this._pending;
1031
+ this._pending = [];
1032
+ let onlyPrivacyCallsSoFar = true;
1033
+ for (const call of held) {
1034
+ if (call.kind === 'decision') {
1035
+ if (call.tag === 'wipe') {
1036
+ this._sendHeldWithdrawalsNow();
1037
+ this._wipeHandedToNative = true;
1038
+ }
1039
+ this._invoke(call.fn);
1040
+ } else if (call.kind === 'withdrawal' && onlyPrivacyCallsSoFar) {
1041
+ this._invoke(call.fn);
1042
+ } else {
1043
+ if (call.kind !== 'withdrawal') onlyPrivacyCallsSoFar = false;
1044
+ this._pending.push(call);
1045
+ }
1046
+ }
1047
+ }
1048
+
1049
+ /** Hand the held withdrawals to native now, in order; keep the rest held. */
1050
+ private _sendHeldWithdrawalsNow(): void {
1051
+ const withdrawals = this._pending.filter((call) => call.kind === 'withdrawal');
1052
+ this._pending = this._pending.filter((call) => call.kind !== 'withdrawal');
1053
+ withdrawals.forEach((call) => this._invoke(call.fn));
1054
+ }
1055
+
1056
+ /** Drop the held calls of these kinds; keep the rest, in order. */
1057
+ private _discardHeld(kinds: readonly CallKind[]): void {
1058
+ this._pending = this._pending.filter((call) => !kinds.includes(call.kind));
1059
+ }
1060
+
1061
+ private _invoke(fn: () => void): void {
1062
+ try {
1063
+ fn();
1064
+ } catch (e) {
1065
+ // eslint-disable-next-line no-console
1066
+ console.warn('[Joryio] native call failed', e);
1067
+ }
1068
+ }
1069
+
1070
+ /**
1071
+ * Replay the calls held before a SUCCESSFUL init. After a failed one they
1072
+ * stay held for the next initialize() (see _dispatch).
1073
+ */
1074
+ private _drainPending(): void {
1075
+ if (this._initFailed) return;
1076
+ const held = this._pending;
1077
+ this._pending = [];
1078
+ held.forEach((call) => this._invoke(call.fn));
1079
+ }
1080
+
1081
+ /**
1082
+ * Value-returning native call, gated on readiness, that never throws into
1083
+ * the host app: a missing module, an init that never completed and a native
1084
+ * rejection all answer `fallback`.
1085
+ */
1086
+ private async _query<T>(caller: string, fallback: T, fn: () => Promise<T>): Promise<T> {
1087
+ if (!isModuleAvailable) return fallback;
1088
+ if (!(await this._awaitReady(caller))) return fallback;
1089
+ try {
1090
+ const value = await fn();
1091
+ return value ?? fallback;
1092
+ } catch (e) {
1093
+ // eslint-disable-next-line no-console
1094
+ console.warn(`[Joryio] ${caller}() failed`, e);
1095
+ return fallback;
1096
+ }
1097
+ }
1098
+
1099
+ /**
1100
+ * Initialize the SDK. Must be called before any other method.
1101
+ *
1102
+ * @example
1103
+ * ```ts
1104
+ * import Joryio from '@joryio/react-native-sdk';
1105
+ *
1106
+ * // Each platform has its own key: iOS keys start with jry_sdk_ios_,
1107
+ * // Android keys with jry_sdk_android_. The other platform's key is refused.
1108
+ * const sdkKey = Platform.select({
1109
+ * ios: 'jry_sdk_ios_abc123',
1110
+ * android: 'jry_sdk_android_def456',
1111
+ * })!;
1112
+ * Joryio.initialize(sdkKey, 'api-eu1.joryio.com', {
1113
+ * enableDebug: __DEV__,
1114
+ * trackSessionStart: true,
1115
+ * });
1116
+ * ```
1117
+ */
1118
+ async initialize(
1119
+ sdkKey: string,
1120
+ apiHost: string,
1121
+ config: JoryioConfig = {},
1122
+ ): Promise<void> {
1123
+ if (!isModuleAvailable) {
1124
+ // eslint-disable-next-line no-console
1125
+ console.warn(`${MODULE_MISSING_MESSAGE} initialize() is a no-op.`);
1126
+ return;
1127
+ }
1128
+ // A missing key or host cannot start anything, but it must not switch the
1129
+ // SDK off either: nothing is marked failed, so a later initialize() with
1130
+ // real values still works and the calls held meanwhile are replayed.
1131
+ if (!requireString('initialize', 'sdkKey', sdkKey)) return;
1132
+ if (!requireString('initialize', 'apiHost', apiHost)) return;
1133
+ // Two overlapping calls - React StrictMode runs effects twice - share one
1134
+ // native initialize(). The second used to run its own: the first resolved
1135
+ // and set `_initialized`, the second rejected and set `_initFailed`, and
1136
+ // `_dispatch` then dropped every call of a working SDK.
1137
+ if (this._initInFlight) return this._initInFlight;
1138
+ this._initInFlight = this._initialize(sdkKey, apiHost, config);
1139
+ try {
1140
+ await this._initInFlight;
1141
+ } finally {
1142
+ this._initInFlight = null;
1143
+ }
1144
+ }
1145
+
1146
+ private async _initialize(sdkKey: string, apiHost: string, config: JoryioConfig): Promise<void> {
1147
+ if (this._initFailed) {
1148
+ // A retry after a failed initialize(). The failure used to be permanent
1149
+ // for the life of the JS context: `_initFailed` was never cleared, so
1150
+ // even a successful second initialize() left every call dropped.
1151
+ // Readiness is re-armed, so calls made from here on are held and
1152
+ // replayed again instead of being discarded.
1153
+ this._initFailed = false;
1154
+ this._ready = new Promise<void>((resolve) => {
1155
+ this._markReady = resolve;
1156
+ });
1157
+ }
1158
+ let failure: { error: unknown } | null = null;
1159
+ const forNative = nativeConfig(config);
1160
+ if (!this._initialized) {
1161
+ // Calls made while this init is in flight are judged with its config
1162
+ // (contract v9 91): native applies it before replaying them.
1163
+ if (forNative.trackingConsent === 'granted') this._initConfigDenied = false;
1164
+ if (forNative.trackingConsent === 'denied') this._initConfigDenied = true;
1165
+ if (forNative.optOut === true) this._initConfigOptedOut = true;
1166
+ }
1167
+ try {
1168
+ await JoryioModule.initialize(sdkKey, apiHost, forNative);
1169
+ } catch (e) {
1170
+ failure = { error: e };
1171
+ }
1172
+ try {
1173
+ // The held data calls are judged against the decisions stored by an
1174
+ // earlier launch before anything is replayed or sent straight through
1175
+ // (contract v8 83). Native was asked for them first thing, ahead of
1176
+ // this initialize(), so they are normally known by now; the read never
1177
+ // rejects and is bounded.
1178
+ await this._storedKnown;
1179
+ this._initConfigOptedOut = null;
1180
+ this._initConfigDenied = null;
1181
+ if (failure === null) {
1182
+ this._pending.forEach((call) => delete call.madeInWindow);
1183
+ this._initialized = true;
1184
+ this._wipeHandedToNative = false;
1185
+ return;
1186
+ }
1187
+ if (this._initialized) {
1188
+ // A later initialize() on an SDK that is already running. Native
1189
+ // keeps the running instance, so this is not a failure of the SDK.
1190
+ // eslint-disable-next-line no-console
1191
+ console.warn('[Joryio] initialize() was called again and ignored; the SDK keeps running.', failure.error);
1192
+ return;
1193
+ }
1194
+ // Record the failure rather than rethrowing into the host app's startup.
1195
+ // Everything downstream then degrades to a no-op with one clear warning,
1196
+ // instead of each call throwing its own "SDK not initialized".
1197
+ this._initFailed = true;
1198
+ this._rejudgeWithoutInitConfig();
1199
+ this._handHeldDecisionsToNative();
1200
+ // eslint-disable-next-line no-console
1201
+ console.warn(
1202
+ '[Joryio] initialize() failed; calls are held (at most ' +
1203
+ `${MAX_PRE_INIT_CALLS}) and sent once a later initialize() succeeds. ` +
1204
+ 'Calling it again with corrected values is allowed.',
1205
+ failure.error,
1206
+ );
1207
+ } finally {
1208
+ // Settle READINESS even when native init THREW. Leaving it unresolved
1209
+ // would hang every deferred call and every getter for the life of the
1210
+ // process — a failed init must degrade, not deadlock. `_initFailed` is
1211
+ // what makes that degradation quiet: fire-and-forget calls stay held
1212
+ // for the next initialize() and getters return defaults, rather than
1213
+ // each throwing its own red box.
1214
+ this._markReady();
1215
+ this._drainPending();
1216
+ }
1217
+ }
1218
+
1219
+ // ─── Event Tracking ──────────────────────────────────────────────────────
1220
+
1221
+ /**
1222
+ * Track a custom event.
1223
+ *
1224
+ * @example
1225
+ * ```ts
1226
+ * Joryio.track('Tutorial Completed', { step: 4 });
1227
+ *
1228
+ * // For e-commerce, prefer the typed surface - it applies the platform's
1229
+ * // canonical event names and property keys for you. A hand-typed name that
1230
+ * // is close but not exact is stored verbatim and matches no report, because
1231
+ * // there is deliberately no alias layer:
1232
+ * Joryio.ecommerce.addToCart({ productId: '123', name: 'Buds', price: 29.99, quantity: 1 });
1233
+ * ```
1234
+ */
1235
+ track(eventName: string, properties: Record<string, unknown> = {}): void {
1236
+ if (!requireString('track', 'eventName', eventName)) return;
1237
+ const props = finiteProperties('track', plainObject(properties));
1238
+ this._dispatch(() => JoryioModule.track(eventName, props), 'data');
1239
+ }
1240
+
1241
+ /**
1242
+ * Track a screen view.
1243
+ *
1244
+ * @example
1245
+ * ```ts
1246
+ * Joryio.trackScreen('ProductDetail', { productId: '123' });
1247
+ * ```
1248
+ */
1249
+ trackScreen(
1250
+ screenName: string,
1251
+ properties: Record<string, unknown> = {},
1252
+ ): void {
1253
+ if (!requireString('trackScreen', 'screenName', screenName)) return;
1254
+ const props = finiteProperties('trackScreen', plainObject(properties));
1255
+ this._dispatch(() => JoryioModule.trackScreen(screenName, props), 'data');
1256
+ }
1257
+
1258
+ // ─── User Identity ───────────────────────────────────────────────────────
1259
+
1260
+ /**
1261
+ * Identify the current user by their user ID.
1262
+ * Call this after login or when you know who the user is.
1263
+ *
1264
+ * @example
1265
+ * ```ts
1266
+ * Joryio.identify('user-123');
1267
+ * ```
1268
+ */
1269
+ identify(userId: string): void {
1270
+ if (!requireString('identify', 'userId', userId)) return;
1271
+ this._dispatch(() => JoryioModule.identify(userId), 'identity');
1272
+ }
1273
+
1274
+ /**
1275
+ * Attach a labelled alias to the current person - an id from another system,
1276
+ * such as `addAlias('crm_id', 'C-1042')`. Works for anonymous visitors as
1277
+ * well as identified users: the alias is stored against the anonymous id and
1278
+ * travels with it when the visitor is identified later.
1279
+ *
1280
+ * There is no unlabelled `alias(userId)`; to name the current person, call
1281
+ * {@link identify}.
1282
+ *
1283
+ * @param aliasLabel what kind of id this is, e.g. `crm_id`
1284
+ * @param aliasName the id itself
1285
+ */
1286
+ addAlias(aliasLabel: string, aliasName: string): void {
1287
+ if (!requireString('addAlias', 'aliasLabel', aliasLabel)) return;
1288
+ if (!requireString('addAlias', 'aliasName', aliasName)) return;
1289
+ this._dispatch(() => JoryioModule.addAlias(aliasLabel, aliasName), 'data');
1290
+ }
1291
+
1292
+ /**
1293
+ * Reset the user identity. Call this on logout.
1294
+ * Clears user ID, starts new anonymous session, clears in-app messages.
1295
+ */
1296
+ reset(): void {
1297
+ this._dispatch(() => JoryioModule.reset(), 'control', 'reset');
1298
+ }
1299
+
1300
+ // ─── Tracking consent ────────────────────────────────────────────────────
1301
+
1302
+ /**
1303
+ * Stop collecting and sending. Call when a user declines tracking.
1304
+ *
1305
+ * TRACKING consent, not marketing subscription - this does not unsubscribe
1306
+ * anyone from email or SMS. Those live on the contact's subscription state
1307
+ * and are changed through the API or the preference centre.
1308
+ *
1309
+ * Existed on web, Android, iOS and Unity; React Native had no consent surface
1310
+ * at all, so an app built on it could not honour a "do not track" choice
1311
+ * through the SDK. The native SDKs record `tracking_opted_out` on the profile
1312
+ * as part of opting out, so that behaviour comes through this bridge
1313
+ * unchanged - nothing about the wire is decided here.
1314
+ *
1315
+ * Stored on the device: it holds across restarts until {@link optIn}.
1316
+ * Independent of tracking consent - `updateConsent('granted')` does not
1317
+ * cancel it.
1318
+ *
1319
+ * Safe before initialize(), and applied FIRST: the SDK then starts opted out,
1320
+ * with no Session Start, in-app sync or push registration in between. Calls
1321
+ * that collect data made before it, or after it until {@link optIn}, are
1322
+ * dropped: a later optIn() does not send them.
1323
+ */
1324
+ optOut(): void {
1325
+ this._preInitOptedOut = true;
1326
+ this._decide(() => JoryioModule.optOut(), DISCARDED_BY_WITHDRAWN_CONSENT);
1327
+ }
1328
+
1329
+ /**
1330
+ * Resume collecting and sending after {@link optOut}. The only call that
1331
+ * lifts an opt-out. It does not change tracking consent: with consent
1332
+ * `'denied'` or `'pending'` nothing is sent until consent is granted too.
1333
+ * Safe before initialize(), and applied before the SDK starts. Calls that
1334
+ * collect data made before it while an opt-out was in force - one made in
1335
+ * this launch or one stored by an earlier launch - are not sent by it.
1336
+ */
1337
+ optIn(): void {
1338
+ this._preInitOptedOut = false;
1339
+ this._decide(() => JoryioModule.optIn(), []);
1340
+ }
1341
+
1342
+ /**
1343
+ * Erase everything this device holds for the current person - identity,
1344
+ * queued events, pending writes, in-app state, session, device id and push
1345
+ * token - and unregister the token on the server. The opt-out is KEPT, so a
1346
+ * person who opted out and asked for erasure stays opted out. Push stays
1347
+ * unregistered until {@link registerPushToken} or a permission grant.
1348
+ *
1349
+ * Safe before initialize(), and applied before the SDK starts: nothing is
1350
+ * sent under the erased identity first. Calls made before it that collect
1351
+ * data are dropped, since the wipe would erase them anyway.
1352
+ */
1353
+ wipeData(): void {
1354
+ this._decide(() => JoryioModule.wipeData(), DISCARDED_BY_WIPE);
1355
+ }
1356
+
1357
+ /**
1358
+ * Whether the user has opted out of tracking - the opt-out switch only
1359
+ * ({@link optOut} / {@link optIn}). Consent is a separate switch; read it
1360
+ * with {@link getTrackingConsent}.
1361
+ *
1362
+ * Safe before initialize(): the answer then comes from what is stored on the
1363
+ * device, so an opt-out from an earlier launch reads as `true` straight
1364
+ * away. While initialize() is running it waits for it, and then asks native
1365
+ * whether that initialize() succeeded or failed. Answers `false` when the
1366
+ * native module is not linked or the native read fails.
1367
+ */
1368
+ isUserOptedOut(): Promise<boolean> {
1369
+ return this._queryDecision('isUserOptedOut', false, () => {
1370
+ return JoryioModule.isUserOptedOut();
1371
+ });
1372
+ }
1373
+
1374
+ /**
1375
+ * Change tracking consent - typically when the user answers your consent
1376
+ * banner, for example after you initialized with `trackingConsent: 'pending'`.
1377
+ *
1378
+ * - `'granted'` releases everything held while consent was pending and
1379
+ * starts normal collection. It lifts a stored `'denied'`, but never an
1380
+ * opt-out: only {@link optIn} does that.
1381
+ * - `'denied'` discards what was held and collects nothing more - calls
1382
+ * made while denied are dropped, and a later `'granted'` does not send
1383
+ * them. Stored, so it holds on later launches until `'granted'`.
1384
+ * - `'pending'` holds data on the device and sends nothing, for this launch
1385
+ * only. It never replaces a stored `'denied'`.
1386
+ *
1387
+ * It does not opt the user out and {@link isUserOptedOut} does not change.
1388
+ * Safe before initialize(), and applied before the SDK starts.
1389
+ */
1390
+ updateConsent(consent: TrackingConsent): void {
1391
+ const parsed = parseConsent(consent);
1392
+ if (parsed === null) {
1393
+ warn(`updateConsent() takes 'granted', 'denied' or 'pending', not '${String(consent)}'; ignored.`);
1394
+ return;
1395
+ }
1396
+ if (parsed === 'denied') this._preInitDenied = true;
1397
+ if (parsed === 'granted') this._preInitDenied = false;
1398
+ const discards = parsed === 'denied' ? DISCARDED_BY_WITHDRAWN_CONSENT : [];
1399
+ this._decide(() => JoryioModule.updateConsent(parsed), discards);
1400
+ }
1401
+
1402
+ /**
1403
+ * The current tracking consent: `'granted'`, `'denied'` or `'pending'`.
1404
+ * Separate from the opt-out, which {@link isUserOptedOut} reports.
1405
+ *
1406
+ * Safe before initialize(): the answer then comes from what is stored on the
1407
+ * device (a stored `'denied'` reads as `'denied'`) and from any decision
1408
+ * made earlier in this launch; with nothing decided and nothing stored it
1409
+ * is `'granted'`, the state the SDK runs in, on both platforms. While
1410
+ * initialize() is running it waits for it, and then asks native whether
1411
+ * that initialize() succeeded or failed. Native's answer is passed through
1412
+ * as it is. Answers `null` only when the native module is not linked or the
1413
+ * native call fails.
1414
+ */
1415
+ getTrackingConsent(): Promise<TrackingConsent | null> {
1416
+ return this._queryDecision<TrackingConsent | null>('getTrackingConsent', null, async () => {
1417
+ const raw = await JoryioModule.getTrackingConsent?.();
1418
+ return parseConsent(raw);
1419
+ });
1420
+ }
1421
+
1422
+ // ─── Marketing subscription ──────────────────────────────────────────────
1423
+
1424
+ /**
1425
+ * Set the marketing subscription status for one channel.
1426
+ *
1427
+ * A DIFFERENT consent from {@link optOut}: that controls whether we may
1428
+ * OBSERVE this person, this controls whether we may MESSAGE them. Neither
1429
+ * implies the other - somebody can decline tracking and still want the
1430
+ * newsletter.
1431
+ *
1432
+ * This is what an in-app preference centre writes. It existed on web only,
1433
+ * so a mobile user had to find an email and click its unsubscribe link -
1434
+ * unsubscribing being harder in the app than on the web, which is the wrong
1435
+ * way round for the one action that must always be easy.
1436
+ *
1437
+ * @param channel one of email, sms, whatsapp, push, viber
1438
+ * @param status one of optedIn, subscribed, unsubscribed
1439
+ */
1440
+ setSubscription(channel: SubscriptionChannel, status: SubscriptionStatus): void {
1441
+ if (!requireString('setSubscription', 'channel', channel)) return;
1442
+ if (!requireString('setSubscription', 'status', status)) return;
1443
+ // An unsubscribe is a withdrawal: a pre-init optOut, denial or wipe never
1444
+ // discards it (contract v4 36).
1445
+ const kind = status === 'unsubscribed' ? 'withdrawal' : 'data';
1446
+ this._dispatch(() => JoryioModule.setSubscription(channel, status), kind);
1447
+ }
1448
+
1449
+ /** Join a subscription group (list) on a channel. */
1450
+ addToSubscriptionGroup(groupId: string, channel: SubscriptionChannel): void {
1451
+ if (!requireString('addToSubscriptionGroup', 'groupId', groupId)) return;
1452
+ if (!requireString('addToSubscriptionGroup', 'channel', channel)) return;
1453
+ this._dispatch(() => JoryioModule.addToSubscriptionGroup(groupId, channel), 'data');
1454
+ }
1455
+
1456
+ /** Leave a subscription group (list) on a channel. */
1457
+ removeFromSubscriptionGroup(groupId: string, channel: SubscriptionChannel): void {
1458
+ if (!requireString('removeFromSubscriptionGroup', 'groupId', groupId)) return;
1459
+ if (!requireString('removeFromSubscriptionGroup', 'channel', channel)) return;
1460
+ this._dispatch(() => JoryioModule.removeFromSubscriptionGroup(groupId, channel), 'withdrawal');
1461
+ }
1462
+
1463
+ // ─── User Attributes ─────────────────────────────────────────────────────
1464
+
1465
+ /**
1466
+ * Set multiple user attributes at once.
1467
+ *
1468
+ * @example
1469
+ * ```ts
1470
+ * Joryio.setAttributes({
1471
+ * firstName: 'John',
1472
+ * plan: 'premium',
1473
+ * age: 28,
1474
+ * });
1475
+ * ```
1476
+ */
1477
+ setAttributes(attributes: UserAttributes): void {
1478
+ // `null` means UNSET, on both platforms. Split here so the two bridges
1479
+ // receive the same thing; they also treat a null as an unset themselves.
1480
+ // Android used to crash the app on a null value (a NullPointerException
1481
+ // casting it to Any), and iOS forwarded it as NSNull.
1482
+ if (attributes === null || typeof attributes !== 'object' || Array.isArray(attributes)) {
1483
+ warn('setAttributes() needs an object of attributes; ignored.');
1484
+ return;
1485
+ }
1486
+ const values: Record<string, string | number | boolean> = {};
1487
+ const unset: string[] = [];
1488
+ const nonFinite: string[] = [];
1489
+ for (const [key, value] of Object.entries(attributes)) {
1490
+ if (value === null || value === undefined) unset.push(key);
1491
+ else if (isNonFiniteNumber(value)) nonFinite.push(key);
1492
+ else values[key] = value;
1493
+ }
1494
+ // Left out, not unset: a NaN is a mistake, not a request to erase.
1495
+ if (nonFinite.length > 0) {
1496
+ warn(`setAttributes() left out ${nonFinite.join(', ')}: NaN and Infinity are not valid values.`);
1497
+ }
1498
+ if (Object.keys(values).length > 0) {
1499
+ this._dispatch(() => JoryioModule.setAttributes(values), 'data');
1500
+ }
1501
+ unset.forEach((key) => this._dispatch(() => JoryioModule.unsetAttribute(key), 'data'));
1502
+ }
1503
+
1504
+ /**
1505
+ * Set a single user attribute. `null` unsets it.
1506
+ */
1507
+ setAttribute(key: string, value: string | number | boolean | null): void {
1508
+ if (!requireString('setAttribute', 'key', key)) return;
1509
+ if (value === null || value === undefined) {
1510
+ this._dispatch(() => JoryioModule.unsetAttribute(key), 'data');
1511
+ return;
1512
+ }
1513
+ if (isNonFiniteNumber(value)) {
1514
+ // Over the bridge a NaN arrives as null - an UNSET of the stored value.
1515
+ warn(`setAttribute('${key}') needs a finite number, not ${String(value)}; ignored.`);
1516
+ return;
1517
+ }
1518
+ this._dispatch(() => JoryioModule.setAttribute(key, value), 'data');
1519
+ }
1520
+
1521
+ /**
1522
+ * Increment a numeric user attribute.
1523
+ *
1524
+ * @example
1525
+ * ```ts
1526
+ * Joryio.incrementAttribute('loginCount', 1);
1527
+ * ```
1528
+ */
1529
+ incrementAttribute(key: string, by: number = 1): void {
1530
+ if (!requireString('incrementAttribute', 'key', key)) return;
1531
+ // null means the default step, like an omitted argument.
1532
+ const step = by === null || by === undefined ? 1 : by;
1533
+ if (typeof step !== 'number' || !Number.isFinite(step)) {
1534
+ warn(`incrementAttribute('${key}') needs a finite number to add; ignored.`);
1535
+ return;
1536
+ }
1537
+ this._dispatch(() => JoryioModule.incrementAttribute(key, step), 'data');
1538
+ }
1539
+
1540
+ /**
1541
+ * Remove a user attribute.
1542
+ */
1543
+ unsetAttribute(key: string): void {
1544
+ if (!requireString('unsetAttribute', 'key', key)) return;
1545
+ this._dispatch(() => JoryioModule.unsetAttribute(key), 'data');
1546
+ }
1547
+
1548
+ // ─── Push Notifications ──────────────────────────────────────────────────
1549
+
1550
+ /**
1551
+ * Register a push notification token (FCM on Android, APNs on iOS).
1552
+ * Typically called after receiving the token from Firebase or Apple.
1553
+ *
1554
+ * Resolves with the registration outcome - it never rejects, so a caller
1555
+ * that ignores the promise gets exactly the previous fire-and-forget
1556
+ * behaviour. Before 1.3.0 the bridge dropped the native result: a
1557
+ * device-side DNS blip left the device unregistered for push with no
1558
+ * signal and nothing to retry (Teamder, 2026-09-29). `isRetryable` says
1559
+ * whether trying again is worth it; `isUnauthorized` says the SDK key is
1560
+ * wrong and a retry will not help.
1561
+ *
1562
+ * On iOS register the APNs device token, never the FCM one: Joryio sends
1563
+ * iOS push through APNs. A token that is not an APNs token (hex) is refused
1564
+ * on iOS with `isRetryable: false`; registered, it would replace the
1565
+ * device's working APNs token with one APNs rejects.
1566
+ *
1567
+ * @example
1568
+ * ```ts
1569
+ * // With @react-native-firebase/messaging
1570
+ * const token = Platform.OS === 'ios'
1571
+ * ? await messaging().getAPNSToken() // APNs device token (hex)
1572
+ * : await messaging().getToken(); // FCM token
1573
+ * if (token) {
1574
+ * const result = await Joryio.registerPushToken(token);
1575
+ * if (!result.success && result.isRetryable) {
1576
+ * // transient (offline, DNS, 5xx): register again when the network is back
1577
+ * }
1578
+ * }
1579
+ *
1580
+ * // onTokenRefresh delivers the FCM token - on iOS too, at every app start.
1581
+ * // Register it on Android only.
1582
+ * messaging().onTokenRefresh((fcmToken) => {
1583
+ * if (Platform.OS === 'android') Joryio.registerPushToken(fcmToken);
1584
+ * });
1585
+ * ```
1586
+ */
1587
+ async registerPushToken(token: string): Promise<PushRegistrationResult> {
1588
+ if (!isModuleAvailable) {
1589
+ return {success: false, message: 'Joryio native module is not linked', isUnauthorized: false, isRetryable: false};
1590
+ }
1591
+ // Trimmed first, like both native SDKs (contract v6 69): a token read
1592
+ // with a trailing newline was refused on iOS as "not an APNs token" here
1593
+ // while native would have trimmed and registered it.
1594
+ token = typeof token === 'string' ? token.trim() : token;
1595
+ if (!requireString('registerPushToken', 'token', token)) {
1596
+ return {success: false, message: 'A push token is required', isUnauthorized: false, isRetryable: false};
1597
+ }
1598
+ if (Platform?.OS === 'ios' && !APNS_TOKEN.test(token)) {
1599
+ const message =
1600
+ 'On iOS registerPushToken() needs the APNs device token (hex), from ' +
1601
+ 'messaging().getAPNSToken() - this looks like an FCM token. Not registered.';
1602
+ warn(message);
1603
+ return {success: false, message, isUnauthorized: false, isRetryable: false};
1604
+ }
1605
+ if (!(await this._awaitReady('registerPushToken'))) {
1606
+ // The token was not sent. Retryable only when initialize() is merely
1607
+ // late - calling again after it resolves works. After a FAILED
1608
+ // initialize() a plain retry cannot work: the token has to be registered
1609
+ // again once a later initialize() succeeds, so `isRetryable` is false
1610
+ // (contract item 48) - retrying in a backoff loop would spin.
1611
+ return this._initFailed
1612
+ ? {success: false, message: 'SDK initialization failed', isUnauthorized: false, isRetryable: false}
1613
+ : {success: false, message: 'SDK not initialized', isUnauthorized: false, isRetryable: true};
1614
+ }
1615
+ try {
1616
+ const r = await JoryioModule.registerPushToken(token);
1617
+ return {
1618
+ success: !!r?.success,
1619
+ message: r?.message ?? undefined,
1620
+ isUnauthorized: !!r?.isUnauthorized,
1621
+ isRetryable: !!r?.isRetryable,
1622
+ };
1623
+ } catch (e) {
1624
+ // eslint-disable-next-line no-console
1625
+ console.warn('[Joryio] native call failed', e);
1626
+ return {success: false, message: String((e as Error)?.message ?? e), isUnauthorized: false, isRetryable: true};
1627
+ }
1628
+ }
1629
+
1630
+ /**
1631
+ * Report that a push ARRIVED on this device.
1632
+ *
1633
+ * Needed because most React Native apps run their OWN messaging service -
1634
+ * @react-native-firebase/messaging, Expo notifications - so a push never
1635
+ * passes through our native service and would report no delivery at all.
1636
+ * The same apps already call `registerPushToken`; this is the matching half.
1637
+ *
1638
+ * ```ts
1639
+ * messaging().onMessage(async (msg) => {
1640
+ * if (msg.data?.trackingId) Joryio.reportPushDelivered(msg.data.trackingId);
1641
+ * });
1642
+ * messaging().setBackgroundMessageHandler(async (msg) => {
1643
+ * if (msg.data?.trackingId) Joryio.reportPushDelivered(msg.data.trackingId);
1644
+ * });
1645
+ * ```
1646
+ *
1647
+ * @param trackingId the `trackingId` from the message's data payload.
1648
+ */
1649
+ reportPushDelivered(trackingId: string): void {
1650
+ if (!requireString('reportPushDelivered', 'trackingId', trackingId)) return;
1651
+ this._dispatch(() => JoryioModule.reportPushDelivered(trackingId), 'data');
1652
+ }
1653
+
1654
+ /**
1655
+ * Unregister from push notifications.
1656
+ */
1657
+ unregisterPush(): void {
1658
+ // A withdrawal (contract v4 36): held before initialize() through an
1659
+ // optOut, a denial or a wipe, and the last thing a full hold drops.
1660
+ this._dispatch(() => JoryioModule.unregisterPush(), 'withdrawal');
1661
+ }
1662
+
1663
+ /**
1664
+ * Where the user stands on notifications, without asking them anything.
1665
+ *
1666
+ * Check this BEFORE showing a primer: `notDetermined` is the ONLY state in
1667
+ * which the system prompt can still appear. Push permission is one-shot on
1668
+ * iOS and Android 13+ - a denial is recoverable only in system Settings - so a
1669
+ * primer shown to an already-denied user spends their goodwill on a prompt
1670
+ * that can never be displayed.
1671
+ */
1672
+ async getPushPermissionStatus(): Promise<PushPermissionStatus> {
1673
+ return this._query<PushPermissionStatus>('getPushPermissionStatus', 'unavailable', () => {
1674
+ return JoryioModule.getPushPermissionStatus();
1675
+ });
1676
+ }
1677
+
1678
+ /**
1679
+ * Show the system notification prompt, resolving the resulting status.
1680
+ *
1681
+ * The SDK never calls this for you. The prompt can only be shown once, and
1682
+ * neither `initialize()` nor a dashboard toggle can know when your app has
1683
+ * earned the right to ask - so the moment is yours. Ask after a booking, a
1684
+ * purchase, or from a notification settings screen; not on first launch.
1685
+ *
1686
+ * ```ts
1687
+ * if ((await Joryio.getPushPermissionStatus()) === 'notDetermined') {
1688
+ * // show your own primer first, then:
1689
+ * const status = await Joryio.requestPushPermission();
1690
+ * }
1691
+ * ```
1692
+ *
1693
+ * On Android this covers the API 33+ POST_NOTIFICATIONS runtime permission and
1694
+ * registers the FCM token on a grant. Below API 33 nothing is shown and the
1695
+ * current Settings state is reported.
1696
+ *
1697
+ * The same on both platforms when the user has opted out of tracking: the
1698
+ * prompt is still shown (whether the app may NOTIFY is the user's choice in
1699
+ * the OS, not a tracking decision) and the resulting status is resolved, but
1700
+ * nothing is registered or reported to Joryio while opted out. iOS used to
1701
+ * skip the prompt and answer `notDetermined`, so a primer asked forever.
1702
+ *
1703
+ * Always settles. If Android recreates the Activity while the prompt is up
1704
+ * (a rotation, a theme change), the answer is lost with it; the promise then
1705
+ * resolves with the status as it stands once the app is back in front.
1706
+ */
1707
+ async requestPushPermission(): Promise<PushPermissionStatus> {
1708
+ return this._query<PushPermissionStatus>('requestPushPermission', 'unavailable', () => {
1709
+ return JoryioModule.requestPushPermission();
1710
+ });
1711
+ }
1712
+
1713
+ /**
1714
+ * Check if push notifications are enabled.
1715
+ */
1716
+ async isPushEnabled(): Promise<boolean> {
1717
+ return this._query('isPushEnabled', false, () => {
1718
+ return JoryioModule.isPushEnabled();
1719
+ });
1720
+ }
1721
+
1722
+ /**
1723
+ * Track a push notification click (for analytics).
1724
+ * Call this when the user taps a push notification.
1725
+ */
1726
+ trackPushClick(trackingId: string): void {
1727
+ if (!requireString('trackPushClick', 'trackingId', trackingId)) return;
1728
+ this._dispatch(() => JoryioModule.trackPushClick(trackingId), 'data');
1729
+ }
1730
+
1731
+ // ─── In-App Messaging ────────────────────────────────────────────────────
1732
+
1733
+ /**
1734
+ * Listen for in-app messages that are ready to display.
1735
+ * Returns an unsubscribe function.
1736
+ *
1737
+ * Subscribing OVERRIDES the SDK's own rendering: you draw the message, and
1738
+ * therefore YOU MUST REPORT what happened via {@link trackInAppImpression}.
1739
+ * The SDK cannot see a message it did not draw. Handing a message to you
1740
+ * neither emits `in_app.displayed` nor spends the campaign's frequency
1741
+ * budget: both happen when you report `'displayed'`, once, as the message
1742
+ * appears - the same rule on iOS and Android. Report it exactly once per
1743
+ * message you show; a message you never report counts toward no frequency
1744
+ * cap and reads as delivered to nobody, which also breaks revenue
1745
+ * attribution, which joins on that event.
1746
+ *
1747
+ * Declare what YOUR renderer can draw via `options.capabilities`. Campaigns
1748
+ * are targeted on it, and an undeclared capability is read as "cannot" rather
1749
+ * than "unknown" - so an app with `react-native-webview` should include
1750
+ * `content.html`, and one without it should not. Omitting the option keeps the
1751
+ * previous behaviour, where the value came from a native config flag
1752
+ * describing the native SDK's own views, which never run here.
1753
+ *
1754
+ * @example
1755
+ * ```ts
1756
+ * const unsubscribe = Joryio.onInAppMessage((message) => {
1757
+ * showMyOwnUI(message);
1758
+ * Joryio.trackInAppImpression(message.id, 'displayed');
1759
+ * // ... 'clicked' (with the button's id) when a button is tapped, and
1760
+ * // 'dismissed' whenever the message leaves the screen - after a click too.
1761
+ * });
1762
+ *
1763
+ * // Later:
1764
+ * unsubscribe();
1765
+ * ```
1766
+ *
1767
+ * A message you reported `'displayed'` holds the display slot until you
1768
+ * report `'dismissed'`: until then no other in-app message is shown on iOS,
1769
+ * and the same campaign is not shown again on Android. If `'dismissed'`
1770
+ * never comes, the SDK frees the slot after 5 minutes. So report it whenever
1771
+ * the message leaves the screen, including after a click.
1772
+ *
1773
+ * A callback that throws (or returns a promise that rejects) is caught and
1774
+ * logged; it never crashes the app or stops other listeners.
1775
+ *
1776
+ * Unsubscribing the LAST listener hands rendering back to the native SDK, so
1777
+ * messages that arrive afterwards are drawn natively instead of being handed
1778
+ * to a listener that no longer exists. Before 1.3.0 they were silently lost
1779
+ * on both platforms for the rest of the session.
1780
+ */
1781
+ onInAppMessage(
1782
+ callback: (message: InAppMessage) => unknown,
1783
+ options?: { capabilities?: Array<'content.native' | 'content.html'> },
1784
+ ): () => void {
1785
+ if (typeof callback !== 'function') {
1786
+ warn('onInAppMessage() needs a callback function; ignored.');
1787
+ return () => {};
1788
+ }
1789
+ if (!isModuleAvailable || !emitter) {
1790
+ return () => {};
1791
+ }
1792
+ const subscription = emitter.addListener(
1793
+ 'JoryioInAppMessage',
1794
+ guardedCallback('onInAppMessage', callback),
1795
+ );
1796
+ this._inAppListeners += 1;
1797
+ // Via _dispatch, like every other native call. Called directly, this threw
1798
+ // "Joryio SDK not initialized" and RN surfaced a red box: a host app
1799
+ // naturally subscribes on mount, which is BEFORE initialize() resolves, so
1800
+ // in-app messaging never enabled at all. _dispatch queues the call until the
1801
+ // SDK is ready and then replays it, in order with the matching disable.
1802
+ // Declared capabilities describe YOUR renderer, not the SDK's.
1803
+ const capabilities = normalizeCapabilities(options?.capabilities);
1804
+ this._dispatch(() => JoryioModule.enableInAppMessages(capabilities));
1805
+
1806
+ let removed = false;
1807
+ return () => {
1808
+ // Idempotent: a second call must not decrement someone else's listener.
1809
+ if (removed) return;
1810
+ removed = true;
1811
+ subscription.remove();
1812
+ this._inAppListeners = Math.max(0, this._inAppListeners - 1);
1813
+ if (this._inAppListeners === 0) {
1814
+ this._dispatch(() => JoryioModule.disableInAppMessages());
1815
+ }
1816
+ };
1817
+ }
1818
+
1819
+ /**
1820
+ * Hand in-app messages to JavaScript and say what YOUR renderer can draw
1821
+ * (`content.native`, `content.html`). `onInAppMessage` calls this for you;
1822
+ * call it directly only when you listen for the `JoryioInAppMessage` event
1823
+ * some other way, and pair it with {@link disableInAppMessages}.
1824
+ *
1825
+ * An empty or omitted list means "not declared" on both platforms - the SDK
1826
+ * then describes its own renderer - never "this app can draw nothing".
1827
+ */
1828
+ enableInAppMessages(capabilities?: string[]): void {
1829
+ const declared = normalizeCapabilities(capabilities);
1830
+ this._dispatch(() => JoryioModule.enableInAppMessages(declared));
1831
+ }
1832
+
1833
+ /**
1834
+ * Hand in-app rendering back to the native SDK after
1835
+ * {@link enableInAppMessages}. Removing the last `onInAppMessage` listener
1836
+ * does this for you.
1837
+ */
1838
+ disableInAppMessages(): void {
1839
+ this._dispatch(() => JoryioModule.disableInAppMessages());
1840
+ }
1841
+
1842
+ private _ecommerce?: JoryioEcommerce;
1843
+
1844
+ /**
1845
+ * E-commerce events with the platform's canonical names applied for you:
1846
+ * `Joryio.ecommerce.purchase(order)`.
1847
+ *
1848
+ * A real typed property, not something bolted on after construction. The
1849
+ * first version assigned it with `(Joryio as any).ecommerce = ...`, which
1850
+ * worked at runtime and was invisible to TypeScript - so every call site got
1851
+ * "Property 'ecommerce' does not exist". The demo app found it immediately,
1852
+ * which is what a reference integration is for.
1853
+ */
1854
+ get ecommerce(): JoryioEcommerce {
1855
+ return (this._ecommerce ??= new JoryioEcommerce(this));
1856
+ }
1857
+
1858
+ /**
1859
+ * Report what happened to an in-app message YOUR app rendered.
1860
+ *
1861
+ * @param campaignId the message's `id`
1862
+ * @param action `'displayed'` once when it appears (this is also what
1863
+ * spends the campaign's frequency budget), `'clicked'`
1864
+ * when a button is tapped, and `'dismissed'` whenever it
1865
+ * leaves the screen - after a click too, because the
1866
+ * message holds the display slot until then
1867
+ * @param elementId for a click, the id of the button or link tapped - sent
1868
+ * as `elementId`, the same name on every SDK, so reports can
1869
+ * say which button. Omit it for other actions.
1870
+ */
1871
+ trackInAppImpression(campaignId: string, action: string, elementId?: string): void {
1872
+ if (!requireString('trackInAppImpression', 'campaignId', campaignId)) return;
1873
+ if (!requireString('trackInAppImpression', 'action', action)) return;
1874
+ const element = typeof elementId === 'string' && elementId.length > 0 ? elementId : null;
1875
+ this._dispatch(() => JoryioModule.trackInAppImpression(campaignId, action, element), 'data');
1876
+ }
1877
+
1878
+ /**
1879
+ * Submit a form shown inside an HTML in-app message that your app renders
1880
+ * itself (a `<form data-jry-form>` in the campaign's html). The values are
1881
+ * posted with the SDK's identity; the backend validates them against the
1882
+ * form the campaign carries and writes the person. Resolves with the
1883
+ * server's verdict - `{ ok, message?, redirect?, errors? }` - and never
1884
+ * throws: a failure is `ok: false`.
1885
+ */
1886
+ async submitInAppForm(campaignId: string, values: Record<string, string | string[]>): Promise<InAppFormResult> {
1887
+ const failed: InAppFormResult = { ok: false, message: 'Something went wrong. Please try again.' };
1888
+ if (!requireString('submitInAppForm', 'campaignId', campaignId)) return failed;
1889
+ const fields = plainObject(values);
1890
+ return this._query<InAppFormResult>('submitInAppForm', failed, async () => {
1891
+ const raw = await JoryioModule.submitInAppForm(campaignId, fields);
1892
+ // Android answers a JSON string, iOS an object; both mean the same thing.
1893
+ return typeof raw === 'string' ? (JSON.parse(raw) as InAppFormResult) : raw;
1894
+ });
1895
+ }
1896
+
1897
+ // ─── SDK Authentication ──────────────────────────────────────────────────
1898
+
1899
+ /**
1900
+ * Set/replace the customer-minted JWT used for SDK Authentication.
1901
+ * Attached as the `X-Joryio-Auth` header on every subsequent ingest request.
1902
+ * Call after login, and again from your `onSdkAuthenticationError` handler to
1903
+ * supply a freshly minted token.
1904
+ *
1905
+ * @example
1906
+ * ```ts
1907
+ * const jwt = await mintJoryioJwt(); // your backend mints it
1908
+ * Joryio.setSdkAuthenticationToken(jwt);
1909
+ * ```
1910
+ */
1911
+ setSdkAuthenticationToken(token: string): void {
1912
+ if (!requireString('setSdkAuthenticationToken', 'token', token)) return;
1913
+ const send = () => JoryioModule.setSdkAuthenticationToken(token);
1914
+ // Before initialize() has started, straight to native, which holds a
1915
+ // token set before its own init and applies it ahead of the Session Start
1916
+ // and the in-app sync. Held here until init resolved, it arrived after
1917
+ // both, so a stored identified user's first requests went out with no
1918
+ // token, were refused 401 `missing` and waited on the refresh round-trip.
1919
+ //
1920
+ // Only when no identify() or reset() is held: the token belongs to the
1921
+ // identity those set up, and handed over ahead of them it would be
1922
+ // applied to the previous one (a held reset() replayed after init would
1923
+ // even retire it). Nor after a wipeData() handed to native: native runs
1924
+ // that wipe inside init and would retire the new visitor's token with
1925
+ // the erased person's. A wipe made AFTER it is native's to apply: both
1926
+ // natives keep a token recorded before their pre-init wipe and the wipe
1927
+ // retires it to the erased person's own withdrawals, so it is never
1928
+ // carried over to the next visitor.
1929
+ if (
1930
+ isModuleAvailable &&
1931
+ !this._initialized &&
1932
+ this._initInFlight === null &&
1933
+ !this._wipeHandedToNative &&
1934
+ !this._pending.some((call) => call.kind === 'identity' || call.kind === 'token' || call.tag === 'reset')
1935
+ ) {
1936
+ this._invoke(send);
1937
+ return;
1938
+ }
1939
+ this._dispatch(send, 'token');
1940
+ }
1941
+
1942
+ /**
1943
+ * Listen for SDK-authentication failures (the backend rejected the JWT with a
1944
+ * 401 `sdk_authentication_error`). Mint a fresh JWT in the callback and call
1945
+ * `setSdkAuthenticationToken()`; the refreshed token is carried on the next
1946
+ * flush. Returns an unsubscribe function.
1947
+ *
1948
+ * @example
1949
+ * ```ts
1950
+ * const unsubscribe = Joryio.onSdkAuthenticationError(async ({ reason }) => {
1951
+ * const jwt = await mintJoryioJwt(); // re-mint (esp. on 'expired')
1952
+ * Joryio.setSdkAuthenticationToken(jwt);
1953
+ * });
1954
+ * ```
1955
+ */
1956
+ // Works on BOTH platforms, by different routes: Android exposes an explicit
1957
+ // enable call, iOS registers the handler during initialize(). The asymmetry is
1958
+ // invisible to callers and is handled below.
1959
+ onSdkAuthenticationError(callback: (error: SdkAuthError) => unknown): () => void {
1960
+ if (typeof callback !== 'function') {
1961
+ warn('onSdkAuthenticationError() needs a callback function; ignored.');
1962
+ return () => {};
1963
+ }
1964
+ if (!isModuleAvailable || !emitter) {
1965
+ return () => {};
1966
+ }
1967
+ const subscription = emitter.addListener(
1968
+ 'JoryioSdkAuthError',
1969
+ guardedCallback('onSdkAuthenticationError', callback),
1970
+ );
1971
+ // Straight to native, before initialize() too, and as an optional call:
1972
+ //
1973
+ // - straight away: the Android bridge installs the handler through the
1974
+ // native SDK's pre-init-aware setter, so one set before initialize()
1975
+ // is in place for the first requests (Session Start, in-app sync) and
1976
+ // survives a pre-init wipeData() (contract v8 84). Held here until
1977
+ // init, it arrived after those requests, whose 401s nobody heard.
1978
+ // - `?.()`: the method exists on ANDROID ONLY. iOS registers the same
1979
+ // handler inside initialize() instead, so the callback works there
1980
+ // either way - but calling a method iOS does not export would throw
1981
+ // "undefined is not a function". Same convention as syncInAppCampaigns.
1982
+ //
1983
+ // Safe before init on both platforms (the same shape as the pre-init token
1984
+ // in setSdkAuthenticationToken): Android's bridge calls the companion
1985
+ // setter, iOS has no such method. _invoke catches a native throw.
1986
+ const enable = () => JoryioModule.enableSdkAuthenticationErrors?.();
1987
+ this._invoke(enable);
1988
+ return () => subscription.remove();
1989
+ }
1990
+
1991
+ // ─── Utilities ───────────────────────────────────────────────────────────
1992
+
1993
+ /**
1994
+ * Flush the event queue immediately.
1995
+ * Events are normally batched and sent periodically.
1996
+ */
1997
+ flush(): void {
1998
+ this._dispatch(() => JoryioModule.flush());
1999
+ }
2000
+
2001
+ /**
2002
+ * Fetch in-app campaigns now and display any that are eligible.
2003
+ *
2004
+ * The SDK already syncs on session start and on foreground; use this for the
2005
+ * moments in between - after resetDisplayedCampaigns(), or once the user has
2006
+ * done something that should make them newly eligible.
2007
+ *
2008
+ * Both platforms. It was iOS-only, which was a missing BINDING rather than a
2009
+ * missing capability - the Android facade has exposed `syncInAppCampaigns()`
2010
+ * all along and the module simply never bound it, so this call was
2011
+ * `undefined` on Android and did nothing at all.
2012
+ *
2013
+ * `?.()` is kept for a different reason than before: an app on an older
2014
+ * native module than this JS package would otherwise throw
2015
+ * "undefined is not a function".
2016
+ */
2017
+ syncInAppCampaigns(): void {
2018
+ this._dispatch(() => JoryioModule.syncInAppCampaigns?.());
2019
+ }
2020
+
2021
+ /**
2022
+ * Forget which in-app campaigns have already been shown on this device.
2023
+ *
2024
+ * The SDK skips any campaign it has displayed before, and that set is
2025
+ * persisted - so a message appears once per install and then never again.
2026
+ * Useful when testing: without it you must delete and reinstall the app to
2027
+ * see the same campaign twice.
2028
+ *
2029
+ * Both platforms, but they forget different things, and the difference is
2030
+ * real rather than an oversight:
2031
+ *
2032
+ * iOS clears the persisted show-once-ever set AND the per-campaign
2033
+ * impression history.
2034
+ * Android has no show-once-ever set - a campaign with no frequency cap
2035
+ * repeats there by design - so it clears the impression history
2036
+ * and the cross-campaign gap, which is the state that actually
2037
+ * suppresses a repeat on that platform.
2038
+ *
2039
+ * This was documented as "iOS ONLY - Android has nothing to reset", which was
2040
+ * out of date: Joryio.kt has exposed resetDisplayedCampaigns() throughout and
2041
+ * it does the second thing above. Debug-gated natively, so release builds are
2042
+ * unaffected on both.
2043
+ */
2044
+ resetDisplayedCampaigns(): void {
2045
+ // `?.()` guards an app on an older native module than this JS package, not
2046
+ // a platform gap.
2047
+ this._dispatch(() => JoryioModule.resetDisplayedCampaigns?.());
2048
+ }
2049
+
2050
+ /**
2051
+ * What the SDK is EXPERIENCING: which server it talks to, and the last
2052
+ * transport failure (null once a call succeeds again).
2053
+ *
2054
+ * "Initialized" and "the server accepts us" are different claims. Without
2055
+ * this, a wrong SDK key looks identical to a healthy integration - the SDK
2056
+ * reports itself up while every request is rejected 401.
2057
+ *
2058
+ * Implemented on BOTH platforms. It was iOS-only, which meant the one call you
2059
+ * reach for when an integration misbehaves was absent on Android.
2060
+ *
2061
+ * It is also what the SDK is ACTUALLY doing, as opposed to the config your
2062
+ * app believes it set: `initialize()` ignores a second call, so changing
2063
+ * settings without restarting leaves the SDK on the old values while your UI
2064
+ * shows the new ones.
2065
+ */
2066
+ async getDiagnostics(): Promise<{
2067
+ apiEndpoint: string | null;
2068
+ lastError?: {message: string; isUnauthorized: boolean; isRetryable: boolean; at: number};
2069
+ }> {
2070
+ // Gated like the other getters. "initialize() never completed" IS the
2071
+ // diagnostic in that case, and _awaitReady logs exactly that.
2072
+ return this._query('getDiagnostics', {apiEndpoint: null}, () => {
2073
+ return JoryioModule.getDiagnostics();
2074
+ });
2075
+ }
2076
+
2077
+ /**
2078
+ * Get the anonymous ID assigned to this device.
2079
+ */
2080
+ async getAnonymousId(): Promise<string> {
2081
+ return this._query('getAnonymousId', '', () => {
2082
+ return JoryioModule.getAnonymousId();
2083
+ });
2084
+ }
2085
+
2086
+ /**
2087
+ * Get the current user ID (null if not identified).
2088
+ */
2089
+ async getUserId(): Promise<string | null> {
2090
+ return this._query<string | null>('getUserId', null, () => {
2091
+ return JoryioModule.getUserId();
2092
+ });
2093
+ }
2094
+
2095
+ /**
2096
+ * Get the current session ID.
2097
+ */
2098
+ async getSessionId(): Promise<string> {
2099
+ return this._query('getSessionId', '', () => {
2100
+ return JoryioModule.getSessionId();
2101
+ });
2102
+ }
2103
+ }
2104
+
2105
+ // Export singleton
2106
+ /** A product as the e-commerce tracker expects it. */
2107
+ export interface EcommerceProduct {
2108
+ productId: string;
2109
+ name: string;
2110
+ price: number;
2111
+ quantity?: number;
2112
+ category?: string;
2113
+ brand?: string;
2114
+ variant?: string;
2115
+ variantId?: string;
2116
+ sku?: string;
2117
+ imageUrl?: string;
2118
+ url?: string;
2119
+ }
2120
+
2121
+ /** A cart line. `quantity` is required here; a line without one is not a line. */
2122
+ export interface EcommerceCartItem extends EcommerceProduct {
2123
+ quantity: number;
2124
+ }
2125
+
2126
+ export interface EcommerceOrder {
2127
+ orderId: string;
2128
+ items: EcommerceCartItem[];
2129
+ value: number;
2130
+ currency?: string;
2131
+ shipping?: number;
2132
+ tax?: number;
2133
+ discount?: number;
2134
+ coupon?: string;
2135
+ }
2136
+ /**
2137
+ * E-commerce events, with the platform's canonical names applied for you.
2138
+ *
2139
+ * WHY THIS EXISTS. The web, iOS and Android SDKs have always had this; React
2140
+ * Native did not, so every RN integration hand-typed event names - and there is
2141
+ * deliberately NO alias layer server-side. "Added to Cart" is not a near-miss
2142
+ * for "Product Added", it is a different event: stored verbatim, matching no
2143
+ * e-commerce report, no revenue attribution and no recommendation feature.
2144
+ *
2145
+ * Our own demo shop did exactly that and recorded zero revenue while looking
2146
+ * completely healthy on screen, which is the failure mode this closes: not an
2147
+ * error, a plausible number.
2148
+ *
2149
+ * Every call forwards to the native tracker, which owns the vocabulary. Nothing
2150
+ * here re-spells an event name.
2151
+ */
2152
+ /** The lines of a cart or order without its null entries. */
2153
+ function withoutNullLines(items: unknown[]): unknown[] {
2154
+ return items.filter((item) => item !== null && item !== undefined);
2155
+ }
2156
+
2157
+ class JoryioEcommerce {
2158
+ private readonly sdk: JoryioSDK;
2159
+
2160
+ constructor(sdk: JoryioSDK) {
2161
+ this.sdk = sdk;
2162
+ }
2163
+
2164
+ private send(method: string, payload: Record<string, unknown>): void {
2165
+ // Optional arguments left out rather than sent as null: the bridges read a
2166
+ // present key as a value, and a null where a number was expected is the
2167
+ // native SDK's default, not an error (contract item 19).
2168
+ const clean = withoutNullValues(payload);
2169
+ // A null line is skipped (see lines()), so it must not reach the bridge
2170
+ // either: both bridges turned it into a line with no productId, and both
2171
+ // native trackers then refused the WHOLE call - an order lost, with
2172
+ // nothing in JS to say so.
2173
+ if (Array.isArray(clean.items)) clean.items = withoutNullLines(clean.items);
2174
+ if (isRecord(clean.order) && Array.isArray(clean.order.items)) {
2175
+ clean.order = { ...clean.order, items: withoutNullLines(clean.order.items) };
2176
+ }
2177
+ // An optional count that is NaN or Infinity (a line's quantity, a search's
2178
+ // resultCount) is left out with a warning, so the native default applies
2179
+ // (contract v5 49). Android read NaN as 0 and iOS as absent.
2180
+ const finite = finiteProperties(`ecommerce.${method}`, clean);
2181
+ // Same queue-until-ready path as every other call, so a tracker built in a
2182
+ // module field before initialize() resolves does not silently drop events.
2183
+ (this.sdk as any)._dispatch(() => JoryioModule.ecommerce(method, finite), 'data');
2184
+ }
2185
+
2186
+ // ── Required arguments (contract items 19 and 42) ──────────────────────
2187
+ //
2188
+ // A missing required argument warns and sends NOTHING. Both bridges used to
2189
+ // fill the gap with a default, so `purchase(null)` tracked an Order
2190
+ // Completed with orderId "" and total 0 - not an error anyone would see, a
2191
+ // plausible zero in the revenue report. The native trackers check again.
2192
+ //
2193
+ // ONE set on every Joryio SDK and wrapper (pinned by the backend's
2194
+ // ecommerce-guard-parity spec): a product - and every cart/order line -
2195
+ // needs a productId and a finite price; cart calls a finite cartValue;
2196
+ // purchase an orderId and a finite value (the items may be empty); every
2197
+ // other id or label is required; an optional amount may be left out, but
2198
+ // one given must be finite. An id that is only whitespace counts as missing.
2199
+
2200
+ private product(caller: string, value: unknown, name = 'product'): boolean {
2201
+ if (!isRecord(value) || !isNonBlankString(value.productId)) {
2202
+ warn(`ecommerce.${caller}() needs a ${name} with a productId; ignored.`);
2203
+ return false;
2204
+ }
2205
+ return this.amount(caller, `${name}.price`, value.price);
2206
+ }
2207
+
2208
+ /** Every line (a null entry is skipped, and not sent) a valid product. */
2209
+ private lines(caller: string, items: unknown[]): boolean {
2210
+ return items.every((item) => item == null || this.product(caller, item, 'item'));
2211
+ }
2212
+
2213
+ private text(caller: string, name: string, value: unknown): boolean {
2214
+ if (isNonBlankString(value)) return true;
2215
+ warn(`ecommerce.${caller}() needs a non-empty string ${name}; ignored.`);
2216
+ return false;
2217
+ }
2218
+
2219
+ private amount(caller: string, name: string, value: unknown): boolean {
2220
+ if (typeof value === 'number' && Number.isFinite(value)) return true;
2221
+ warn(`ecommerce.${caller}() needs a number ${name}; ignored.`);
2222
+ return false;
2223
+ }
2224
+
2225
+ /** Absent is fine; present must be finite. */
2226
+ private optionalAmount(caller: string, name: string, value: unknown): boolean {
2227
+ return value === undefined || value === null || this.amount(caller, name, value);
2228
+ }
2229
+
2230
+ private cart(caller: string, items: unknown, cartValue: unknown): boolean {
2231
+ if (!Array.isArray(items)) {
2232
+ warn(`ecommerce.${caller}() needs an array of items; ignored.`);
2233
+ return false;
2234
+ }
2235
+ return this.lines(caller, items) && this.amount(caller, 'cartValue', cartValue);
2236
+ }
2237
+
2238
+ viewProduct(product: EcommerceProduct): void {
2239
+ if (!this.product('viewProduct', product)) return;
2240
+ this.send('viewProduct', {product});
2241
+ }
2242
+ viewCategory(categoryId: string, categoryName: string): void {
2243
+ if (!this.text('viewCategory', 'categoryId', categoryId)) return;
2244
+ if (!this.text('viewCategory', 'categoryName', categoryName)) return;
2245
+ this.send('viewCategory', {categoryId, categoryName});
2246
+ }
2247
+ search(query: string, resultCount?: number): void {
2248
+ if (!this.text('search', 'query', query)) return;
2249
+ this.send('search', {query, resultCount});
2250
+ }
2251
+ addToCart(item: EcommerceCartItem, cartValue?: number): void {
2252
+ if (!this.product('addToCart', item, 'item')) return;
2253
+ if (!this.optionalAmount('addToCart', 'cartValue', cartValue)) return;
2254
+ this.send('addToCart', {item, cartValue});
2255
+ }
2256
+ removeFromCart(product: EcommerceProduct, quantity = 1): void {
2257
+ if (!this.product('removeFromCart', product)) return;
2258
+ this.send('removeFromCart', {product, quantity});
2259
+ }
2260
+ cartViewed(items: EcommerceCartItem[], cartValue: number): void {
2261
+ if (!this.cart('cartViewed', items, cartValue)) return;
2262
+ this.send('cartViewed', {items, cartValue});
2263
+ }
2264
+ updateCart(items: EcommerceCartItem[], cartValue: number): void {
2265
+ if (!this.cart('updateCart', items, cartValue)) return;
2266
+ this.send('updateCart', {items, cartValue});
2267
+ }
2268
+ startCheckout(items: EcommerceCartItem[], cartValue: number): void {
2269
+ if (!this.cart('startCheckout', items, cartValue)) return;
2270
+ this.send('startCheckout', {items, cartValue});
2271
+ }
2272
+ addPaymentInfo(paymentMethod: string): void {
2273
+ if (!this.text('addPaymentInfo', 'paymentMethod', paymentMethod)) return;
2274
+ this.send('addPaymentInfo', {paymentMethod});
2275
+ }
2276
+ /**
2277
+ * An order. `orderId`, `items` (an array, may be empty) and a finite `value`
2278
+ * are required: the value is sent as `total`, the key revenue reports read,
2279
+ * and a purchase missing any of them is ignored with a warning rather than
2280
+ * recorded as a zero or an empty order.
2281
+ */
2282
+ purchase(order: EcommerceOrder): void {
2283
+ if (!isRecord(order) || !isNonBlankString(order.orderId)) {
2284
+ warn('ecommerce.purchase() needs an order with an orderId; ignored.');
2285
+ return;
2286
+ }
2287
+ if (!Array.isArray(order.items)) {
2288
+ warn('ecommerce.purchase() needs an order with an items array; ignored.');
2289
+ return;
2290
+ }
2291
+ if (!this.amount('purchase', 'order.value', order.value)) return;
2292
+ if (!this.lines('purchase', order.items)) return;
2293
+ if (!this.optionalAmount('purchase', 'order.shipping', order.shipping)) return;
2294
+ if (!this.optionalAmount('purchase', 'order.tax', order.tax)) return;
2295
+ if (!this.optionalAmount('purchase', 'order.discount', order.discount)) return;
2296
+ this.send('purchase', {order});
2297
+ }
2298
+ orderFulfilled(orderId: string, trackingNumber?: string, carrier?: string): void {
2299
+ if (!this.text('orderFulfilled', 'orderId', orderId)) return;
2300
+ this.send('orderFulfilled', {orderId, trackingNumber, carrier});
2301
+ }
2302
+ orderDelivered(orderId: string): void {
2303
+ if (!this.text('orderDelivered', 'orderId', orderId)) return;
2304
+ this.send('orderDelivered', {orderId});
2305
+ }
2306
+ orderCancelled(orderId: string, reason?: string): void {
2307
+ if (!this.text('orderCancelled', 'orderId', orderId)) return;
2308
+ this.send('orderCancelled', {orderId, reason});
2309
+ }
2310
+ orderRefunded(orderId: string, refundAmount?: number, reason?: string): void {
2311
+ if (!this.text('orderRefunded', 'orderId', orderId)) return;
2312
+ if (!this.optionalAmount('orderRefunded', 'refundAmount', refundAmount)) return;
2313
+ this.send('orderRefunded', {orderId, refundAmount, reason});
2314
+ }
2315
+ addToWishlist(product: EcommerceProduct): void {
2316
+ if (!this.product('addToWishlist', product)) return;
2317
+ this.send('addToWishlist', {product});
2318
+ }
2319
+ removeFromWishlist(product: EcommerceProduct): void {
2320
+ if (!this.product('removeFromWishlist', product)) return;
2321
+ this.send('removeFromWishlist', {product});
2322
+ }
2323
+ shareProduct(product: EcommerceProduct, shareMethod: string): void {
2324
+ if (!this.product('shareProduct', product)) return;
2325
+ if (!this.text('shareProduct', 'shareMethod', shareMethod)) return;
2326
+ this.send('shareProduct', {product, shareMethod});
2327
+ }
2328
+ applyCoupon(couponCode: string, discountAmount?: number, discountType?: string): void {
2329
+ if (!this.text('applyCoupon', 'couponCode', couponCode)) return;
2330
+ if (!this.optionalAmount('applyCoupon', 'discountAmount', discountAmount)) return;
2331
+ this.send('applyCoupon', {couponCode, discountAmount, discountType});
2332
+ }
2333
+ removeCoupon(couponCode: string): void {
2334
+ if (!this.text('removeCoupon', 'couponCode', couponCode)) return;
2335
+ this.send('removeCoupon', {couponCode});
2336
+ }
2337
+ submitReview(productId: string, rating: number, reviewText?: string): void {
2338
+ if (!this.text('submitReview', 'productId', productId)) return;
2339
+ if (!this.amount('submitReview', 'rating', rating)) return;
2340
+ // Both native trackers take an Int. Swift's Int(Double) TRAPS past
2341
+ // Int.max - a crash of the host app from `Number(userInput)` - and
2342
+ // Kotlin's toInt() quietly saturates, so the platforms disagreed too.
2343
+ if (Math.abs(rating) > MAX_RATING_MAGNITUDE) {
2344
+ warn(`ecommerce.submitReview() needs a rating a whole-number type can hold, not ${rating}; ignored.`);
2345
+ return;
2346
+ }
2347
+ this.send('submitReview', {productId, rating, reviewText});
2348
+ }
2349
+ }
2350
+
2351
+ const Joryio = new JoryioSDK();
2352
+
2353
+ /** Also exported standalone, for `import {ecommerce} from ...`. */
2354
+ export const ecommerce = Joryio.ecommerce;
2355
+ export default Joryio;