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.
@@ -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 { generateUUID, generateShortDeviceID } from '../utils/helpers';
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 affiliateDetails = await this.getAffiliateDetails(shortCode, { trackUsage: true });
214
- if (!affiliateDetails) {
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
- * This method queries the API and does not store or set the affiliate identifier
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 AffiliateDetails if found, null otherwise
353
+ * @returns an AffiliateLookupResult with a status of 'found', 'not_found', 'lookup_failed', or 'not_configured'
311
354
  */
312
- static async getAffiliateDetails(affiliateCode: string, options?: { trackUsage?: boolean }): Promise<AffiliateDetails | null> {
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 && data.affiliate) {
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 offerCode = (await response.text()).replace(/[^a-zA-Z0-9]/g, '');
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
- if (errorCodes.includes(offerCode)) {
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 offerCode = (await response.text()).replace(/[^a-zA-Z0-9]/g, '');
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
- if (errorCodes.includes(offerCode)) {
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 offerCode = (await res.text()).replace(/[^a-zA-Z0-9]/g, '');
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
- if (errorCodes.includes(offerCode)) {
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');
@@ -1,10 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "WebFetch(domain:github.com)",
5
- "WebFetch(domain:www.revenuecat.com)"
6
- ],
7
- "deny": [],
8
- "ask": []
9
- }
10
- }