insert-affiliate-js-sdk 1.4.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
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;
@@ -44,6 +65,11 @@ export class InsertAffiliate {
44
65
  private static affiliateAttributionActiveTime: number | null = null; // in milliseconds
45
66
  private static preventAffiliateTransfer: boolean = false;
46
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;
47
73
 
48
74
  private static verboseLog(message: string): void {
49
75
  if (this.verboseLogging) {
@@ -804,6 +830,243 @@ export class InsertAffiliate {
804
830
  }
805
831
  }
806
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
+
807
1070
  private static async getOrCreateUserID(): Promise<string> {
808
1071
  this.verboseLog('Getting or creating user ID...');
809
1072
  let id = await getValue('userId');