insert-affiliate-js-sdk 1.3.0 → 1.5.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/.github/workflows/publish.yml +28 -0
- package/CHANGELOG.md +26 -0
- package/README.md +175 -0
- package/dist/index.d.ts +287 -4
- package/dist/index.js +1044 -41
- package/package.json +7 -3
- package/src/index.ts +16 -1
- package/src/referrals/referAFriendModal.ts +534 -0
- package/src/referrals/referralApi.ts +385 -0
- package/src/referrals/referralStrings.ts +85 -0
- package/src/referrals/referralTypes.ts +211 -0
- package/src/referrals/referrerTokenStore.ts +42 -0
- package/src/sdk/InsertAffiliate.ts +350 -46
- package/.claude/settings.local.json +0 -10
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// src/referrals/referrerTokenStore.ts
|
|
2
|
+
// The device token that lets this browser read its own referrer's stats.
|
|
3
|
+
// One token per company ID in localStorage. Storage can be unavailable
|
|
4
|
+
// (private mode, blocked site data, a full quota), so every access is
|
|
5
|
+
// guarded; when saving throws, the token lives in memory for this page load
|
|
6
|
+
// only and reads fall back to it. The token is never logged.
|
|
7
|
+
|
|
8
|
+
const KEY_PREFIX = 'insertAffiliateReferrerToken_';
|
|
9
|
+
const memoryTokens: Record<string, string> = {};
|
|
10
|
+
|
|
11
|
+
const keyFor = (companyId: string): string => `${KEY_PREFIX}${companyId}`;
|
|
12
|
+
|
|
13
|
+
export const readReferrerToken = (companyId: string): string | null => {
|
|
14
|
+
let stored: string | null = null;
|
|
15
|
+
try {
|
|
16
|
+
stored = localStorage.getItem(keyFor(companyId));
|
|
17
|
+
} catch {
|
|
18
|
+
// Storage unreadable; use the in-memory copy.
|
|
19
|
+
}
|
|
20
|
+
return stored || memoryTokens[companyId] || null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Returns false when only the in-memory copy could be kept. */
|
|
24
|
+
export const saveReferrerToken = (companyId: string, token: string): boolean => {
|
|
25
|
+
try {
|
|
26
|
+
localStorage.setItem(keyFor(companyId), token);
|
|
27
|
+
delete memoryTokens[companyId];
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
memoryTokens[companyId] = token;
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const clearReferrerToken = (companyId: string): void => {
|
|
36
|
+
delete memoryTokens[companyId];
|
|
37
|
+
try {
|
|
38
|
+
localStorage.removeItem(keyFor(companyId));
|
|
39
|
+
} catch {
|
|
40
|
+
// Nothing stored that we can reach.
|
|
41
|
+
}
|
|
42
|
+
};
|
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
// src/sdk/InsertAffiliate.ts
|
|
2
2
|
import { getValue, saveValue } from '../utils/asyncStorage';
|
|
3
|
-
import {
|
|
3
|
+
import { generateShortDeviceID, generateUUID } from '../utils/helpers';
|
|
4
|
+
import {
|
|
5
|
+
buildReferralShareText,
|
|
6
|
+
fetchMyAffiliateDetails,
|
|
7
|
+
fetchReferralProgramConfig,
|
|
8
|
+
MyDetailsFetch,
|
|
9
|
+
normalizeVerificationCode,
|
|
10
|
+
postEnrolment,
|
|
11
|
+
postReferrerIdentity,
|
|
12
|
+
shareText,
|
|
13
|
+
} from '../referrals/referralApi';
|
|
14
|
+
import { clearReferrerToken, readReferrerToken } from '../referrals/referrerTokenStore';
|
|
15
|
+
import { presentReferAFriend } from '../referrals/referAFriendModal';
|
|
16
|
+
import type {
|
|
17
|
+
AffiliateEnrolmentResult,
|
|
18
|
+
MyAffiliateDetails,
|
|
19
|
+
ReferAFriendHandle,
|
|
20
|
+
ReferAFriendOptions,
|
|
21
|
+
ReferralProgramConfig,
|
|
22
|
+
ReferralShareOutcome,
|
|
23
|
+
ReferrerAccountOptions,
|
|
24
|
+
} from '../referrals/referralTypes';
|
|
4
25
|
|
|
5
26
|
interface IapticAndroidReceipt {
|
|
6
27
|
orderId: string;
|
|
@@ -25,6 +46,15 @@ export interface AffiliateDetails {
|
|
|
25
46
|
deeplinkUrl: string;
|
|
26
47
|
}
|
|
27
48
|
|
|
49
|
+
// 'lookup_failed' (outage, timeout) may be worth retrying. 'not_configured' (no company
|
|
50
|
+
// code set) never will be — it's always a bug in the calling app, not the code or backend.
|
|
51
|
+
export type AffiliateLookupStatus = 'found' | 'not_found' | 'lookup_failed' | 'not_configured';
|
|
52
|
+
|
|
53
|
+
export interface AffiliateLookupResult {
|
|
54
|
+
status: AffiliateLookupStatus;
|
|
55
|
+
details: AffiliateDetails | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
28
58
|
export type InsertAffiliateIdentifierChangeCallback = (identifier: string | null, offerCode: string | null) => void;
|
|
29
59
|
|
|
30
60
|
export class InsertAffiliate {
|
|
@@ -35,6 +65,11 @@ export class InsertAffiliate {
|
|
|
35
65
|
private static affiliateAttributionActiveTime: number | null = null; // in milliseconds
|
|
36
66
|
private static preventAffiliateTransfer: boolean = false;
|
|
37
67
|
private static offerCode: string | null = null;
|
|
68
|
+
// Last referral details and config seen. shareReferralLink uses only these,
|
|
69
|
+
// so the share sheet opens without a network round trip (browsers require
|
|
70
|
+
// the share to follow the user's tap closely).
|
|
71
|
+
private static lastReferralDetails: MyAffiliateDetails | null = null;
|
|
72
|
+
private static lastReferralConfig: ReferralProgramConfig | null = null;
|
|
38
73
|
|
|
39
74
|
private static verboseLog(message: string): void {
|
|
40
75
|
if (this.verboseLogging) {
|
|
@@ -196,9 +231,11 @@ export class InsertAffiliate {
|
|
|
196
231
|
* Validates and sets a short code for affiliate tracking
|
|
197
232
|
* Validates the short code against the API before storing
|
|
198
233
|
* @param shortCode The short code to validate and set
|
|
234
|
+
* @param options.onLookupFailed called when the lookup itself couldn't be completed
|
|
235
|
+
* (not just an invalid code) — use it to offer a retry instead of proceeding unattributed.
|
|
199
236
|
* @returns true if the code exists and was successfully validated and stored, false otherwise
|
|
200
237
|
*/
|
|
201
|
-
static async setShortCode(shortCode: string): Promise<boolean> {
|
|
238
|
+
static async setShortCode(shortCode: string, options?: { onLookupFailed?: () => void }): Promise<boolean> {
|
|
202
239
|
this.verboseLog(`Setting short code. Input: ${shortCode}`);
|
|
203
240
|
|
|
204
241
|
const valid = /^[a-zA-Z0-9]{3,25}$/.test(shortCode);
|
|
@@ -210,12 +247,16 @@ export class InsertAffiliate {
|
|
|
210
247
|
}
|
|
211
248
|
|
|
212
249
|
// Validate that the short code exists in the system
|
|
213
|
-
const
|
|
214
|
-
if (!
|
|
215
|
-
this.verboseLog(`Short code '${shortCode}' does not exist or validation failed`);
|
|
250
|
+
const lookup = await this.getAffiliateLookupResult(shortCode, { trackUsage: true });
|
|
251
|
+
if (lookup.status !== 'found' || !lookup.details) {
|
|
252
|
+
this.verboseLog(`Short code '${shortCode}' does not exist or validation failed (status: ${lookup.status})`);
|
|
216
253
|
console.error(`[Insert Affiliate] Error: Short code '${shortCode}' does not exist or validation failed.`);
|
|
254
|
+
if (lookup.status === 'lookup_failed') {
|
|
255
|
+
options?.onLookupFailed?.();
|
|
256
|
+
}
|
|
217
257
|
return false;
|
|
218
258
|
}
|
|
259
|
+
const affiliateDetails = lookup.details;
|
|
219
260
|
|
|
220
261
|
this.verboseLog(`Short code validated successfully for affiliate: ${affiliateDetails.affiliateName}`);
|
|
221
262
|
console.log(`[Insert Affiliate] Short code validated successfully for affiliate: ${affiliateDetails.affiliateName}`);
|
|
@@ -304,18 +345,20 @@ export class InsertAffiliate {
|
|
|
304
345
|
}
|
|
305
346
|
|
|
306
347
|
/**
|
|
307
|
-
* Retrieve detailed information about an affiliate by their short code or deep link
|
|
308
|
-
*
|
|
348
|
+
* Retrieve detailed information about an affiliate by their short code or deep link,
|
|
349
|
+
* distinguishing "no affiliate matches this code" from "couldn't check" (backend
|
|
350
|
+
* outage, timeout, rate limit). This method queries the API and does not store or set
|
|
351
|
+
* the affiliate identifier.
|
|
309
352
|
* @param affiliateCode The short code or deep link to look up
|
|
310
|
-
* @returns
|
|
353
|
+
* @returns an AffiliateLookupResult with a status of 'found', 'not_found', 'lookup_failed', or 'not_configured'
|
|
311
354
|
*/
|
|
312
|
-
static async
|
|
355
|
+
static async getAffiliateLookupResult(affiliateCode: string, options?: { trackUsage?: boolean }): Promise<AffiliateLookupResult> {
|
|
313
356
|
this.verboseLog(`Getting affiliate details for: ${affiliateCode}`);
|
|
314
357
|
|
|
315
358
|
const companyCode = this.companyCode || await getValue('companyCode');
|
|
316
359
|
if (!companyCode) {
|
|
317
360
|
this.verboseLog('Cannot get affiliate details: no company code available');
|
|
318
|
-
return null;
|
|
361
|
+
return { status: 'not_configured', details: null };
|
|
319
362
|
}
|
|
320
363
|
|
|
321
364
|
// Strip UUID from code if present (e.g., "ABC123-uuid" becomes "ABC123")
|
|
@@ -346,13 +389,19 @@ export class InsertAffiliate {
|
|
|
346
389
|
|
|
347
390
|
if (!response.ok) {
|
|
348
391
|
this.verboseLog(`Failed to get affiliate details, status: ${response.status}`);
|
|
349
|
-
return null;
|
|
392
|
+
return { status: 'lookup_failed', details: null };
|
|
350
393
|
}
|
|
351
394
|
|
|
352
395
|
const data = await response.json();
|
|
353
396
|
this.verboseLog(`API response data: ${JSON.stringify(data)}`);
|
|
354
397
|
|
|
355
|
-
if (data.exists
|
|
398
|
+
if (data.exists) {
|
|
399
|
+
if (!data.affiliate) {
|
|
400
|
+
// Malformed response, not a real not-found.
|
|
401
|
+
this.verboseLog('Affiliate exists but response is missing affiliate details');
|
|
402
|
+
return { status: 'lookup_failed', details: null };
|
|
403
|
+
}
|
|
404
|
+
|
|
356
405
|
const details: AffiliateDetails = {
|
|
357
406
|
affiliateName: data.affiliate.affiliateName,
|
|
358
407
|
affiliateShortCode: data.affiliate.affiliateShortCode,
|
|
@@ -360,17 +409,30 @@ export class InsertAffiliate {
|
|
|
360
409
|
};
|
|
361
410
|
|
|
362
411
|
this.verboseLog(`Successfully retrieved affiliate details for: ${details.affiliateName}`);
|
|
363
|
-
return details;
|
|
412
|
+
return { status: 'found', details };
|
|
364
413
|
}
|
|
365
414
|
|
|
366
415
|
this.verboseLog('Affiliate does not exist');
|
|
367
|
-
return null;
|
|
416
|
+
return { status: 'not_found', details: null };
|
|
368
417
|
} catch (error) {
|
|
369
418
|
this.verboseLog(`Error fetching affiliate details: ${error}`);
|
|
370
|
-
return null;
|
|
419
|
+
return { status: 'lookup_failed', details: null };
|
|
371
420
|
}
|
|
372
421
|
}
|
|
373
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Retrieve detailed information about an affiliate by their short code or deep link.
|
|
425
|
+
* Kept for backward compatibility: collapses 'not_found' and 'lookup_failed' into the
|
|
426
|
+
* same null result, exactly as before. Use getAffiliateLookupResult if you need to tell
|
|
427
|
+
* an invalid code apart from a backend outage.
|
|
428
|
+
* @param affiliateCode The short code or deep link to look up
|
|
429
|
+
* @returns AffiliateDetails if found, null otherwise
|
|
430
|
+
*/
|
|
431
|
+
static async getAffiliateDetails(affiliateCode: string, options?: { trackUsage?: boolean }): Promise<AffiliateDetails | null> {
|
|
432
|
+
const result = await this.getAffiliateLookupResult(affiliateCode, options);
|
|
433
|
+
return result.details;
|
|
434
|
+
}
|
|
435
|
+
|
|
374
436
|
static async returnCompanyId(): Promise<string | null> {
|
|
375
437
|
this.verboseLog('Getting company ID...');
|
|
376
438
|
const companyCode = this.companyCode || await getValue('companyCode');
|
|
@@ -453,21 +515,23 @@ export class InsertAffiliate {
|
|
|
453
515
|
return null;
|
|
454
516
|
}
|
|
455
517
|
|
|
456
|
-
const
|
|
457
|
-
this.verboseLog(`Received offer code: ${offerCode}`);
|
|
458
|
-
|
|
459
|
-
const errorCodes = [
|
|
460
|
-
'errorofferCodeNotFound',
|
|
461
|
-
'errorAffiliateoffercodenotfoundinanycompany',
|
|
462
|
-
'errorAffiliateoffercodenotfoundinanycompanyAffiliatelinkwas',
|
|
463
|
-
'Routenotfound'
|
|
464
|
-
];
|
|
518
|
+
const rawOfferCode = await response.text();
|
|
465
519
|
|
|
466
|
-
|
|
520
|
+
// Check for specific error strings from API before cleaning
|
|
521
|
+
if (
|
|
522
|
+
rawOfferCode.includes('errorofferCodeNotFound') ||
|
|
523
|
+
rawOfferCode.includes('errorAffiliateoffercodenotfoundinanycompany') ||
|
|
524
|
+
rawOfferCode.includes('errorAffiliateoffercodenotfoundinanycompanyAffiliatelinkwas') ||
|
|
525
|
+
rawOfferCode.includes('Routenotfound')
|
|
526
|
+
) {
|
|
467
527
|
this.verboseLog('Offer code not found or invalid');
|
|
468
528
|
return null;
|
|
469
529
|
}
|
|
470
530
|
|
|
531
|
+
// Remove special characters, keep only alphanumeric, underscores, and dashes
|
|
532
|
+
const offerCode = rawOfferCode.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
533
|
+
this.verboseLog(`Received offer code: ${offerCode}`);
|
|
534
|
+
|
|
471
535
|
// Store offer code with platform-specific key
|
|
472
536
|
const storageKey = platformType === 'stripe' ? 'offerCode' : `offerCode_${platformType}`;
|
|
473
537
|
await saveValue(storageKey, offerCode);
|
|
@@ -507,22 +571,23 @@ export class InsertAffiliate {
|
|
|
507
571
|
return null;
|
|
508
572
|
}
|
|
509
573
|
|
|
510
|
-
const
|
|
511
|
-
this.verboseLog(`Received offer code: ${offerCode}`);
|
|
512
|
-
|
|
513
|
-
// Check for error codes
|
|
514
|
-
const errorCodes = [
|
|
515
|
-
'errorofferCodeNotFound',
|
|
516
|
-
'errorAffiliateoffercodenotfoundinanycompany',
|
|
517
|
-
'errorAffiliateoffercodenotfoundinanycompanyAffiliatelinkwas',
|
|
518
|
-
'Routenotfound'
|
|
519
|
-
];
|
|
574
|
+
const rawOfferCode = await response.text();
|
|
520
575
|
|
|
521
|
-
|
|
576
|
+
// Check for specific error strings from API before cleaning
|
|
577
|
+
if (
|
|
578
|
+
rawOfferCode.includes('errorofferCodeNotFound') ||
|
|
579
|
+
rawOfferCode.includes('errorAffiliateoffercodenotfoundinanycompany') ||
|
|
580
|
+
rawOfferCode.includes('errorAffiliateoffercodenotfoundinanycompanyAffiliatelinkwas') ||
|
|
581
|
+
rawOfferCode.includes('Routenotfound')
|
|
582
|
+
) {
|
|
522
583
|
this.verboseLog('Offer code not found or invalid');
|
|
523
584
|
return null;
|
|
524
585
|
}
|
|
525
586
|
|
|
587
|
+
// Remove special characters, keep only alphanumeric, underscores, and dashes
|
|
588
|
+
const offerCode = rawOfferCode.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
589
|
+
this.verboseLog(`Received offer code: ${offerCode}`);
|
|
590
|
+
|
|
526
591
|
// Store offer code
|
|
527
592
|
this.offerCode = offerCode;
|
|
528
593
|
await saveValue('offerCode', offerCode);
|
|
@@ -739,21 +804,23 @@ export class InsertAffiliate {
|
|
|
739
804
|
const res = await fetch(url);
|
|
740
805
|
this.verboseLog(`API response status: ${res.status}`);
|
|
741
806
|
|
|
742
|
-
const
|
|
743
|
-
this.verboseLog(`Received offer code: ${offerCode}`);
|
|
744
|
-
|
|
745
|
-
const errorCodes = [
|
|
746
|
-
'errorofferCodeNotFound',
|
|
747
|
-
'errorAffiliateoffercodenotfoundinanycompany',
|
|
748
|
-
'errorAffiliateoffercodenotfoundinanycompanyAffiliatelinkwas',
|
|
749
|
-
'Routenotfound'
|
|
750
|
-
];
|
|
807
|
+
const rawOfferCode = await res.text();
|
|
751
808
|
|
|
752
|
-
|
|
809
|
+
// Check for specific error strings from API before cleaning
|
|
810
|
+
if (
|
|
811
|
+
rawOfferCode.includes('errorofferCodeNotFound') ||
|
|
812
|
+
rawOfferCode.includes('errorAffiliateoffercodenotfoundinanycompany') ||
|
|
813
|
+
rawOfferCode.includes('errorAffiliateoffercodenotfoundinanycompanyAffiliatelinkwas') ||
|
|
814
|
+
rawOfferCode.includes('Routenotfound')
|
|
815
|
+
) {
|
|
753
816
|
this.verboseLog('Offer code not found or invalid');
|
|
754
817
|
return;
|
|
755
818
|
}
|
|
756
819
|
|
|
820
|
+
// Remove special characters, keep only alphanumeric, underscores, and dashes
|
|
821
|
+
const offerCode = rawOfferCode.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
822
|
+
this.verboseLog(`Received offer code: ${offerCode}`);
|
|
823
|
+
|
|
757
824
|
const redeemUrl = `https://apps.apple.com/redeem?ctx=offercodes&id=${offerCodeUrlId}&code=${offerCode}`;
|
|
758
825
|
this.verboseLog(`Opening redeem URL: ${redeemUrl}`);
|
|
759
826
|
window.open(redeemUrl, '_blank');
|
|
@@ -763,6 +830,243 @@ export class InsertAffiliate {
|
|
|
763
830
|
}
|
|
764
831
|
}
|
|
765
832
|
|
|
833
|
+
// ---------------------------------------------------------------------------
|
|
834
|
+
// In-app referrals: make the app's own user an affiliate and read their stats
|
|
835
|
+
// ---------------------------------------------------------------------------
|
|
836
|
+
|
|
837
|
+
private static async referralCompanyId(): Promise<string | null> {
|
|
838
|
+
try {
|
|
839
|
+
return this.companyCode || await getValue('companyCode');
|
|
840
|
+
} catch {
|
|
841
|
+
return this.companyCode;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* The device id in this browser's "{shortCode}-{deviceId}" identifier, so the
|
|
847
|
+
* server can tell a referrer apart from the friends they refer.
|
|
848
|
+
* Null when storage is unavailable.
|
|
849
|
+
*/
|
|
850
|
+
private static async referralDeviceId(): Promise<string | null> {
|
|
851
|
+
try {
|
|
852
|
+
return await this.getOrCreateUserID();
|
|
853
|
+
} catch {
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
private static notInitializedResult(): AffiliateEnrolmentResult {
|
|
859
|
+
this.verboseLog('Cannot use referrals: no company code available. Call initialize first.');
|
|
860
|
+
return {
|
|
861
|
+
status: 'error',
|
|
862
|
+
errorCode: 'NOT_INITIALIZED',
|
|
863
|
+
errorMessage: 'The Insert Affiliate SDK is not initialized with a company code.',
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Makes the app's user an affiliate (a referrer) of this company.
|
|
869
|
+
* A new email is created straight away and this device is connected.
|
|
870
|
+
* An email that is already an affiliate is sent a 6-digit code instead:
|
|
871
|
+
* the result is `verificationRequired`; finish with verifyAffiliateCode.
|
|
872
|
+
* @param email The user's email (usually the app's logged-in user)
|
|
873
|
+
* @param name The user's display name
|
|
874
|
+
* @param options The user's own accounts (RevenueCat / Adapty app user id,
|
|
875
|
+
* Google Play purchase token), used to grant their referral rewards
|
|
876
|
+
*/
|
|
877
|
+
static async createAffiliateForUser(
|
|
878
|
+
email: string,
|
|
879
|
+
name: string,
|
|
880
|
+
options?: ReferrerAccountOptions
|
|
881
|
+
): Promise<AffiliateEnrolmentResult> {
|
|
882
|
+
this.verboseLog('Creating affiliate for app user...');
|
|
883
|
+
const companyId = await this.referralCompanyId();
|
|
884
|
+
if (!companyId) return this.notInitializedResult();
|
|
885
|
+
|
|
886
|
+
const result = await postEnrolment('enrol', companyId, {
|
|
887
|
+
email: (email || '').trim(),
|
|
888
|
+
name: (name || '').trim(),
|
|
889
|
+
...await this.referrerAccountFields(options),
|
|
890
|
+
}, (message) => this.verboseLog(message));
|
|
891
|
+
if (result.status !== 'error') this.lastReferralDetails = null;
|
|
892
|
+
return result;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Finishes connecting this device with the 6-digit code emailed by
|
|
897
|
+
* createAffiliateForUser. On success the device is connected.
|
|
898
|
+
* @param email The same email passed to createAffiliateForUser
|
|
899
|
+
* @param code The 6-digit code from the email
|
|
900
|
+
* @param name Optional display name, used if the affiliate is created now
|
|
901
|
+
* @param options The user's own accounts, as for createAffiliateForUser
|
|
902
|
+
*/
|
|
903
|
+
static async verifyAffiliateCode(
|
|
904
|
+
email: string,
|
|
905
|
+
code: string,
|
|
906
|
+
name?: string,
|
|
907
|
+
options?: ReferrerAccountOptions
|
|
908
|
+
): Promise<AffiliateEnrolmentResult> {
|
|
909
|
+
this.verboseLog('Verifying affiliate code...');
|
|
910
|
+
const companyId = await this.referralCompanyId();
|
|
911
|
+
if (!companyId) return this.notInitializedResult();
|
|
912
|
+
|
|
913
|
+
const result = await postEnrolment('verify', companyId, {
|
|
914
|
+
email: (email || '').trim(),
|
|
915
|
+
code: normalizeVerificationCode(code),
|
|
916
|
+
name: (name || '').trim(),
|
|
917
|
+
...await this.referrerAccountFields(options),
|
|
918
|
+
}, (message) => this.verboseLog(message));
|
|
919
|
+
if (result.status !== 'error') this.lastReferralDetails = null;
|
|
920
|
+
return result;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
private static async referrerAccountFields(options?: ReferrerAccountOptions): Promise<Record<string, string | undefined>> {
|
|
924
|
+
return {
|
|
925
|
+
deviceId: (await this.referralDeviceId()) || undefined,
|
|
926
|
+
appUserId: options && options.appUserId,
|
|
927
|
+
playPurchaseToken: options && options.playPurchaseToken,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Saves the connected referrer's own accounts, for apps whose user
|
|
933
|
+
* subscribes or logs in after joining. The server then grants any
|
|
934
|
+
* rewards that were waiting for them.
|
|
935
|
+
* @param options The RevenueCat / Adapty app user id and/or the Google Play purchase token
|
|
936
|
+
* @returns True when saved; false when no referrer is connected on this device
|
|
937
|
+
* or the request failed. A revoked connection is cleared.
|
|
938
|
+
*/
|
|
939
|
+
static async setReferrerAccount(options: ReferrerAccountOptions): Promise<boolean> {
|
|
940
|
+
this.verboseLog('Saving referrer account...');
|
|
941
|
+
const companyId = await this.referralCompanyId();
|
|
942
|
+
const token = companyId ? readReferrerToken(companyId) : null;
|
|
943
|
+
if (!companyId || !token) {
|
|
944
|
+
this.verboseLog('Cannot save referrer account: no referrer connected on this device');
|
|
945
|
+
return false;
|
|
946
|
+
}
|
|
947
|
+
const saved = await postReferrerIdentity(
|
|
948
|
+
companyId,
|
|
949
|
+
token,
|
|
950
|
+
await this.referrerAccountFields(options),
|
|
951
|
+
(message) => this.verboseLog(message)
|
|
952
|
+
);
|
|
953
|
+
if (!readReferrerToken(companyId)) this.lastReferralDetails = null;
|
|
954
|
+
this.verboseLog(`Referrer account saved: ${saved}`);
|
|
955
|
+
return saved;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
private static async loadMyAffiliateDetails(): Promise<MyDetailsFetch> {
|
|
959
|
+
const companyId = await this.referralCompanyId();
|
|
960
|
+
const token = companyId ? readReferrerToken(companyId) : null;
|
|
961
|
+
if (!companyId || !token) {
|
|
962
|
+
this.verboseLog('No referrer connected on this device');
|
|
963
|
+
this.lastReferralDetails = null;
|
|
964
|
+
return { kind: 'signedOut' };
|
|
965
|
+
}
|
|
966
|
+
const loaded = await fetchMyAffiliateDetails(companyId, token, (message) => this.verboseLog(message));
|
|
967
|
+
if (loaded.kind === 'ok') this.lastReferralDetails = loaded.details;
|
|
968
|
+
if (loaded.kind === 'signedOut') this.lastReferralDetails = null;
|
|
969
|
+
return loaded;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* The connected user's affiliate details and referral stats.
|
|
974
|
+
* Values are for display: grant anything valuable from your server.
|
|
975
|
+
* @returns The details, or null when this device has no referrer connected
|
|
976
|
+
* (or the request failed). A revoked connection is cleared.
|
|
977
|
+
*/
|
|
978
|
+
static async getMyAffiliateDetails(): Promise<MyAffiliateDetails | null> {
|
|
979
|
+
this.verboseLog('Getting my affiliate details...');
|
|
980
|
+
const loaded = await this.loadMyAffiliateDetails();
|
|
981
|
+
return loaded.kind === 'ok' ? loaded.details : null;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Whether this device has a referrer connected for this company.
|
|
986
|
+
* Local check only, no network.
|
|
987
|
+
*/
|
|
988
|
+
static async isUserAnAffiliate(): Promise<boolean> {
|
|
989
|
+
const companyId = await this.referralCompanyId();
|
|
990
|
+
return !!companyId && !!readReferrerToken(companyId);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/** Disconnects the referrer from this device (call on app logout). The affiliate account is kept. */
|
|
994
|
+
static async signOutAffiliate(): Promise<void> {
|
|
995
|
+
this.verboseLog('Signing out referrer on this device');
|
|
996
|
+
const companyId = await this.referralCompanyId();
|
|
997
|
+
if (companyId) clearReferrerToken(companyId);
|
|
998
|
+
this.lastReferralDetails = null;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/** The company's in-app referral settings (program on/off, copy, colour), or null if unavailable. */
|
|
1002
|
+
static async getReferralProgramConfig(): Promise<ReferralProgramConfig | null> {
|
|
1003
|
+
this.verboseLog('Getting referral program config...');
|
|
1004
|
+
const companyId = await this.referralCompanyId();
|
|
1005
|
+
if (!companyId) {
|
|
1006
|
+
this.verboseLog('Cannot get referral config: no company code available');
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
const config = await fetchReferralProgramConfig(companyId, (message) => this.verboseLog(message));
|
|
1010
|
+
if (config) this.lastReferralConfig = config;
|
|
1011
|
+
return config;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Shares the connected user's referral link with the system share sheet,
|
|
1016
|
+
* or copies it to the clipboard where sharing is unavailable.
|
|
1017
|
+
* Call from a click handler. Browsers only allow sharing and copying
|
|
1018
|
+
* shortly after the tap, so this never waits on the network: it uses the
|
|
1019
|
+
* details from the last getMyAffiliateDetails call and the app name from
|
|
1020
|
+
* the last getReferralProgramConfig call. Load both before the user taps.
|
|
1021
|
+
* Without loaded details it returns 'failed' and starts loading them, so a
|
|
1022
|
+
* later tap can share.
|
|
1023
|
+
* @param message Optional message. May use {link} and {code} placeholders.
|
|
1024
|
+
*/
|
|
1025
|
+
static async shareReferralLink(message?: string): Promise<ReferralShareOutcome> {
|
|
1026
|
+
this.verboseLog('Sharing referral link...');
|
|
1027
|
+
const details = this.lastReferralDetails;
|
|
1028
|
+
const config = this.lastReferralConfig;
|
|
1029
|
+
if (!config) void this.getReferralProgramConfig();
|
|
1030
|
+
if (!details) {
|
|
1031
|
+
this.verboseLog('Cannot share: referrer details not loaded. Call getMyAffiliateDetails before the tap');
|
|
1032
|
+
void this.getMyAffiliateDetails();
|
|
1033
|
+
return 'failed';
|
|
1034
|
+
}
|
|
1035
|
+
const text = buildReferralShareText(details, config ? config.companyName : '', message);
|
|
1036
|
+
const outcome = await shareText(text);
|
|
1037
|
+
this.verboseLog(`Share outcome: ${outcome}`);
|
|
1038
|
+
return outcome;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Presents the drop-in "Refer a friend" modal. Handles enrolment, the
|
|
1044
|
+
* email code step, sharing and stats. Browser only. Only one modal is
|
|
1045
|
+
* shown at a time: calling this while it is open focuses the open one.
|
|
1046
|
+
* @returns A handle whose close() dismisses the modal
|
|
1047
|
+
*/
|
|
1048
|
+
static showReferAFriend(options: ReferAFriendOptions = {}): ReferAFriendHandle {
|
|
1049
|
+
if (typeof document === 'undefined' || !document.body) {
|
|
1050
|
+
console.error('[Insert Affiliate] showReferAFriend needs a browser document.');
|
|
1051
|
+
return { close: () => undefined };
|
|
1052
|
+
}
|
|
1053
|
+
if (!this.companyCode) {
|
|
1054
|
+
this.verboseLog('showReferAFriend called before initialize; using the stored company code if any');
|
|
1055
|
+
}
|
|
1056
|
+
const account: ReferrerAccountOptions = {
|
|
1057
|
+
appUserId: options.appUserId,
|
|
1058
|
+
playPurchaseToken: options.playPurchaseToken,
|
|
1059
|
+
};
|
|
1060
|
+
return presentReferAFriend(options, {
|
|
1061
|
+
hasToken: () => this.isUserAnAffiliate(),
|
|
1062
|
+
loadConfig: () => this.getReferralProgramConfig(),
|
|
1063
|
+
loadDetails: () => this.loadMyAffiliateDetails(),
|
|
1064
|
+
enrol: (email, name) => this.createAffiliateForUser(email, name, account),
|
|
1065
|
+
verify: (email, code, name) => this.verifyAffiliateCode(email, code, name, account),
|
|
1066
|
+
saveAccount: () => this.setReferrerAccount(account),
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
|
|
766
1070
|
private static async getOrCreateUserID(): Promise<string> {
|
|
767
1071
|
this.verboseLog('Getting or creating user ID...');
|
|
768
1072
|
let id = await getValue('userId');
|