@tolinku/react-native-sdk 0.1.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/dist/index.js ADDED
@@ -0,0 +1,971 @@
1
+ 'use strict';
2
+
3
+ var reactNative = require('react-native');
4
+ var AsyncStorage = require('@react-native-async-storage/async-storage');
5
+ var react = require('react');
6
+ var jsxRuntime = require('react/jsx-runtime');
7
+
8
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
9
+
10
+ var AsyncStorage__default = /*#__PURE__*/_interopDefault(AsyncStorage);
11
+
12
+ // src/types.ts
13
+ var SDK_VERSION = "0.1.0";
14
+
15
+ // src/debug.ts
16
+ var _debugEnabled = false;
17
+ function setDebugEnabled(enabled) {
18
+ _debugEnabled = enabled;
19
+ }
20
+ function debugLog(message) {
21
+ if (_debugEnabled) {
22
+ console.log(`[TolinkuSDK] ${message}`);
23
+ }
24
+ }
25
+ function debugWarn(message) {
26
+ if (_debugEnabled) {
27
+ console.warn(`[TolinkuSDK] ${message}`);
28
+ }
29
+ }
30
+
31
+ // src/client.ts
32
+ var MAX_RETRIES = 3;
33
+ var BASE_DELAY_MS = 500;
34
+ var MAX_JITTER_MS = 250;
35
+ var HttpClient = class {
36
+ constructor(config) {
37
+ /** Set of AbortControllers for in-flight requests. Used by destroy() to cancel all. */
38
+ this.pendingControllers = /* @__PURE__ */ new Set();
39
+ this.destroyed = false;
40
+ this.baseUrl = config.baseUrl.replace(/\/+$/, "");
41
+ this.apiKey = config.apiKey;
42
+ this.timeout = config.timeout;
43
+ }
44
+ async get(path, params) {
45
+ let url = this.baseUrl + path;
46
+ if (params) {
47
+ const qs = new URLSearchParams(params).toString();
48
+ if (qs) url += "?" + qs;
49
+ }
50
+ return this.executeWithRetry(url, {
51
+ method: "GET",
52
+ headers: this.authenticatedHeaders()
53
+ });
54
+ }
55
+ async post(path, body) {
56
+ return this.executeWithRetry(this.baseUrl + path, {
57
+ method: "POST",
58
+ headers: {
59
+ ...this.authenticatedHeaders(),
60
+ "Content-Type": "application/json"
61
+ },
62
+ body: body ? JSON.stringify(body) : void 0
63
+ });
64
+ }
65
+ /** GET without API key auth (for public endpoints like deferred claim) */
66
+ async getPublic(path, params) {
67
+ let url = this.baseUrl + path;
68
+ if (params) {
69
+ const qs = new URLSearchParams(params).toString();
70
+ if (qs) url += "?" + qs;
71
+ }
72
+ return this.executeWithRetry(url, {
73
+ method: "GET",
74
+ headers: this.publicHeaders()
75
+ });
76
+ }
77
+ /** POST without API key auth (for public endpoints like deferred claim) */
78
+ async postPublic(path, body) {
79
+ return this.executeWithRetry(this.baseUrl + path, {
80
+ method: "POST",
81
+ headers: {
82
+ ...this.publicHeaders(),
83
+ "Content-Type": "application/json"
84
+ },
85
+ body: body ? JSON.stringify(body) : void 0
86
+ });
87
+ }
88
+ /**
89
+ * Abort all in-flight requests and mark the client as destroyed.
90
+ * After calling this, all future requests will throw immediately.
91
+ */
92
+ abort() {
93
+ this.destroyed = true;
94
+ for (const controller of this.pendingControllers) {
95
+ controller.abort();
96
+ }
97
+ this.pendingControllers.clear();
98
+ }
99
+ authenticatedHeaders() {
100
+ return {
101
+ "X-API-Key": this.apiKey,
102
+ "Accept": "application/json",
103
+ "User-Agent": `TolinkuReactNativeSDK/${SDK_VERSION}`
104
+ };
105
+ }
106
+ publicHeaders() {
107
+ return {
108
+ "Accept": "application/json",
109
+ "User-Agent": `TolinkuReactNativeSDK/${SDK_VERSION}`
110
+ };
111
+ }
112
+ /**
113
+ * Execute a fetch request with retry logic. Retries on:
114
+ * - Network errors (fetch throws)
115
+ * - HTTP 429 (Too Many Requests), respecting the Retry-After header
116
+ * - HTTP 5xx (server errors)
117
+ *
118
+ * Does NOT retry on 4xx errors (except 429).
119
+ * Uses exponential backoff: BASE_DELAY_MS * 2^attempt + random jitter (0..MAX_JITTER_MS).
120
+ */
121
+ async executeWithRetry(url, init) {
122
+ if (this.destroyed) {
123
+ throw new TolinkuError("Tolinku: client has been destroyed. Call Tolinku.init() to reinitialize.", 0);
124
+ }
125
+ let lastError = null;
126
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
127
+ const controller = new AbortController();
128
+ this.pendingControllers.add(controller);
129
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
130
+ try {
131
+ const res = await fetch(url, {
132
+ ...init,
133
+ signal: controller.signal
134
+ });
135
+ clearTimeout(timeoutId);
136
+ this.pendingControllers.delete(controller);
137
+ if (res.ok) {
138
+ return res.json();
139
+ }
140
+ const errorBody = await res.json().catch(() => ({ error: res.statusText }));
141
+ const errorMessage = errorBody.error || res.statusText;
142
+ const errorCode = errorBody.code;
143
+ const isRetryable = res.status === 429 || res.status >= 500 && res.status < 600;
144
+ if (!isRetryable || attempt === MAX_RETRIES) {
145
+ throw new TolinkuError(errorMessage, res.status, errorCode);
146
+ }
147
+ lastError = new TolinkuError(errorMessage, res.status, errorCode);
148
+ let delayMs;
149
+ if (res.status === 429) {
150
+ const retryAfter = res.headers.get("Retry-After");
151
+ const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : NaN;
152
+ if (!isNaN(retryAfterSeconds) && retryAfterSeconds > 0) {
153
+ delayMs = retryAfterSeconds * 1e3;
154
+ } else {
155
+ delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
156
+ }
157
+ } else {
158
+ delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
159
+ }
160
+ const jitter = Math.random() * MAX_JITTER_MS;
161
+ const totalDelay = delayMs + jitter;
162
+ debugLog(`Retry ${attempt + 1}/${MAX_RETRIES} after ${Math.round(totalDelay)}ms (status=${res.status})`);
163
+ await sleep(totalDelay);
164
+ } catch (err) {
165
+ clearTimeout(timeoutId);
166
+ this.pendingControllers.delete(controller);
167
+ if (this.destroyed) {
168
+ throw new TolinkuError("Tolinku: request aborted (client destroyed).", 0);
169
+ }
170
+ if (err instanceof DOMException && err.name === "AbortError") {
171
+ const timeoutErr = new TolinkuError(`Tolinku: request timed out after ${this.timeout}ms`, 0);
172
+ if (attempt === MAX_RETRIES) throw timeoutErr;
173
+ lastError = timeoutErr;
174
+ } else if (err instanceof TolinkuError) {
175
+ throw err;
176
+ } else {
177
+ if (attempt === MAX_RETRIES) {
178
+ throw new TolinkuError(
179
+ `Tolinku: network error after ${MAX_RETRIES + 1} attempts: ${err.message}`,
180
+ 0
181
+ );
182
+ }
183
+ lastError = err;
184
+ }
185
+ const delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
186
+ const jitter = Math.random() * MAX_JITTER_MS;
187
+ const totalDelay = delayMs + jitter;
188
+ debugLog(`Retry ${attempt + 1}/${MAX_RETRIES} after ${Math.round(totalDelay)}ms (${err.message})`);
189
+ await sleep(totalDelay);
190
+ }
191
+ }
192
+ throw lastError || new TolinkuError("Tolinku: request failed after retries", 0);
193
+ }
194
+ };
195
+ function sleep(ms) {
196
+ return new Promise((resolve) => setTimeout(resolve, ms));
197
+ }
198
+ var TolinkuError = class extends Error {
199
+ constructor(message, status, code) {
200
+ super(message);
201
+ this.name = "TolinkuError";
202
+ this.status = status;
203
+ this.code = code;
204
+ }
205
+ };
206
+
207
+ // src/validation.ts
208
+ function isSafeUrl(url) {
209
+ if (!url || typeof url !== "string") return false;
210
+ const trimmed = url.trim();
211
+ if (!trimmed) return false;
212
+ try {
213
+ const parsed = new URL(trimmed);
214
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+ function validateBaseUrl(baseUrl) {
220
+ if (typeof baseUrl !== "string") {
221
+ throw new Error("Tolinku: baseUrl must be a non-empty string.");
222
+ }
223
+ const trimmed = baseUrl.trim();
224
+ const isLocalDev = trimmed.startsWith("http://localhost") || trimmed.startsWith("http://127.0.0.1") || trimmed.startsWith("http://10.") || /^http:\/\/172\.(1[6-9]|2\d|3[01])\./.test(trimmed) || trimmed.startsWith("http://192.168.");
225
+ if (trimmed.startsWith("https://")) {
226
+ return;
227
+ }
228
+ if (isLocalDev) {
229
+ return;
230
+ }
231
+ throw new Error(
232
+ "Tolinku: baseUrl must use HTTPS to protect your API key. Use https:// instead of http://. Local development URLs (localhost, 127.0.0.1, 10.x, 172.16-31.x, 192.168.x) are exempt from this requirement."
233
+ );
234
+ }
235
+ var EVENT_NAME_REGEX = /^custom\.[a-z0-9_]+$/;
236
+ function validateEventType(eventType) {
237
+ if (!eventType || typeof eventType !== "string" || !eventType.trim()) {
238
+ throw new Error("Tolinku: eventType must be a non-empty string.");
239
+ }
240
+ let normalized = eventType.trim();
241
+ if (!normalized.startsWith("custom.")) {
242
+ normalized = "custom." + normalized;
243
+ }
244
+ if (!EVENT_NAME_REGEX.test(normalized)) {
245
+ throw new Error(
246
+ `Tolinku: invalid event type "${normalized}". Event names must match the pattern "custom.[a-z0-9_]+". Use only lowercase letters, digits, and underscores after the "custom." prefix.`
247
+ );
248
+ }
249
+ return normalized;
250
+ }
251
+
252
+ // src/analytics.ts
253
+ var BATCH_SIZE = 10;
254
+ var FLUSH_INTERVAL_MS = 5e3;
255
+ var MAX_QUEUE_SIZE = 1e3;
256
+ var Analytics = class {
257
+ constructor(client) {
258
+ this.queue = [];
259
+ this.flushTimer = null;
260
+ this.appStateSubscription = null;
261
+ this.isFlushing = false;
262
+ this.handleAppStateChange = (state) => {
263
+ if (state === "background" || state === "inactive") {
264
+ this.flush().catch(() => {
265
+ });
266
+ }
267
+ };
268
+ this.client = client;
269
+ this.appStateSubscription = reactNative.AppState.addEventListener(
270
+ "change",
271
+ this.handleAppStateChange
272
+ );
273
+ }
274
+ /**
275
+ * Track a custom event. The event is added to the internal queue
276
+ * and will be flushed automatically or when flush() is called.
277
+ *
278
+ * Event type must match /^custom\.[a-z0-9_]+$/.
279
+ * Auto-prefixes with "custom." if missing.
280
+ */
281
+ async track(eventType, properties) {
282
+ const normalizedType = validateEventType(eventType);
283
+ const event = {
284
+ event_type: normalizedType,
285
+ properties: properties || {}
286
+ };
287
+ if (this.queue.length >= MAX_QUEUE_SIZE) {
288
+ debugWarn(
289
+ `Analytics queue is full (${MAX_QUEUE_SIZE} events). Dropping oldest event to make room.`
290
+ );
291
+ this.queue.shift();
292
+ }
293
+ this.queue.push(event);
294
+ if (this.queue.length >= BATCH_SIZE) {
295
+ await this.flush();
296
+ } else if (this.queue.length === 1) {
297
+ this.startFlushTimer();
298
+ }
299
+ }
300
+ /**
301
+ * Immediately flush all queued events to the server.
302
+ * If the queue is empty, this is a no-op.
303
+ * If a flush is already in progress, this is a no-op to prevent race conditions.
304
+ */
305
+ async flush() {
306
+ if (this.queue.length === 0 || this.isFlushing) return;
307
+ this.isFlushing = true;
308
+ const eventsToSend = this.queue.splice(0, this.queue.length);
309
+ this.cancelFlushTimer();
310
+ try {
311
+ debugLog(`Flushing ${eventsToSend.length} analytics event(s)`);
312
+ const result = await this.client.post("/v1/api/analytics/batch", {
313
+ events: eventsToSend
314
+ });
315
+ if (result.errors && result.errors.length > 0) {
316
+ debugWarn(`Batch partial failure: ${result.errors.join(", ")}`);
317
+ }
318
+ } catch (err) {
319
+ debugWarn(`Failed to flush analytics events: ${err.message}`);
320
+ const spaceLeft = MAX_QUEUE_SIZE - this.queue.length;
321
+ if (spaceLeft > 0) {
322
+ this.queue.unshift(...eventsToSend.slice(0, spaceLeft));
323
+ }
324
+ } finally {
325
+ this.isFlushing = false;
326
+ }
327
+ }
328
+ /**
329
+ * Shut down the analytics module: flush remaining events, cancel the timer,
330
+ * and remove the AppState listener.
331
+ */
332
+ async destroy() {
333
+ this.cancelFlushTimer();
334
+ this.appStateSubscription?.remove();
335
+ this.appStateSubscription = null;
336
+ try {
337
+ await this.flush();
338
+ } catch {
339
+ }
340
+ }
341
+ startFlushTimer() {
342
+ this.cancelFlushTimer();
343
+ this.flushTimer = setTimeout(() => {
344
+ this.flush().catch((err) => {
345
+ debugWarn(`Timer flush failed: ${err.message}`);
346
+ });
347
+ }, FLUSH_INTERVAL_MS);
348
+ }
349
+ cancelFlushTimer() {
350
+ if (this.flushTimer !== null) {
351
+ clearTimeout(this.flushTimer);
352
+ this.flushTimer = null;
353
+ }
354
+ }
355
+ };
356
+
357
+ // src/referrals.ts
358
+ var Referrals = class {
359
+ constructor(client) {
360
+ this.client = client;
361
+ }
362
+ /** Create a new referral for a user */
363
+ async create(options) {
364
+ if (!options.userId || !options.userId.trim()) {
365
+ throw new Error("Tolinku: userId is required and must not be blank.");
366
+ }
367
+ return this.client.post("/v1/api/referral/create", {
368
+ user_id: options.userId,
369
+ metadata: options.metadata,
370
+ user_name: options.userName
371
+ });
372
+ }
373
+ /** Get referral info by code */
374
+ async get(code) {
375
+ if (!code || !code.trim()) {
376
+ throw new Error("Tolinku: referral code is required and must not be blank.");
377
+ }
378
+ return this.client.get(`/v1/api/referral/${encodeURIComponent(code)}`);
379
+ }
380
+ /** Complete a referral (mark as converted) */
381
+ async complete(options) {
382
+ if (!options.code || !options.code.trim()) {
383
+ throw new Error("Tolinku: referral code is required and must not be blank.");
384
+ }
385
+ if (!options.referredUserId || !options.referredUserId.trim()) {
386
+ throw new Error("Tolinku: referredUserId is required and must not be blank.");
387
+ }
388
+ return this.client.post("/v1/api/referral/complete", {
389
+ referral_code: options.code,
390
+ referred_user_id: options.referredUserId,
391
+ milestone: options.milestone,
392
+ referred_user_name: options.referredUserName
393
+ });
394
+ }
395
+ /** Update a referral milestone */
396
+ async milestone(options) {
397
+ if (!options.code || !options.code.trim()) {
398
+ throw new Error("Tolinku: referral code is required and must not be blank.");
399
+ }
400
+ if (!options.milestone || !options.milestone.trim()) {
401
+ throw new Error("Tolinku: milestone is required and must not be blank.");
402
+ }
403
+ return this.client.post("/v1/api/referral/milestone", {
404
+ referral_code: options.code,
405
+ milestone: options.milestone
406
+ });
407
+ }
408
+ /** Claim a referral reward */
409
+ async claimReward(code) {
410
+ if (!code || !code.trim()) {
411
+ throw new Error("Tolinku: referral code is required and must not be blank.");
412
+ }
413
+ return this.client.post("/v1/api/referral/claim-reward", {
414
+ referral_code: code
415
+ });
416
+ }
417
+ /** Get the referral leaderboard */
418
+ async leaderboard(limit = 25) {
419
+ return this.client.get("/v1/api/referral/leaderboard", {
420
+ limit: String(limit)
421
+ });
422
+ }
423
+ };
424
+ var Deferred = class {
425
+ constructor(client) {
426
+ this.client = client;
427
+ }
428
+ /** Claim a deferred deep link by referrer token (from Play Store referrer or clipboard) */
429
+ async claimByToken(token) {
430
+ if (!token || !token.trim()) {
431
+ throw new Error("Tolinku: token is required and must not be blank for claimByToken.");
432
+ }
433
+ try {
434
+ return await this.client.getPublic("/v1/api/deferred/claim", { token });
435
+ } catch (err) {
436
+ debugWarn(`Deferred claimByToken failed: ${err.message}`);
437
+ return null;
438
+ }
439
+ }
440
+ /** Claim a deferred deep link by device signal matching */
441
+ async claimBySignals(options) {
442
+ if (!options.appspaceId || !options.appspaceId.trim()) {
443
+ throw new Error("Tolinku: appspaceId is required and must not be blank for claimBySignals.");
444
+ }
445
+ try {
446
+ const { width, height } = reactNative.Dimensions.get("screen");
447
+ const resolvedTimezone = options.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
448
+ const resolvedLanguage = options.language || "en";
449
+ return await this.client.postPublic("/v1/api/deferred/claim-by-signals", {
450
+ appspace_id: options.appspaceId,
451
+ timezone: resolvedTimezone,
452
+ language: resolvedLanguage,
453
+ screen_width: options.screenWidth || width,
454
+ screen_height: options.screenHeight || height
455
+ });
456
+ } catch (err) {
457
+ debugWarn(`Deferred claimBySignals failed: ${err.message}`);
458
+ return null;
459
+ }
460
+ }
461
+ };
462
+ var BASE_KEY = "tolinku_message_dismissed";
463
+ var IMPRESSIONS_BASE_KEY = "tolinku_message_impressions";
464
+ var LAST_SHOWN_BASE_KEY = "tolinku_message_last_shown";
465
+ var keyPrefix = BASE_KEY;
466
+ var impressionsKeyPrefix = IMPRESSIONS_BASE_KEY;
467
+ var lastShownKeyPrefix = LAST_SHOWN_BASE_KEY;
468
+ function setStorageNamespace(apiKey) {
469
+ const hash = simpleHash(apiKey);
470
+ keyPrefix = `tolinku_${hash}_message_dismissed`;
471
+ impressionsKeyPrefix = `tolinku_${hash}_message_impressions`;
472
+ lastShownKeyPrefix = `tolinku_${hash}_message_last_shown`;
473
+ }
474
+ function resetStorageNamespace() {
475
+ keyPrefix = BASE_KEY;
476
+ impressionsKeyPrefix = IMPRESSIONS_BASE_KEY;
477
+ lastShownKeyPrefix = LAST_SHOWN_BASE_KEY;
478
+ }
479
+ async function getStore(key) {
480
+ try {
481
+ const raw = await AsyncStorage__default.default.getItem(key);
482
+ return raw ? JSON.parse(raw) : {};
483
+ } catch {
484
+ return {};
485
+ }
486
+ }
487
+ async function setStore(key, data) {
488
+ try {
489
+ await AsyncStorage__default.default.setItem(key, JSON.stringify(data));
490
+ } catch {
491
+ }
492
+ }
493
+ async function isMessageDismissed(messageId, dismissDays) {
494
+ if (!dismissDays || dismissDays <= 0) return false;
495
+ const data = await getStore(keyPrefix);
496
+ const entry = data[messageId];
497
+ if (!entry) return false;
498
+ const dismissedAt = new Date(entry).getTime();
499
+ return Date.now() - dismissedAt < dismissDays * 864e5;
500
+ }
501
+ async function saveMessageDismissal(messageId) {
502
+ const data = await getStore(keyPrefix);
503
+ data[messageId] = (/* @__PURE__ */ new Date()).toISOString();
504
+ await setStore(keyPrefix, data);
505
+ }
506
+ async function isMessageSuppressed(messageId, maxImpressions, minIntervalHours) {
507
+ if (maxImpressions !== null && maxImpressions > 0) {
508
+ const impressions = await getStore(impressionsKeyPrefix);
509
+ const count = parseInt(impressions[messageId] || "0", 10);
510
+ if (count >= maxImpressions) return true;
511
+ }
512
+ if (minIntervalHours !== null && minIntervalHours > 0) {
513
+ const lastShown = await getStore(lastShownKeyPrefix);
514
+ const entry = lastShown[messageId];
515
+ if (entry) {
516
+ const lastShownAt = new Date(entry).getTime();
517
+ const intervalMs = minIntervalHours * 36e5;
518
+ if (Date.now() - lastShownAt < intervalMs) return true;
519
+ }
520
+ }
521
+ return false;
522
+ }
523
+ async function recordMessageImpression(messageId) {
524
+ const impressions = await getStore(impressionsKeyPrefix);
525
+ const count = parseInt(impressions[messageId] || "0", 10);
526
+ impressions[messageId] = String(count + 1);
527
+ await setStore(impressionsKeyPrefix, impressions);
528
+ const lastShown = await getStore(lastShownKeyPrefix);
529
+ lastShown[messageId] = (/* @__PURE__ */ new Date()).toISOString();
530
+ await setStore(lastShownKeyPrefix, lastShown);
531
+ }
532
+ function simpleHash(str) {
533
+ let hash = 5381;
534
+ for (let i = 0; i < str.length; i++) {
535
+ hash = (hash << 5) + hash + str.charCodeAt(i) & 4294967295;
536
+ }
537
+ return (hash >>> 0).toString(16);
538
+ }
539
+
540
+ // src/Tolinku.ts
541
+ var _Tolinku = class _Tolinku {
542
+ /**
543
+ * Initialize the SDK. Must be called before any other method.
544
+ *
545
+ * If init() is called a second time without calling destroy() first,
546
+ * a warning is logged and the existing instance is returned.
547
+ */
548
+ static init(config) {
549
+ if (!config.apiKey) throw new Error("Tolinku: apiKey is required");
550
+ if (_Tolinku._initialized) {
551
+ debugWarn(
552
+ "Tolinku.init() called while already initialized. Call Tolinku.destroy() first if you need to reconfigure."
553
+ );
554
+ console.warn(
555
+ "[TolinkuSDK] init() called while already initialized. Call Tolinku.destroy() first if you need to reconfigure."
556
+ );
557
+ return;
558
+ }
559
+ const baseUrl = config.baseUrl || "https://api.tolinku.com";
560
+ validateBaseUrl(baseUrl);
561
+ setDebugEnabled(config.debug === true);
562
+ const resolvedConfig = {
563
+ apiKey: config.apiKey,
564
+ baseUrl,
565
+ debug: config.debug === true,
566
+ timeout: config.timeout ?? 3e4
567
+ };
568
+ setStorageNamespace(config.apiKey);
569
+ _Tolinku.client = new HttpClient(resolvedConfig);
570
+ _Tolinku.analyticsInstance = new Analytics(_Tolinku.client);
571
+ _Tolinku.referralsInstance = new Referrals(_Tolinku.client);
572
+ _Tolinku.deferredInstance = new Deferred(_Tolinku.client);
573
+ _Tolinku._initialized = true;
574
+ debugLog(`Tolinku SDK v${SDK_VERSION} initialized (baseUrl=${baseUrl})`);
575
+ }
576
+ /** Check whether the SDK has been initialized. */
577
+ static isConfigured() {
578
+ return _Tolinku._initialized;
579
+ }
580
+ /**
581
+ * Set the user ID for segment targeting and analytics attribution.
582
+ * Pass null to clear the user ID.
583
+ */
584
+ static setUserId(userId) {
585
+ _Tolinku._userId = userId;
586
+ }
587
+ /** Get the current user ID, or null if not set. */
588
+ static getUserId() {
589
+ return _Tolinku._userId;
590
+ }
591
+ /** Get the underlying HTTP client (used internally by MessageProvider) */
592
+ static getClient() {
593
+ if (!_Tolinku.client) {
594
+ throw new Error("Tolinku: SDK not initialized. Call Tolinku.init() first.");
595
+ }
596
+ return _Tolinku.client;
597
+ }
598
+ /**
599
+ * Track a custom event (shorthand for analytics.track).
600
+ * Event type is auto-prefixed with "custom." if not already.
601
+ * Events are batched and flushed automatically.
602
+ */
603
+ static async track(eventType, properties) {
604
+ if (!_Tolinku.analyticsInstance) {
605
+ throw new Error("Tolinku: SDK not initialized. Call Tolinku.init() first.");
606
+ }
607
+ const mergedProps = _Tolinku._userId ? { user_id: _Tolinku._userId, ...properties } : properties;
608
+ return _Tolinku.analyticsInstance.track(eventType, mergedProps);
609
+ }
610
+ /**
611
+ * Immediately flush all queued analytics events to the server.
612
+ */
613
+ static async flush() {
614
+ if (!_Tolinku.analyticsInstance) {
615
+ throw new Error("Tolinku: SDK not initialized. Call Tolinku.init() first.");
616
+ }
617
+ return _Tolinku.analyticsInstance.flush();
618
+ }
619
+ /** Referrals: create, complete, milestone, leaderboard, claimReward */
620
+ static get referrals() {
621
+ if (!_Tolinku.referralsInstance) {
622
+ throw new Error("Tolinku: SDK not initialized. Call Tolinku.init() first.");
623
+ }
624
+ return _Tolinku.referralsInstance;
625
+ }
626
+ /** Deferred deep links: claimByToken, claimBySignals */
627
+ static get deferred() {
628
+ if (!_Tolinku.deferredInstance) {
629
+ throw new Error("Tolinku: SDK not initialized. Call Tolinku.init() first.");
630
+ }
631
+ return _Tolinku.deferredInstance;
632
+ }
633
+ /**
634
+ * Shut down the SDK and release resources.
635
+ * Flushes remaining analytics events, cancels timers, removes listeners,
636
+ * and aborts in-flight requests. After calling this, you must call init()
637
+ * again before using the SDK.
638
+ */
639
+ static async destroy() {
640
+ debugLog("Tolinku SDK shutting down");
641
+ if (_Tolinku.analyticsInstance) {
642
+ await _Tolinku.analyticsInstance.destroy();
643
+ }
644
+ if (_Tolinku.client) {
645
+ _Tolinku.client.abort();
646
+ }
647
+ resetStorageNamespace();
648
+ _Tolinku.client = null;
649
+ _Tolinku.analyticsInstance = null;
650
+ _Tolinku.referralsInstance = null;
651
+ _Tolinku.deferredInstance = null;
652
+ _Tolinku._initialized = false;
653
+ _Tolinku._userId = null;
654
+ setDebugEnabled(false);
655
+ }
656
+ };
657
+ _Tolinku.VERSION = SDK_VERSION;
658
+ _Tolinku.client = null;
659
+ _Tolinku.analyticsInstance = null;
660
+ _Tolinku.referralsInstance = null;
661
+ _Tolinku.deferredInstance = null;
662
+ _Tolinku._initialized = false;
663
+ _Tolinku._userId = null;
664
+ var Tolinku = _Tolinku;
665
+ function PuckComponentRenderer({ component, messageId, options }) {
666
+ const { props } = component;
667
+ switch (component.type) {
668
+ case "Heading": {
669
+ const style = {
670
+ fontSize: props.fontSize || 28,
671
+ fontWeight: "700",
672
+ color: props.color || "#1B1B1B",
673
+ textAlign: props.alignment || "left",
674
+ lineHeight: (props.fontSize || 28) * 1.2,
675
+ marginBottom: 8
676
+ };
677
+ return /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style, children: props.text || "" });
678
+ }
679
+ case "TextBlock": {
680
+ const style = {
681
+ fontSize: props.fontSize || 15,
682
+ color: props.color || "#555555",
683
+ textAlign: props.alignment || "left",
684
+ lineHeight: (props.fontSize || 15) * 1.5,
685
+ marginBottom: 8
686
+ };
687
+ return /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style, children: props.content || "" });
688
+ }
689
+ case "Image": {
690
+ const imageUrl = props.url || "";
691
+ if (!imageUrl || !imageUrl.trim()) {
692
+ debugWarn("Image component has empty URL, skipping render.");
693
+ return null;
694
+ }
695
+ if (!isSafeUrl(imageUrl)) {
696
+ debugWarn(`Image URL blocked (unsafe protocol): ${imageUrl}`);
697
+ return null;
698
+ }
699
+ const widthRaw = props.width || "100%";
700
+ let imageWidth = "100%";
701
+ if (widthRaw.endsWith("px")) {
702
+ imageWidth = parseInt(widthRaw, 10);
703
+ } else if (widthRaw.endsWith("%")) {
704
+ imageWidth = widthRaw;
705
+ } else {
706
+ const parsed = parseInt(widthRaw, 10);
707
+ imageWidth = isNaN(parsed) ? "100%" : parsed;
708
+ }
709
+ const style = {
710
+ width: imageWidth,
711
+ height: props.height || 200,
712
+ borderRadius: props.borderRadius || 8,
713
+ alignSelf: "center",
714
+ marginBottom: 8
715
+ };
716
+ return /* @__PURE__ */ jsxRuntime.jsx(
717
+ reactNative.Image,
718
+ {
719
+ source: { uri: imageUrl },
720
+ style,
721
+ resizeMode: "cover",
722
+ accessibilityLabel: props.alt || ""
723
+ }
724
+ );
725
+ }
726
+ case "Button": {
727
+ const handlePress = () => {
728
+ const action = props.action || "";
729
+ if (options.onButtonPress) {
730
+ options.onButtonPress(action, messageId);
731
+ } else if (action) {
732
+ if (!isSafeUrl(action)) {
733
+ debugWarn(`Button action URL blocked (unsafe protocol): ${action}`);
734
+ return;
735
+ }
736
+ reactNative.Linking.openURL(action).catch(() => {
737
+ });
738
+ }
739
+ };
740
+ const containerStyle = {
741
+ backgroundColor: props.bgColor || "#1B1B1B",
742
+ borderRadius: props.borderRadius || 8,
743
+ paddingVertical: 10,
744
+ paddingHorizontal: 20,
745
+ marginVertical: 8,
746
+ alignItems: "center",
747
+ ...props.fullWidth ? { width: "100%" } : {}
748
+ };
749
+ const textStyle = {
750
+ color: props.textColor || "#ffffff",
751
+ fontSize: props.fontSize || 16,
752
+ fontWeight: "600"
753
+ };
754
+ return /* @__PURE__ */ jsxRuntime.jsx(reactNative.TouchableOpacity, { onPress: handlePress, style: containerStyle, activeOpacity: 0.7, children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: textStyle, children: props.label || "Click" }) });
755
+ }
756
+ case "Section": {
757
+ const children = props.children || [];
758
+ const containerStyle = {
759
+ backgroundColor: props.bgColor || void 0,
760
+ padding: props.padding || 16,
761
+ borderRadius: props.borderRadius || 0,
762
+ marginVertical: 8
763
+ };
764
+ const bgImage = props.bgImage || "";
765
+ const content = children.map((child, index) => /* @__PURE__ */ jsxRuntime.jsx(
766
+ PuckComponentRenderer,
767
+ {
768
+ component: child,
769
+ messageId,
770
+ options
771
+ },
772
+ `${messageId}-section-${index}-${child.type}`
773
+ ));
774
+ if (bgImage) {
775
+ if (!isSafeUrl(bgImage)) {
776
+ debugWarn(`Section background image URL blocked (unsafe protocol): ${bgImage}`);
777
+ return /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: containerStyle, children: content });
778
+ }
779
+ return /* @__PURE__ */ jsxRuntime.jsx(
780
+ reactNative.ImageBackground,
781
+ {
782
+ source: { uri: bgImage },
783
+ style: containerStyle,
784
+ resizeMode: props.bgSize === "contain" ? "contain" : "cover",
785
+ imageStyle: { borderRadius: props.borderRadius || 0 },
786
+ children: content
787
+ }
788
+ );
789
+ }
790
+ return /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: containerStyle, children: content });
791
+ }
792
+ case "Spacer": {
793
+ const style = {
794
+ height: props.height || 24
795
+ };
796
+ return /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style });
797
+ }
798
+ case "Divider": {
799
+ const style = {
800
+ borderTopWidth: props.thickness || 1,
801
+ borderTopColor: props.color || "#e5e5e5",
802
+ marginVertical: 8
803
+ };
804
+ return /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style });
805
+ }
806
+ default:
807
+ return null;
808
+ }
809
+ }
810
+ function MessageModal({ message, visible, onClose, options }) {
811
+ if (!message) {
812
+ return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, {});
813
+ }
814
+ const handleDismiss = async () => {
815
+ await saveMessageDismissal(message.id);
816
+ options.onDismiss?.(message.id);
817
+ onClose();
818
+ };
819
+ return /* @__PURE__ */ jsxRuntime.jsx(
820
+ reactNative.Modal,
821
+ {
822
+ visible,
823
+ transparent: true,
824
+ animationType: "fade",
825
+ onRequestClose: handleDismiss,
826
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Pressable, { style: styles.overlay, onPress: handleDismiss, children: /* @__PURE__ */ jsxRuntime.jsxs(reactNative.Pressable, { style: [styles.card, { backgroundColor: message.background_color || "#ffffff" }], onPress: () => {
827
+ }, children: [
828
+ /* @__PURE__ */ jsxRuntime.jsx(
829
+ reactNative.TouchableOpacity,
830
+ {
831
+ onPress: handleDismiss,
832
+ style: styles.closeButton,
833
+ accessibilityLabel: "Close message",
834
+ accessibilityRole: "button",
835
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: styles.closeText, children: "\xD7" })
836
+ }
837
+ ),
838
+ /* @__PURE__ */ jsxRuntime.jsx(
839
+ reactNative.ScrollView,
840
+ {
841
+ style: styles.scrollContent,
842
+ showsVerticalScrollIndicator: false,
843
+ children: message.content?.content?.map((component, index) => /* @__PURE__ */ jsxRuntime.jsx(
844
+ PuckComponentRenderer,
845
+ {
846
+ component,
847
+ messageId: message.id,
848
+ options
849
+ },
850
+ `${message.id}-${index}-${component.type}`
851
+ ))
852
+ }
853
+ )
854
+ ] }) })
855
+ }
856
+ );
857
+ }
858
+ var styles = reactNative.StyleSheet.create({
859
+ overlay: {
860
+ flex: 1,
861
+ backgroundColor: "rgba(0,0,0,0.5)",
862
+ alignItems: "center",
863
+ justifyContent: "center"
864
+ },
865
+ card: {
866
+ position: "relative",
867
+ maxWidth: 375,
868
+ width: "90%",
869
+ maxHeight: "80%",
870
+ borderRadius: 16,
871
+ padding: 24,
872
+ shadowColor: "#000",
873
+ shadowOffset: { width: 0, height: 20 },
874
+ shadowOpacity: 0.3,
875
+ shadowRadius: 30,
876
+ elevation: 10
877
+ },
878
+ closeButton: {
879
+ position: "absolute",
880
+ top: 12,
881
+ right: 12,
882
+ zIndex: 10,
883
+ backgroundColor: "rgba(0,0,0,0.1)",
884
+ borderRadius: 14,
885
+ width: 28,
886
+ height: 28,
887
+ alignItems: "center",
888
+ justifyContent: "center"
889
+ },
890
+ closeText: {
891
+ fontSize: 18,
892
+ lineHeight: 20,
893
+ opacity: 0.6
894
+ },
895
+ scrollContent: {
896
+ marginTop: 8
897
+ }
898
+ });
899
+ function TolinkuMessages({
900
+ trigger,
901
+ triggerValue,
902
+ onDismiss,
903
+ onButtonPress
904
+ }) {
905
+ const [message, setMessage] = react.useState(null);
906
+ const [visible, setVisible] = react.useState(false);
907
+ const onDismissRef = react.useRef(onDismiss);
908
+ onDismissRef.current = onDismiss;
909
+ const onButtonPressRef = react.useRef(onButtonPress);
910
+ onButtonPressRef.current = onButtonPress;
911
+ const options = react.useMemo(() => ({
912
+ trigger,
913
+ triggerValue,
914
+ onDismiss: (messageId) => onDismissRef.current?.(messageId),
915
+ onButtonPress: (action, messageId) => onButtonPressRef.current?.(action, messageId)
916
+ }), [trigger, triggerValue]);
917
+ react.useEffect(() => {
918
+ let cancelled = false;
919
+ async function fetchMessages() {
920
+ try {
921
+ const client = Tolinku.getClient();
922
+ const params = {};
923
+ if (trigger) params.trigger = trigger;
924
+ const userId = Tolinku.getUserId();
925
+ if (userId) params.user_id = userId;
926
+ const data = await client.get("/v1/api/messages", params);
927
+ if (cancelled || !data.messages || data.messages.length === 0) return;
928
+ const candidates = [];
929
+ for (const m of data.messages) {
930
+ if (triggerValue && m.trigger_value !== triggerValue) continue;
931
+ const dismissed = await isMessageDismissed(m.id, m.dismiss_days);
932
+ if (dismissed) continue;
933
+ const suppressed = await isMessageSuppressed(m.id, m.max_impressions, m.min_interval_hours);
934
+ if (!suppressed) candidates.push(m);
935
+ }
936
+ candidates.sort((a, b) => b.priority - a.priority);
937
+ if (candidates.length > 0 && !cancelled) {
938
+ await recordMessageImpression(candidates[0].id);
939
+ setMessage(candidates[0]);
940
+ setVisible(true);
941
+ }
942
+ } catch (err) {
943
+ debugWarn(`Failed to fetch messages: ${err.message}`);
944
+ }
945
+ }
946
+ fetchMessages();
947
+ return () => {
948
+ cancelled = true;
949
+ };
950
+ }, [trigger, triggerValue]);
951
+ const handleClose = react.useCallback(() => {
952
+ setVisible(false);
953
+ setMessage(null);
954
+ }, []);
955
+ return /* @__PURE__ */ jsxRuntime.jsx(
956
+ MessageModal,
957
+ {
958
+ message,
959
+ visible,
960
+ onClose: handleClose,
961
+ options
962
+ }
963
+ );
964
+ }
965
+
966
+ exports.Tolinku = Tolinku;
967
+ exports.TolinkuError = TolinkuError;
968
+ exports.TolinkuMessages = TolinkuMessages;
969
+ exports.isSafeUrl = isSafeUrl;
970
+ //# sourceMappingURL=index.js.map
971
+ //# sourceMappingURL=index.js.map