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