@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/CHANGELOG.md +5 -0
- package/LICENSE +54 -0
- package/README.md +385 -2
- package/android/build.gradle +90 -0
- package/android/src/main/java/io/joryio/reactnative/JoryioModule.kt +1188 -0
- package/android/src/main/java/io/joryio/reactnative/JoryioPackage.kt +27 -0
- package/ios/JoryioModule.m +113 -0
- package/ios/JoryioModule.swift +979 -0
- package/joryio-react-native.podspec +36 -0
- package/lib/index.d.ts +1098 -0
- package/lib/index.js +1928 -0
- package/lib/index.js.map +1 -0
- package/package.json +62 -4
- package/react-native.config.js +25 -0
- package/src/index.ts +2355 -0
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 & 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;
|