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.
- package/.github/workflows/publish.yml +28 -0
- package/CHANGELOG.md +19 -0
- package/README.md +175 -0
- package/dist/index.d.ts +262 -1
- package/dist/index.js +993 -0
- 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 +263 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// src/referrals/referralApi.ts
|
|
2
|
+
// HTTP calls and response parsing for the /V1/sdk/affiliate endpoints.
|
|
3
|
+
// The parsers are pure so they can be checked without a network.
|
|
4
|
+
import type {
|
|
5
|
+
AffiliateEnrolmentResult,
|
|
6
|
+
MyAffiliateDetails,
|
|
7
|
+
ReferralProgramConfig,
|
|
8
|
+
ReferralRewardCode,
|
|
9
|
+
ReferralShareOutcome,
|
|
10
|
+
ReferralTrigger,
|
|
11
|
+
ReferrerAffiliate,
|
|
12
|
+
} from './referralTypes';
|
|
13
|
+
import { clearReferrerToken, readReferrerToken, saveReferrerToken } from './referrerTokenStore';
|
|
14
|
+
|
|
15
|
+
const BASE_URL = 'https://api.insertaffiliate.com/V1/sdk/affiliate';
|
|
16
|
+
const TOKEN_HEADER = 'X-Insert-Affiliate-Token';
|
|
17
|
+
const PLATFORM = 'web';
|
|
18
|
+
const TRIGGERS: ReferralTrigger[] = ['install', 'event', 'purchase'];
|
|
19
|
+
|
|
20
|
+
export type ReferralLog = (message: string) => void;
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Parsing
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
const str = (value: unknown): string => (typeof value === 'string' ? value : '');
|
|
27
|
+
|
|
28
|
+
const num = (value: unknown): number => {
|
|
29
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
30
|
+
return Number.isFinite(n) ? n : 0;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const trigger = (value: unknown): ReferralTrigger =>
|
|
34
|
+
TRIGGERS.includes(value as ReferralTrigger) ? (value as ReferralTrigger) : 'purchase';
|
|
35
|
+
|
|
36
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
37
|
+
typeof value === 'object' && value !== null;
|
|
38
|
+
|
|
39
|
+
export const parseReferrerAffiliate = (raw: unknown): ReferrerAffiliate => {
|
|
40
|
+
const data = isObject(raw) ? raw : {};
|
|
41
|
+
return {
|
|
42
|
+
affiliateName: str(data.affiliateName),
|
|
43
|
+
affiliateShortCode: str(data.affiliateShortCode),
|
|
44
|
+
deeplinkurl: str(data.deeplinkurl),
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const parseRewardCodes = (value: unknown): ReferralRewardCode[] =>
|
|
49
|
+
(Array.isArray(value) ? value : [])
|
|
50
|
+
.filter(isObject)
|
|
51
|
+
.map((item) => ({
|
|
52
|
+
code: str(item.code),
|
|
53
|
+
redeemUrl: str(item.redeemUrl),
|
|
54
|
+
store: str(item.store) || 'app_store',
|
|
55
|
+
grantedAt: str(item.grantedAt),
|
|
56
|
+
}))
|
|
57
|
+
.filter((item) => item.code);
|
|
58
|
+
|
|
59
|
+
export const parseMyAffiliateDetails = (raw: unknown): MyAffiliateDetails => {
|
|
60
|
+
const data = isObject(raw) ? raw : {};
|
|
61
|
+
return {
|
|
62
|
+
...parseReferrerAffiliate(data),
|
|
63
|
+
referralTrigger: trigger(data.referralTrigger),
|
|
64
|
+
referralCount: num(data.referralCount),
|
|
65
|
+
installCount: num(data.installCount),
|
|
66
|
+
eventCount: num(data.eventCount),
|
|
67
|
+
purchaseCount: num(data.purchaseCount),
|
|
68
|
+
totalEarned: num(data.totalEarned),
|
|
69
|
+
totalPaid: num(data.totalPaid),
|
|
70
|
+
totalUnpaid: num(data.totalUnpaid),
|
|
71
|
+
currency: str(data.currency) || 'USD',
|
|
72
|
+
dashboardUrl: str(data.dashboardUrl),
|
|
73
|
+
rewardsGranted: num(data.rewardsGranted),
|
|
74
|
+
premiumUntil: str(data.premiumUntil) || null,
|
|
75
|
+
rewardCodes: parseRewardCodes(data.rewardCodes),
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const parseReferralProgramConfig = (raw: unknown): ReferralProgramConfig => {
|
|
80
|
+
const data = isObject(raw) ? raw : {};
|
|
81
|
+
const color = str(data.primaryColor);
|
|
82
|
+
return {
|
|
83
|
+
enabled: data.enabled === true,
|
|
84
|
+
companyName: str(data.companyName),
|
|
85
|
+
referralTrigger: trigger(data.referralTrigger),
|
|
86
|
+
headline: str(data.headline),
|
|
87
|
+
rewardText: str(data.rewardText),
|
|
88
|
+
primaryColor: /^#[0-9a-fA-F]{6}$/.test(color) ? color : '',
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const errorResult = (code: string, message: string): AffiliateEnrolmentResult => ({
|
|
93
|
+
status: 'error',
|
|
94
|
+
errorCode: code,
|
|
95
|
+
errorMessage: message,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Turns an enrol or verify response into a result. A token is only returned
|
|
100
|
+
* alongside `created` or `connected`; the caller stores it.
|
|
101
|
+
*/
|
|
102
|
+
export const parseEnrolmentResponse = (
|
|
103
|
+
httpStatus: number,
|
|
104
|
+
body: unknown
|
|
105
|
+
): { result: AffiliateEnrolmentResult; token: string | null } => {
|
|
106
|
+
const data = isObject(body) ? body : {};
|
|
107
|
+
|
|
108
|
+
if (httpStatus < 200 || httpStatus >= 300) {
|
|
109
|
+
return {
|
|
110
|
+
result: errorResult(
|
|
111
|
+
str(data.code) || `HTTP_${httpStatus}`,
|
|
112
|
+
str(data.error) || `Request failed with status ${httpStatus}.`
|
|
113
|
+
),
|
|
114
|
+
token: null,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (data.status === 'verificationRequired') {
|
|
119
|
+
return { result: { status: 'verificationRequired' }, token: null };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const token = str(data.token);
|
|
123
|
+
if ((data.status === 'created' || data.status === 'connected') && token) {
|
|
124
|
+
return {
|
|
125
|
+
result: { status: data.status, affiliate: parseReferrerAffiliate(data.affiliate) },
|
|
126
|
+
token,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { result: errorResult('NETWORK_ERROR', 'Unexpected response from the server.'), token: null };
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Built at runtime: the compile target (ES2017) predates regex property escapes.
|
|
134
|
+
const DECIMAL_DIGIT = new RegExp('\\p{Nd}', 'u');
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The emailed verification code as ASCII digits. Any Unicode decimal digit
|
|
138
|
+
* (Arabic-Indic, full-width and so on) becomes its 0-9 value; spaces, dashes
|
|
139
|
+
* and every other character are dropped.
|
|
140
|
+
*/
|
|
141
|
+
export const normalizeVerificationCode = (value: unknown): string => {
|
|
142
|
+
let out = '';
|
|
143
|
+
Array.from(String(value == null ? '' : value)).forEach((char) => {
|
|
144
|
+
if (char >= '0' && char <= '9') {
|
|
145
|
+
out += char;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (!DECIMAL_DIGIT.test(char)) return;
|
|
149
|
+
// Unicode encodes decimal digits in contiguous runs of whole 0-9 sets,
|
|
150
|
+
// so the value is the distance from the start of the run, mod 10.
|
|
151
|
+
const point = char.codePointAt(0) as number;
|
|
152
|
+
let start = point;
|
|
153
|
+
while (start > 0 && DECIMAL_DIGIT.test(String.fromCodePoint(start - 1))) start -= 1;
|
|
154
|
+
out += String((point - start) % 10);
|
|
155
|
+
});
|
|
156
|
+
return out;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Share text
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The text to share. A real link shares "<message> <link>" (default message
|
|
165
|
+
* "Try {companyName}:"). Short Code Only companies (the "link" is the code
|
|
166
|
+
* itself) share "Use my code {code} in {companyName}". An app message that
|
|
167
|
+
* contains `{link}` or `{code}` is used as is with those filled in.
|
|
168
|
+
* Returns an empty string when there is nothing to share.
|
|
169
|
+
*/
|
|
170
|
+
export const buildReferralShareText = (
|
|
171
|
+
affiliate: Pick<ReferrerAffiliate, 'affiliateShortCode' | 'deeplinkurl'>,
|
|
172
|
+
companyName: string,
|
|
173
|
+
message?: string
|
|
174
|
+
): string => {
|
|
175
|
+
const code = affiliate.affiliateShortCode || '';
|
|
176
|
+
const hasLink = /^http/i.test(affiliate.deeplinkurl || '');
|
|
177
|
+
const link = hasLink ? affiliate.deeplinkurl : code;
|
|
178
|
+
const appName = companyName || 'the app';
|
|
179
|
+
const custom = (message || '').trim();
|
|
180
|
+
|
|
181
|
+
if (!link) return '';
|
|
182
|
+
|
|
183
|
+
if (custom && /\{link\}|\{code\}/.test(custom)) {
|
|
184
|
+
return custom.replace(/\{link\}/g, link).replace(/\{code\}/g, code);
|
|
185
|
+
}
|
|
186
|
+
if (hasLink) {
|
|
187
|
+
return `${custom || `Try ${companyName || 'this app'}:`} ${link}`;
|
|
188
|
+
}
|
|
189
|
+
return custom ? `${custom} ${code}` : `Use my code ${code} in ${appName}`;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// HTTP
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
const readJson = async (response: Response): Promise<unknown> => {
|
|
197
|
+
try {
|
|
198
|
+
return await response.json();
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** Drops empty and missing values, so optional fields are only sent when set. */
|
|
205
|
+
export const withoutEmpty = (payload: Record<string, string | null | undefined>): Record<string, string> => {
|
|
206
|
+
const out: Record<string, string> = {};
|
|
207
|
+
Object.keys(payload).forEach((key) => {
|
|
208
|
+
const value = payload[key];
|
|
209
|
+
if (typeof value === 'string' && value.trim()) out[key] = value.trim();
|
|
210
|
+
});
|
|
211
|
+
return out;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const networkError = (): AffiliateEnrolmentResult =>
|
|
215
|
+
errorResult('NETWORK_ERROR', 'Could not reach Insert Affiliate. Check the connection and try again.');
|
|
216
|
+
|
|
217
|
+
/** GET /config/{companyId}. Null when the request fails. */
|
|
218
|
+
export const fetchReferralProgramConfig = async (
|
|
219
|
+
companyId: string,
|
|
220
|
+
log: ReferralLog
|
|
221
|
+
): Promise<ReferralProgramConfig | null> => {
|
|
222
|
+
const url = `${BASE_URL}/config/${encodeURIComponent(companyId)}`;
|
|
223
|
+
log(`Making API call to: ${url}`);
|
|
224
|
+
try {
|
|
225
|
+
const response = await fetch(url);
|
|
226
|
+
log(`Referral config response status: ${response.status}`);
|
|
227
|
+
if (!response.ok) return null;
|
|
228
|
+
return parseReferralProgramConfig(await readJson(response));
|
|
229
|
+
} catch (error) {
|
|
230
|
+
log(`Error fetching referral config: ${error}`);
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/** POST /enrol or /verify, storing the token on success. Never logs the body (it holds the token). */
|
|
236
|
+
export const postEnrolment = async (
|
|
237
|
+
path: 'enrol' | 'verify',
|
|
238
|
+
companyId: string,
|
|
239
|
+
payload: Record<string, string | undefined>,
|
|
240
|
+
log: ReferralLog
|
|
241
|
+
): Promise<AffiliateEnrolmentResult> => {
|
|
242
|
+
const url = `${BASE_URL}/${path}`;
|
|
243
|
+
log(`Making API call to: ${url}`);
|
|
244
|
+
try {
|
|
245
|
+
const response = await fetch(url, {
|
|
246
|
+
method: 'POST',
|
|
247
|
+
headers: { 'Content-Type': 'application/json' },
|
|
248
|
+
body: JSON.stringify({ companyId, platform: PLATFORM, ...withoutEmpty(payload) }),
|
|
249
|
+
});
|
|
250
|
+
log(`Referral ${path} response status: ${response.status}`);
|
|
251
|
+
|
|
252
|
+
const { result, token } = parseEnrolmentResponse(response.status, await readJson(response));
|
|
253
|
+
if (token && !saveReferrerToken(companyId, token)) {
|
|
254
|
+
log('localStorage unavailable; referrer token kept for this page load only');
|
|
255
|
+
}
|
|
256
|
+
log(`Referral ${path} result: ${result.status}${result.errorCode ? ` (${result.errorCode})` : ''}`);
|
|
257
|
+
return result;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
log(`Network error during referral ${path}: ${error}`);
|
|
260
|
+
return networkError();
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* True when the server says the token itself no longer works: 401
|
|
266
|
+
* INVALID_TOKEN or 404 AFFILIATE_NOT_FOUND. Other 401/404 responses (a proxy,
|
|
267
|
+
* a wrong URL) leave the token alone.
|
|
268
|
+
*/
|
|
269
|
+
export const isRevokedTokenResponse = (httpStatus: number, body: unknown): boolean => {
|
|
270
|
+
const code = isObject(body) ? str(body.code) : '';
|
|
271
|
+
return (httpStatus === 401 && code === 'INVALID_TOKEN') || (httpStatus === 404 && code === 'AFFILIATE_NOT_FOUND');
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Clears the stored token after the server revoked `sentToken`, unless another
|
|
276
|
+
* request has already stored a newer one.
|
|
277
|
+
*/
|
|
278
|
+
const clearRevokedToken = (companyId: string, sentToken: string, log: ReferralLog): void => {
|
|
279
|
+
if (readReferrerToken(companyId) !== sentToken) {
|
|
280
|
+
log('Referrer token no longer valid; a newer token is stored, keeping it');
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
clearReferrerToken(companyId);
|
|
284
|
+
log('Referrer token no longer valid; cleared');
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
export type MyDetailsFetch =
|
|
288
|
+
| { kind: 'ok'; details: MyAffiliateDetails }
|
|
289
|
+
| { kind: 'signedOut' }
|
|
290
|
+
| { kind: 'failed' };
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* GET /me with the device token. A revoked token (401 INVALID_TOKEN or
|
|
294
|
+
* 404 AFFILIATE_NOT_FOUND) is cleared and the user counts as signed out.
|
|
295
|
+
*/
|
|
296
|
+
export const fetchMyAffiliateDetails = async (
|
|
297
|
+
companyId: string,
|
|
298
|
+
token: string,
|
|
299
|
+
log: ReferralLog
|
|
300
|
+
): Promise<MyDetailsFetch> => {
|
|
301
|
+
const url = `${BASE_URL}/me`;
|
|
302
|
+
log(`Making API call to: ${url}`);
|
|
303
|
+
try {
|
|
304
|
+
const response = await fetch(url, { headers: { [TOKEN_HEADER]: token } });
|
|
305
|
+
log(`Referral details response status: ${response.status}`);
|
|
306
|
+
|
|
307
|
+
const body = await readJson(response);
|
|
308
|
+
if (isRevokedTokenResponse(response.status, body)) {
|
|
309
|
+
clearRevokedToken(companyId, token, log);
|
|
310
|
+
return { kind: 'signedOut' };
|
|
311
|
+
}
|
|
312
|
+
if (!response.ok || !isObject(body)) return { kind: 'failed' };
|
|
313
|
+
return { kind: 'ok', details: parseMyAffiliateDetails(body) };
|
|
314
|
+
} catch (error) {
|
|
315
|
+
log(`Error fetching referral details: ${error}`);
|
|
316
|
+
return { kind: 'failed' };
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* POST /me/identity with the device token: saves the referrer's own accounts
|
|
322
|
+
* (app user id, Play purchase token, device id). A revoked token is cleared
|
|
323
|
+
* like fetchMyAffiliateDetails. True when the server saved them.
|
|
324
|
+
*/
|
|
325
|
+
export const postReferrerIdentity = async (
|
|
326
|
+
companyId: string,
|
|
327
|
+
token: string,
|
|
328
|
+
payload: Record<string, string | null | undefined>,
|
|
329
|
+
log: ReferralLog
|
|
330
|
+
): Promise<boolean> => {
|
|
331
|
+
const url = `${BASE_URL}/me/identity`;
|
|
332
|
+
log(`Making API call to: ${url}`);
|
|
333
|
+
try {
|
|
334
|
+
const response = await fetch(url, {
|
|
335
|
+
method: 'POST',
|
|
336
|
+
headers: { 'Content-Type': 'application/json', [TOKEN_HEADER]: token },
|
|
337
|
+
body: JSON.stringify(withoutEmpty(payload)),
|
|
338
|
+
});
|
|
339
|
+
log(`Referrer identity response status: ${response.status}`);
|
|
340
|
+
|
|
341
|
+
const body = await readJson(response);
|
|
342
|
+
if (isRevokedTokenResponse(response.status, body)) {
|
|
343
|
+
clearRevokedToken(companyId, token, log);
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
return response.ok && isObject(body) && body.saved === true;
|
|
347
|
+
} catch (error) {
|
|
348
|
+
log(`Error saving referrer identity: ${error}`);
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Sharing (browser)
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
export const copyText = async (text: string): Promise<boolean> => {
|
|
358
|
+
try {
|
|
359
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
|
|
360
|
+
await navigator.clipboard.writeText(text);
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
} catch {
|
|
364
|
+
// Clipboard blocked (no permission or no user gesture).
|
|
365
|
+
}
|
|
366
|
+
return false;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Opens the system share sheet, or copies the text when sharing is not
|
|
371
|
+
* available. Only the share sheet is used: no contacts access.
|
|
372
|
+
*/
|
|
373
|
+
export const shareText = async (text: string): Promise<ReferralShareOutcome> => {
|
|
374
|
+
if (!text) return 'failed';
|
|
375
|
+
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
|
|
376
|
+
try {
|
|
377
|
+
await navigator.share({ text });
|
|
378
|
+
return 'shared';
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (error && (error as { name?: string }).name === 'AbortError') return 'cancelled';
|
|
381
|
+
// Share sheet refused (for example no user gesture); fall back to copying.
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return (await copyText(text)) ? 'copied' : 'failed';
|
|
385
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// src/referrals/referralStrings.ts
|
|
2
|
+
// The English text the "Refer a friend" modal shows, and the merge that lets
|
|
3
|
+
// an app translate or reword any of it. Headline and reward text are not here:
|
|
4
|
+
// they come from the portal and from the modal's own options.
|
|
5
|
+
import type { ReferralStrings } from './referralTypes';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_REFERRAL_STRINGS: ReferralStrings = {
|
|
8
|
+
// Joining
|
|
9
|
+
emailLabel: 'Email',
|
|
10
|
+
nameLabel: 'Name',
|
|
11
|
+
joinButton: 'Get my link',
|
|
12
|
+
joiningButton: 'Please wait...',
|
|
13
|
+
|
|
14
|
+
// Email code step
|
|
15
|
+
codeLabel: 'Code',
|
|
16
|
+
codeSentNotice: 'We sent a 6-digit code to {email}. Enter it below to connect this device.',
|
|
17
|
+
verifyButton: 'Verify',
|
|
18
|
+
verifyingButton: 'Verifying...',
|
|
19
|
+
resendButton: 'Send a new code',
|
|
20
|
+
sendingNotice: 'Sending...',
|
|
21
|
+
codeResentNotice: 'We sent a new code.',
|
|
22
|
+
differentEmailButton: 'Use a different email',
|
|
23
|
+
errorCodeLength: 'Enter the 6-digit code from the email.',
|
|
24
|
+
|
|
25
|
+
// Joined
|
|
26
|
+
copyCodeButton: 'Copy code',
|
|
27
|
+
copyLinkButton: 'Copy link',
|
|
28
|
+
copiedNotice: 'Copied',
|
|
29
|
+
copyFailedNotice: 'Could not copy. Select the text to copy it.',
|
|
30
|
+
shareButton: 'Share',
|
|
31
|
+
shareFailedNotice: 'Could not share. Copy your code instead.',
|
|
32
|
+
referralsLabel: 'Referrals',
|
|
33
|
+
earnedLabel: 'Earned',
|
|
34
|
+
premiumUntil: 'Free premium until {date}',
|
|
35
|
+
rewardsHeading: 'Your rewards',
|
|
36
|
+
redeemButton: 'Redeem',
|
|
37
|
+
dashboardLink: 'Open my dashboard',
|
|
38
|
+
|
|
39
|
+
// Frame and states
|
|
40
|
+
closeButton: 'Close',
|
|
41
|
+
loading: 'Loading...',
|
|
42
|
+
tryAgainButton: 'Try again',
|
|
43
|
+
|
|
44
|
+
// Errors, by the server's error code
|
|
45
|
+
errorProgramDisabled: 'Referrals are not available in this app right now.',
|
|
46
|
+
errorAffiliateLimitReached: 'The referral program is full right now. Please try again later.',
|
|
47
|
+
errorInvalidCode: 'That code is wrong or has expired. Check it or send a new code.',
|
|
48
|
+
errorTooManyCodes: 'Too many codes have been sent. Please wait a while and try again.',
|
|
49
|
+
errorRateLimited: 'Too many attempts. Please try again later.',
|
|
50
|
+
errorInvalidEmail: 'Please enter a valid email address.',
|
|
51
|
+
errorNetwork: 'Could not connect. Check your connection and try again.',
|
|
52
|
+
errorServer: 'Something went wrong. Please try again.',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The error string for a server error code, or the generic one. */
|
|
56
|
+
export const ERROR_STRING_KEYS: Record<string, keyof ReferralStrings> = {
|
|
57
|
+
PROGRAM_DISABLED: 'errorProgramDisabled',
|
|
58
|
+
AFFILIATE_LIMIT_REACHED: 'errorAffiliateLimitReached',
|
|
59
|
+
INVALID_CODE: 'errorInvalidCode',
|
|
60
|
+
TOO_MANY_CODES: 'errorTooManyCodes',
|
|
61
|
+
RATE_LIMITED: 'errorRateLimited',
|
|
62
|
+
INVALID_EMAIL: 'errorInvalidEmail',
|
|
63
|
+
NETWORK_ERROR: 'errorNetwork',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The app's overrides on top of the English defaults. A missing, blank or
|
|
68
|
+
* non-string value keeps the default, and an unknown key is ignored.
|
|
69
|
+
*/
|
|
70
|
+
export const resolveReferralStrings = (overrides?: Partial<ReferralStrings>): ReferralStrings => {
|
|
71
|
+
const resolved: ReferralStrings = { ...DEFAULT_REFERRAL_STRINGS };
|
|
72
|
+
if (!overrides) return resolved;
|
|
73
|
+
(Object.keys(DEFAULT_REFERRAL_STRINGS) as (keyof ReferralStrings)[]).forEach((key) => {
|
|
74
|
+
const value = overrides[key];
|
|
75
|
+
if (typeof value === 'string' && value.trim()) resolved[key] = value;
|
|
76
|
+
});
|
|
77
|
+
return resolved;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Fills `{name}` placeholders, leaving any the app did not use alone. */
|
|
81
|
+
export const fillPlaceholders = (text: string, values: Record<string, string>): string =>
|
|
82
|
+
Object.keys(values).reduce(
|
|
83
|
+
(out, name) => out.split(`{${name}}`).join(values[name]),
|
|
84
|
+
text
|
|
85
|
+
);
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// src/referrals/referralTypes.ts
|
|
2
|
+
// Types for in-app referrals: turning an app's own user into an affiliate,
|
|
3
|
+
// reading their referral stats and presenting the "Refer a friend" modal.
|
|
4
|
+
|
|
5
|
+
/** What the company counts as a referral (set in the Insert Affiliate portal). */
|
|
6
|
+
export type ReferralTrigger = 'install' | 'event' | 'purchase';
|
|
7
|
+
|
|
8
|
+
/** The referrer's own affiliate record, as returned by enrol and verify. */
|
|
9
|
+
export interface ReferrerAffiliate {
|
|
10
|
+
affiliateName: string;
|
|
11
|
+
affiliateShortCode: string;
|
|
12
|
+
/** The link to share. For Short Code Only companies this is the short code itself; empty when no link is assigned yet. */
|
|
13
|
+
deeplinkurl: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The referrer's affiliate record plus referral stats. Values are for display only. */
|
|
17
|
+
export interface MyAffiliateDetails extends ReferrerAffiliate {
|
|
18
|
+
referralTrigger: ReferralTrigger;
|
|
19
|
+
/** The count for the company's configured trigger. Only ever goes up. */
|
|
20
|
+
referralCount: number;
|
|
21
|
+
installCount: number;
|
|
22
|
+
eventCount: number;
|
|
23
|
+
purchaseCount: number;
|
|
24
|
+
totalEarned: number;
|
|
25
|
+
totalPaid: number;
|
|
26
|
+
totalUnpaid: number;
|
|
27
|
+
currency: string;
|
|
28
|
+
dashboardUrl: string;
|
|
29
|
+
/** How many referral rewards this user has been granted. */
|
|
30
|
+
rewardsGranted: number;
|
|
31
|
+
/** ISO date the user's free premium from referrals runs until, or null. */
|
|
32
|
+
premiumUntil: string | null;
|
|
33
|
+
/** App Store offer codes or Google Play promo codes granted as rewards, newest first. */
|
|
34
|
+
rewardCodes: ReferralRewardCode[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A referral reward code: an App Store offer code or a Google Play promo code. */
|
|
38
|
+
export interface ReferralRewardCode {
|
|
39
|
+
code: string;
|
|
40
|
+
/** Opens the store's redemption page with the code filled in. */
|
|
41
|
+
redeemUrl: string;
|
|
42
|
+
/** Which store the code is for. Older servers don't send it; those are App Store codes. */
|
|
43
|
+
store: 'app_store' | 'google_play' | string;
|
|
44
|
+
/** ISO date the code was granted. */
|
|
45
|
+
grantedAt: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The referrer's own accounts, so rewards can be granted and a referrer
|
|
50
|
+
* cannot count as their own referral. Supply whichever the app uses.
|
|
51
|
+
*/
|
|
52
|
+
export interface ReferrerAccountOptions {
|
|
53
|
+
/** The user's RevenueCat app user id or Adapty customer user id. */
|
|
54
|
+
appUserId?: string;
|
|
55
|
+
/** The user's own Google Play subscription purchase token. */
|
|
56
|
+
playPurchaseToken?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Program on/off plus the drop-in UI copy and colour configured in the portal. */
|
|
60
|
+
export interface ReferralProgramConfig {
|
|
61
|
+
enabled: boolean;
|
|
62
|
+
companyName: string;
|
|
63
|
+
referralTrigger: ReferralTrigger;
|
|
64
|
+
headline: string;
|
|
65
|
+
rewardText: string;
|
|
66
|
+
/** `#RRGGBB`, or empty when the company has not set one. */
|
|
67
|
+
primaryColor: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type AffiliateEnrolmentStatus = 'created' | 'connected' | 'verificationRequired' | 'error';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Server error codes, plus two raised by the SDK itself:
|
|
74
|
+
* `NETWORK_ERROR` (request failed or the response was unreadable) and
|
|
75
|
+
* `NOT_INITIALIZED` (no company code; call `initialize` first).
|
|
76
|
+
*/
|
|
77
|
+
export type ReferralErrorCode =
|
|
78
|
+
| 'INVALID_EMAIL'
|
|
79
|
+
| 'INVALID_COMPANY_ID'
|
|
80
|
+
| 'INVALID_CODE'
|
|
81
|
+
| 'PROGRAM_DISABLED'
|
|
82
|
+
| 'AFFILIATE_LIMIT_REACHED'
|
|
83
|
+
| 'COMPANY_NOT_FOUND'
|
|
84
|
+
| 'TOO_MANY_CODES'
|
|
85
|
+
| 'RATE_LIMITED'
|
|
86
|
+
| 'NETWORK_ERROR'
|
|
87
|
+
| 'NOT_INITIALIZED'
|
|
88
|
+
| (string & {});
|
|
89
|
+
|
|
90
|
+
export interface AffiliateEnrolmentResult {
|
|
91
|
+
status: AffiliateEnrolmentStatus;
|
|
92
|
+
affiliate?: ReferrerAffiliate;
|
|
93
|
+
errorCode?: ReferralErrorCode;
|
|
94
|
+
errorMessage?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** How a share ended: the share sheet completed, the text was copied instead, the user dismissed the sheet, or neither was possible. */
|
|
98
|
+
export type ReferralShareOutcome = 'shared' | 'copied' | 'cancelled' | 'failed';
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Every label the "Refer a friend" modal shows, so an app can translate or
|
|
102
|
+
* reword it. Pass only the keys you want to change: the rest keep the English
|
|
103
|
+
* defaults, and a blank value keeps the default too. Keep the `{email}` and
|
|
104
|
+
* `{date}` placeholders in the strings that have them.
|
|
105
|
+
*/
|
|
106
|
+
export interface ReferralStrings {
|
|
107
|
+
/** "Email" */
|
|
108
|
+
emailLabel: string;
|
|
109
|
+
/** "Name" */
|
|
110
|
+
nameLabel: string;
|
|
111
|
+
/** "Get my link" */
|
|
112
|
+
joinButton: string;
|
|
113
|
+
/** "Please wait..." while joining */
|
|
114
|
+
joiningButton: string;
|
|
115
|
+
/** "Code" */
|
|
116
|
+
codeLabel: string;
|
|
117
|
+
/** "We sent a 6-digit code to {email}. Enter it below to connect this device." */
|
|
118
|
+
codeSentNotice: string;
|
|
119
|
+
/** "Verify" */
|
|
120
|
+
verifyButton: string;
|
|
121
|
+
/** "Verifying..." while the code is checked */
|
|
122
|
+
verifyingButton: string;
|
|
123
|
+
/** "Send a new code" */
|
|
124
|
+
resendButton: string;
|
|
125
|
+
/** "Sending..." while a new code is sent */
|
|
126
|
+
sendingNotice: string;
|
|
127
|
+
/** "We sent a new code." */
|
|
128
|
+
codeResentNotice: string;
|
|
129
|
+
/** "Use a different email" */
|
|
130
|
+
differentEmailButton: string;
|
|
131
|
+
/** "Enter the 6-digit code from the email." */
|
|
132
|
+
errorCodeLength: string;
|
|
133
|
+
/** "Copy code" */
|
|
134
|
+
copyCodeButton: string;
|
|
135
|
+
/** "Copy link" */
|
|
136
|
+
copyLinkButton: string;
|
|
137
|
+
/** "Copied" */
|
|
138
|
+
copiedNotice: string;
|
|
139
|
+
/** "Could not copy. Select the text to copy it." */
|
|
140
|
+
copyFailedNotice: string;
|
|
141
|
+
/** "Share" */
|
|
142
|
+
shareButton: string;
|
|
143
|
+
/** "Could not share. Copy your code instead." */
|
|
144
|
+
shareFailedNotice: string;
|
|
145
|
+
/** "Referrals" */
|
|
146
|
+
referralsLabel: string;
|
|
147
|
+
/** "Earned" */
|
|
148
|
+
earnedLabel: string;
|
|
149
|
+
/** "Free premium until {date}" */
|
|
150
|
+
premiumUntil: string;
|
|
151
|
+
/** "Your rewards" */
|
|
152
|
+
rewardsHeading: string;
|
|
153
|
+
/** "Redeem" */
|
|
154
|
+
redeemButton: string;
|
|
155
|
+
/** "Open my dashboard" */
|
|
156
|
+
dashboardLink: string;
|
|
157
|
+
/** The close button's accessible name, "Close" */
|
|
158
|
+
closeButton: string;
|
|
159
|
+
/** "Loading..." */
|
|
160
|
+
loading: string;
|
|
161
|
+
/** "Try again" */
|
|
162
|
+
tryAgainButton: string;
|
|
163
|
+
/** PROGRAM_DISABLED */
|
|
164
|
+
errorProgramDisabled: string;
|
|
165
|
+
/** AFFILIATE_LIMIT_REACHED */
|
|
166
|
+
errorAffiliateLimitReached: string;
|
|
167
|
+
/** INVALID_CODE */
|
|
168
|
+
errorInvalidCode: string;
|
|
169
|
+
/** TOO_MANY_CODES */
|
|
170
|
+
errorTooManyCodes: string;
|
|
171
|
+
/** RATE_LIMITED */
|
|
172
|
+
errorRateLimited: string;
|
|
173
|
+
/** INVALID_EMAIL */
|
|
174
|
+
errorInvalidEmail: string;
|
|
175
|
+
/** NETWORK_ERROR, and any unreadable response */
|
|
176
|
+
errorNetwork: string;
|
|
177
|
+
/** Every other error, "Something went wrong. Please try again." */
|
|
178
|
+
errorServer: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface ReferAFriendOptions {
|
|
182
|
+
/** Prefills the email field (usually the app's logged-in user). */
|
|
183
|
+
email?: string;
|
|
184
|
+
/** Prefills the name field. */
|
|
185
|
+
name?: string;
|
|
186
|
+
/** Share message. May use `{link}` and `{code}` placeholders. */
|
|
187
|
+
shareMessage?: string;
|
|
188
|
+
/** Overrides the portal colour. Any CSS colour. */
|
|
189
|
+
primaryColor?: string;
|
|
190
|
+
/** Overrides the portal headline. */
|
|
191
|
+
headline?: string;
|
|
192
|
+
/** Overrides the portal reward text. */
|
|
193
|
+
rewardText?: string;
|
|
194
|
+
/** CSS font-family for the modal. Defaults to the system font stack. */
|
|
195
|
+
fontFamily?: string;
|
|
196
|
+
/** Corner radius of the modal and its controls, in pixels. Defaults to 12. */
|
|
197
|
+
cornerRadius?: number;
|
|
198
|
+
/** The user's RevenueCat app user id or Adapty customer user id, used to grant their referral rewards. */
|
|
199
|
+
appUserId?: string;
|
|
200
|
+
/** The user's own Google Play subscription purchase token (Android). */
|
|
201
|
+
playPurchaseToken?: string;
|
|
202
|
+
/** Replaces any of the modal's labels, for translating or rewording it. */
|
|
203
|
+
strings?: Partial<ReferralStrings>;
|
|
204
|
+
/** Called once after the modal closes, however it was closed. */
|
|
205
|
+
onClose?: () => void;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface ReferAFriendHandle {
|
|
209
|
+
/** Closes the modal. Safe to call more than once. */
|
|
210
|
+
close(): void;
|
|
211
|
+
}
|