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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insert-affiliate-js-sdk",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,8 +12,8 @@
12
12
  },
13
13
  "scripts": {
14
14
  "build": "tsup",
15
- "dev": "tsup --watch",
16
- "prepare": "npm run build"
15
+ "dev": "tsup --watch",
16
+ "prepare": "npm run build"
17
17
  },
18
18
  "keywords": [],
19
19
  "author": "",
@@ -21,5 +21,9 @@
21
21
  "devDependencies": {
22
22
  "tsup": "^8.4.0",
23
23
  "typescript": "^5.8.2"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/Insert-Affiliate/insert-affiliate-js-sdk.git"
24
28
  }
25
29
  }
package/src/index.ts CHANGED
@@ -1 +1,16 @@
1
- export * from './sdk/InsertAffiliate';
1
+ export * from './sdk/InsertAffiliate';
2
+ export type {
3
+ AffiliateEnrolmentResult,
4
+ AffiliateEnrolmentStatus,
5
+ MyAffiliateDetails,
6
+ ReferAFriendHandle,
7
+ ReferAFriendOptions,
8
+ ReferralErrorCode,
9
+ ReferralProgramConfig,
10
+ ReferralRewardCode,
11
+ ReferralShareOutcome,
12
+ ReferralStrings,
13
+ ReferralTrigger,
14
+ ReferrerAccountOptions,
15
+ ReferrerAffiliate,
16
+ } from './referrals/referralTypes';
@@ -0,0 +1,534 @@
1
+ // src/referrals/referAFriendModal.ts
2
+ // The drop-in "Refer a friend" modal: plain DOM, a scoped <style>, no
3
+ // dependencies. Every server or user value goes in through textContent or
4
+ // an input's value, never innerHTML.
5
+ import type {
6
+ AffiliateEnrolmentResult,
7
+ MyAffiliateDetails,
8
+ ReferAFriendHandle,
9
+ ReferAFriendOptions,
10
+ ReferralProgramConfig,
11
+ ReferralStrings,
12
+ ReferrerAffiliate,
13
+ } from './referralTypes';
14
+ import { buildReferralShareText, copyText, MyDetailsFetch, normalizeVerificationCode, shareText } from './referralApi';
15
+ import { ERROR_STRING_KEYS, fillPlaceholders, resolveReferralStrings } from './referralStrings';
16
+
17
+ /** What the modal needs from the SDK. Passed in so the modal holds no SDK state. */
18
+ export interface ReferAFriendDeps {
19
+ hasToken(): Promise<boolean>;
20
+ loadConfig(): Promise<ReferralProgramConfig | null>;
21
+ loadDetails(): Promise<MyDetailsFetch>;
22
+ enrol(email: string, name: string): Promise<AffiliateEnrolmentResult>;
23
+ verify(email: string, code: string, name: string): Promise<AffiliateEnrolmentResult>;
24
+ /** Saves the referrer's own accounts for an already connected user. */
25
+ saveAccount(): Promise<boolean>;
26
+ }
27
+
28
+ const DEFAULT_COLOR = '#6A0DAD';
29
+ const DEFAULT_HEADLINE = 'Refer a friend';
30
+ const DEFAULT_FONT =
31
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
32
+
33
+ const STYLE = `
34
+ .ia-raf-overlay{position:fixed;inset:0;z-index:2147483000;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box}
35
+ .ia-raf-dialog{position:relative;width:100%;max-width:420px;max-height:calc(100vh - 32px);overflow:auto;background:#fff;color:#1a1a1a;border-radius:var(--ia-raf-radius);box-shadow:0 12px 40px rgba(0,0,0,.25);padding:24px;box-sizing:border-box;font-family:var(--ia-raf-font);font-size:15px;line-height:1.45;text-align:left}
36
+ .ia-raf-dialog *{box-sizing:border-box}
37
+ .ia-raf-title{margin:0 36px 8px 0;font-size:20px;font-weight:700;line-height:1.3}
38
+ .ia-raf-reward{margin:0 0 16px;color:#444}
39
+ .ia-raf-close{position:absolute;margin:0;top:19px;right:14px;width:36px;height:36px;display:flex;align-items:center;justify-content:center;padding:0 0 2px;border:0;border-radius:50%;background:transparent;color:#555;font-size:26px;line-height:1;cursor:pointer}
40
+ .ia-raf-close:hover{background:#f0f0f3}
41
+ .ia-raf-label{display:block;margin:0 0 4px;font-size:13px;font-weight:600}
42
+ .ia-raf-input{display:block;width:100%;margin:0 0 12px;padding:10px 12px;border:1px solid #c8c8cc;border-radius:calc(var(--ia-raf-radius) * .66);background:#fff;color:#1a1a1a;font:inherit}
43
+ .ia-raf-dialog :focus-visible{outline:2px solid var(--ia-raf-primary);outline-offset:2px}
44
+ .ia-raf-btn{display:block;width:100%;margin:8px 0 0;padding:12px;border:1px solid var(--ia-raf-primary);border-radius:calc(var(--ia-raf-radius) * .66);background:var(--ia-raf-primary);color:#fff;font:inherit;font-weight:600;cursor:pointer}
45
+ .ia-raf-btn[disabled]{opacity:.6;cursor:default}
46
+ .ia-raf-btn-secondary{background:transparent;color:var(--ia-raf-primary)}
47
+ .ia-raf-textbtn{display:inline-block;margin:8px 16px 0 0;padding:4px 0;border:0;background:none;color:var(--ia-raf-primary);font:inherit;font-size:14px;text-decoration:underline;cursor:pointer}
48
+ .ia-raf-error{margin:0 0 12px;padding:8px 12px;border-radius:calc(var(--ia-raf-radius) * .5);background:#fdecee;color:#b00020;font-size:14px}
49
+ .ia-raf-error:empty{display:none}
50
+ .ia-raf-muted{margin:0 0 12px;color:#555;font-size:14px}
51
+ .ia-raf-row{display:flex;align-items:center;gap:8px;margin:0 0 8px;padding:10px 12px;border:1px dashed var(--ia-raf-primary);border-radius:calc(var(--ia-raf-radius) * .66)}
52
+ .ia-raf-code{flex:1;min-width:0;font-size:20px;font-weight:700;letter-spacing:1px;word-break:break-all}
53
+ .ia-raf-url{flex:1;min-width:0;font-size:13px;color:#444;word-break:break-all}
54
+ .ia-raf-copy{flex:none;margin:0;padding:6px 10px;border:1px solid var(--ia-raf-primary);border-radius:calc(var(--ia-raf-radius) * .5);background:transparent;color:var(--ia-raf-primary);font:inherit;font-size:13px;font-weight:600;cursor:pointer}
55
+ .ia-raf-stats{display:flex;gap:12px;margin:16px 0 8px}
56
+ .ia-raf-stat{flex:1;padding:12px;border-radius:calc(var(--ia-raf-radius) * .66);background:#f4f4f7;text-align:center}
57
+ .ia-raf-stat-value{display:block;font-size:20px;font-weight:700}
58
+ .ia-raf-stat-label{display:block;color:#666;font-size:12px}
59
+ .ia-raf-premium{margin:8px 0;padding:10px 12px;border-radius:calc(var(--ia-raf-radius) * .66);background:#eef7ef;color:#1b5e20;font-size:14px;font-weight:600;text-align:center}
60
+ .ia-raf-rewards-title{margin:16px 0 8px;font-size:15px;font-weight:700}
61
+ .ia-raf-rewards{margin:0;padding:0;list-style:none}
62
+ .ia-raf-reward-code{flex:1;min-width:0;font-size:15px;font-weight:600;letter-spacing:.5px;word-break:break-all}
63
+ .ia-raf-status{min-height:20px;margin:8px 0 0;color:#2e7d32;font-size:13px;text-align:center}
64
+ `;
65
+
66
+ let instanceCount = 0;
67
+
68
+ // The open modal, if any. Only one is shown at a time.
69
+ let openModal: { handle: ReferAFriendHandle; focus(): void } | null = null;
70
+
71
+ type Child = Node | string | null | undefined | false;
72
+
73
+ function el<K extends keyof HTMLElementTagNameMap>(
74
+ tag: K,
75
+ props: { className?: string; text?: string; attrs?: Record<string, string> } = {},
76
+ children: Child[] = []
77
+ ): HTMLElementTagNameMap[K] {
78
+ const node = document.createElement(tag);
79
+ if (props.className) node.className = props.className;
80
+ if (props.text !== undefined) node.textContent = props.text;
81
+ if (props.attrs) {
82
+ Object.keys(props.attrs).forEach((name) => node.setAttribute(name, props.attrs![name]));
83
+ }
84
+ children.forEach((child) => {
85
+ if (child === null || child === undefined || child === false) return;
86
+ node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
87
+ });
88
+ return node;
89
+ }
90
+
91
+ function button(text: string, className: string, onClick: () => void): HTMLButtonElement {
92
+ const node = el('button', { className, text, attrs: { type: 'button' } });
93
+ node.addEventListener('click', onClick);
94
+ return node;
95
+ }
96
+
97
+ function formatMoney(amount: number, currency: string): string {
98
+ try {
99
+ return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency || 'USD' }).format(amount);
100
+ } catch {
101
+ return `${amount.toFixed(2)} ${currency}`;
102
+ }
103
+ }
104
+
105
+ const isHttpsUrl = (value: string): boolean => /^https:\/\//i.test(value);
106
+
107
+ /** The localised date when `iso` is in the future, otherwise null. */
108
+ export function futureDateLabel(iso: string | null, now: number = Date.now()): string | null {
109
+ if (!iso) return null;
110
+ const date = new Date(iso);
111
+ if (Number.isNaN(date.getTime()) || date.getTime() <= now) return null;
112
+ try {
113
+ return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
114
+ } catch {
115
+ return date.toDateString();
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Presents the modal. Only valid in a browser. While a modal is open, another
121
+ * call focuses it and returns its handle; the new options are not applied.
122
+ */
123
+ export function presentReferAFriend(options: ReferAFriendOptions, deps: ReferAFriendDeps): ReferAFriendHandle {
124
+ if (openModal) {
125
+ openModal.focus();
126
+ return openModal.handle;
127
+ }
128
+ instanceCount += 1;
129
+ const titleId = `ia-raf-title-${instanceCount}`;
130
+
131
+ let closed = false;
132
+ let email = (options.email || '').trim();
133
+ let name = (options.name || '').trim();
134
+ let companyName = '';
135
+ let busy = false;
136
+ let accountSaved = false;
137
+ const text = resolveReferralStrings(options.strings);
138
+ const messageFor = (code?: string): string =>
139
+ text[(code && ERROR_STRING_KEYS[code]) || 'errorServer'];
140
+
141
+ const previousFocus = document.activeElement as HTMLElement | null;
142
+ const previousOverflow = document.body.style.overflow;
143
+
144
+ const style = el('style', { text: STYLE });
145
+ const title = el('h2', { className: 'ia-raf-title', text: options.headline || DEFAULT_HEADLINE, attrs: { id: titleId } });
146
+ const reward = el('p', { className: 'ia-raf-reward', text: options.rewardText || '' });
147
+ reward.hidden = !options.rewardText;
148
+ const body = el('div');
149
+ const closeButton = button('×', 'ia-raf-close', () => close());
150
+ closeButton.setAttribute('aria-label', text.closeButton);
151
+
152
+ const dialog = el('div', {
153
+ className: 'ia-raf-dialog',
154
+ attrs: { role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': titleId },
155
+ }, [closeButton, title, reward, body]);
156
+ dialog.style.setProperty('--ia-raf-font', options.fontFamily || DEFAULT_FONT);
157
+ dialog.style.setProperty('--ia-raf-radius', `${typeof options.cornerRadius === 'number' ? Math.max(0, options.cornerRadius) : 12}px`);
158
+ setColor(options.primaryColor || DEFAULT_COLOR);
159
+
160
+ const overlay = el('div', { className: 'ia-raf-overlay' }, [dialog]);
161
+ overlay.addEventListener('mousedown', (event) => {
162
+ if (event.target === overlay) close();
163
+ });
164
+
165
+ document.addEventListener('keydown', onKeyDown, true);
166
+ document.head.appendChild(style);
167
+ document.body.appendChild(overlay);
168
+ document.body.style.overflow = 'hidden';
169
+
170
+ function setColor(color: string): void {
171
+ dialog.style.setProperty('--ia-raf-primary', color);
172
+ }
173
+
174
+ function close(): void {
175
+ if (closed) return;
176
+ closed = true;
177
+ if (openModal && openModal.handle === handle) openModal = null;
178
+ document.removeEventListener('keydown', onKeyDown, true);
179
+ overlay.remove();
180
+ style.remove();
181
+ document.body.style.overflow = previousOverflow;
182
+ if (previousFocus && typeof previousFocus.focus === 'function') {
183
+ try {
184
+ previousFocus.focus();
185
+ } catch {
186
+ // The element may have left the page.
187
+ }
188
+ }
189
+ if (options.onClose) options.onClose();
190
+ }
191
+
192
+ function focusables(): HTMLElement[] {
193
+ const selector = 'button:not([disabled]), input:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
194
+ return Array.prototype.slice.call(dialog.querySelectorAll(selector)) as HTMLElement[];
195
+ }
196
+
197
+ function onKeyDown(event: KeyboardEvent): void {
198
+ if (event.key === 'Escape') {
199
+ // Handled here only, so the host page's own dialogs stay open.
200
+ event.preventDefault();
201
+ event.stopPropagation();
202
+ close();
203
+ return;
204
+ }
205
+ if (event.key !== 'Tab') return;
206
+ const items = focusables();
207
+ if (!items.length) return;
208
+ const first = items[0];
209
+ const last = items[items.length - 1];
210
+ if (!dialog.contains(document.activeElement)) {
211
+ event.preventDefault();
212
+ first.focus();
213
+ } else if (event.shiftKey && document.activeElement === first) {
214
+ event.preventDefault();
215
+ last.focus();
216
+ } else if (!event.shiftKey && document.activeElement === last) {
217
+ event.preventDefault();
218
+ first.focus();
219
+ }
220
+ }
221
+
222
+ // Replaces the body and moves focus to the first field or button in it.
223
+ function render(children: Child[], focusTarget?: HTMLElement): void {
224
+ if (closed) return;
225
+ body.textContent = '';
226
+ children.forEach((child) => {
227
+ if (child) body.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
228
+ });
229
+ const target = focusTarget || (body.querySelector('input, button') as HTMLElement | null) || closeButton;
230
+ target.focus();
231
+ }
232
+
233
+ function renderLoading(): void {
234
+ render([el('p', { className: 'ia-raf-muted', text: text.loading, attrs: { role: 'status' } })], closeButton);
235
+ }
236
+
237
+ function renderMessage(message: string, retry?: () => void): void {
238
+ render([
239
+ el('p', { className: 'ia-raf-error', text: message, attrs: { role: 'alert' } }),
240
+ retry ? button(text.tryAgainButton, 'ia-raf-btn', retry) : null,
241
+ ]);
242
+ }
243
+
244
+ function errorBox(): HTMLParagraphElement {
245
+ return el('p', { className: 'ia-raf-error', attrs: { role: 'alert' } });
246
+ }
247
+
248
+ function field(label: string, input: HTMLInputElement): HTMLElement[] {
249
+ input.className = 'ia-raf-input';
250
+ input.id = `${titleId}-${input.name}`;
251
+ return [el('label', { className: 'ia-raf-label', text: label, attrs: { for: input.id } }), input];
252
+ }
253
+
254
+ async function runBusy(submit: HTMLButtonElement, busyText: string, task: () => Promise<void>): Promise<void> {
255
+ if (busy) return;
256
+ busy = true;
257
+ const idleText = submit.textContent || '';
258
+ submit.disabled = true;
259
+ submit.textContent = busyText;
260
+ try {
261
+ await task();
262
+ } finally {
263
+ busy = false;
264
+ if (!closed && submit.isConnected) {
265
+ submit.disabled = false;
266
+ submit.textContent = idleText;
267
+ }
268
+ }
269
+ }
270
+
271
+ function renderEnrolForm(): void {
272
+ const error = errorBox();
273
+ const emailInput = el('input', {
274
+ attrs: { type: 'email', name: 'email', autocomplete: 'email', required: 'true' },
275
+ });
276
+ emailInput.value = email;
277
+ const nameInput = el('input', { attrs: { type: 'text', name: 'name', autocomplete: 'name' } });
278
+ nameInput.value = name;
279
+ const submit = el('button', { className: 'ia-raf-btn', text: text.joinButton, attrs: { type: 'submit' } });
280
+
281
+ const form = el('form', { attrs: { novalidate: 'true' } }, [
282
+ error,
283
+ ...field(text.emailLabel, emailInput),
284
+ ...field(text.nameLabel, nameInput),
285
+ submit,
286
+ ]);
287
+ form.addEventListener('submit', (event) => {
288
+ event.preventDefault();
289
+ email = emailInput.value.trim();
290
+ name = nameInput.value.trim();
291
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
292
+ error.textContent = text.errorInvalidEmail;
293
+ emailInput.focus();
294
+ return;
295
+ }
296
+ error.textContent = '';
297
+ void runBusy(submit, text.joiningButton, async () => {
298
+ const result = await deps.enrol(email, name);
299
+ if (!closed) await handleEnrolment(result, error);
300
+ });
301
+ });
302
+
303
+ render([form], email ? submit : emailInput);
304
+ }
305
+
306
+ function renderCodeStep(notice?: string): void {
307
+ const error = errorBox();
308
+ const status = el('p', { className: 'ia-raf-status', attrs: { role: 'status', 'aria-live': 'polite' } });
309
+ const codeInput = el('input', {
310
+ attrs: {
311
+ type: 'text',
312
+ name: 'code',
313
+ inputmode: 'numeric',
314
+ autocomplete: 'one-time-code',
315
+ },
316
+ });
317
+ const submit = el('button', { className: 'ia-raf-btn', text: text.verifyButton, attrs: { type: 'submit' } });
318
+ // Verify is enabled only while the field holds exactly 6 digits (after
319
+ // normalizing) and no verify request is in flight.
320
+ let verifying = false;
321
+ const syncSubmit = (): void => {
322
+ if (!verifying) submit.disabled = normalizeVerificationCode(codeInput.value).length !== 6;
323
+ };
324
+ codeInput.addEventListener('input', syncSubmit);
325
+ syncSubmit();
326
+
327
+ const resend = button(text.resendButton, 'ia-raf-textbtn', () => {
328
+ if (busy) return;
329
+ error.textContent = '';
330
+ status.textContent = text.sendingNotice;
331
+ busy = true;
332
+ void deps.enrol(email, name).then((result) => {
333
+ busy = false;
334
+ if (closed) return;
335
+ status.textContent = '';
336
+ if (result.status === 'verificationRequired') {
337
+ status.textContent = text.codeResentNotice;
338
+ } else {
339
+ void handleEnrolment(result, error);
340
+ }
341
+ });
342
+ });
343
+ const changeEmail = button(text.differentEmailButton, 'ia-raf-textbtn', () => {
344
+ if (!busy) renderEnrolForm();
345
+ });
346
+
347
+ const form = el('form', { attrs: { novalidate: 'true' } }, [
348
+ el('p', { className: 'ia-raf-muted', text: fillPlaceholders(text.codeSentNotice, { email }) }),
349
+ error,
350
+ ...field(text.codeLabel, codeInput),
351
+ submit,
352
+ el('div', {}, [resend, changeEmail]),
353
+ status,
354
+ ]);
355
+ form.addEventListener('submit', (event) => {
356
+ event.preventDefault();
357
+ const code = normalizeVerificationCode(codeInput.value);
358
+ if (code.length !== 6) {
359
+ error.textContent = text.errorCodeLength;
360
+ codeInput.focus();
361
+ return;
362
+ }
363
+ error.textContent = '';
364
+ verifying = true;
365
+ void runBusy(submit, text.verifyingButton, async () => {
366
+ const result = await deps.verify(email, code, name);
367
+ if (!closed) await handleEnrolment(result, error);
368
+ }).then(() => {
369
+ verifying = false;
370
+ if (!closed && submit.isConnected) syncSubmit();
371
+ });
372
+ });
373
+
374
+ render([form], codeInput);
375
+ if (notice) status.textContent = notice;
376
+ }
377
+
378
+ async function handleEnrolment(result: AffiliateEnrolmentResult, error: HTMLElement): Promise<void> {
379
+ if (result.status === 'verificationRequired') {
380
+ renderCodeStep();
381
+ return;
382
+ }
383
+ if (result.status === 'error') {
384
+ error.textContent = messageFor(result.errorCode);
385
+ return;
386
+ }
387
+ renderLoading();
388
+ const loaded = await deps.loadDetails();
389
+ if (loaded.kind === 'ok') {
390
+ renderEnrolled(loaded.details, loaded.details);
391
+ } else if (result.affiliate) {
392
+ renderEnrolled(result.affiliate, null);
393
+ } else {
394
+ renderMessage(messageFor('NETWORK_ERROR'), () => void start());
395
+ }
396
+ }
397
+
398
+ function renderEnrolled(affiliate: ReferrerAffiliate, stats: MyAffiliateDetails | null): void {
399
+ const status = el('p', { className: 'ia-raf-status', attrs: { role: 'status', 'aria-live': 'polite' } });
400
+ const hasLink = /^http/i.test(affiliate.deeplinkurl);
401
+ const messageToShare = buildReferralShareText(affiliate, companyName, options.shareMessage);
402
+
403
+ const copyButton = (label: string, value: string): HTMLButtonElement => {
404
+ const node = button(label, 'ia-raf-copy', () => {
405
+ void copyText(value).then((ok) => {
406
+ if (closed) return;
407
+ status.textContent = ok ? text.copiedNotice : text.copyFailedNotice;
408
+ });
409
+ });
410
+ node.setAttribute('aria-label', `${label}: ${value}`);
411
+ return node;
412
+ };
413
+
414
+ const children: Child[] = [];
415
+ if (affiliate.affiliateShortCode) {
416
+ children.push(el('div', { className: 'ia-raf-row' }, [
417
+ el('span', { className: 'ia-raf-code', text: affiliate.affiliateShortCode }),
418
+ copyButton(text.copyCodeButton, affiliate.affiliateShortCode),
419
+ ]));
420
+ }
421
+ if (hasLink) {
422
+ children.push(el('div', { className: 'ia-raf-row' }, [
423
+ el('span', { className: 'ia-raf-url', text: affiliate.deeplinkurl }),
424
+ copyButton(text.copyLinkButton, affiliate.deeplinkurl),
425
+ ]));
426
+ }
427
+
428
+ if (messageToShare) {
429
+ children.push(button(text.shareButton, 'ia-raf-btn', () => {
430
+ void shareText(messageToShare).then((outcome) => {
431
+ if (closed) return;
432
+ if (outcome === 'copied') status.textContent = text.copiedNotice;
433
+ else if (outcome === 'failed') status.textContent = text.shareFailedNotice;
434
+ else status.textContent = '';
435
+ });
436
+ }));
437
+ }
438
+
439
+ if (stats) {
440
+ children.push(el('div', { className: 'ia-raf-stats' }, [
441
+ el('div', { className: 'ia-raf-stat' }, [
442
+ el('span', { className: 'ia-raf-stat-value', text: String(stats.referralCount) }),
443
+ el('span', { className: 'ia-raf-stat-label', text: text.referralsLabel }),
444
+ ]),
445
+ el('div', { className: 'ia-raf-stat' }, [
446
+ el('span', { className: 'ia-raf-stat-value', text: formatMoney(stats.totalEarned, stats.currency) }),
447
+ el('span', { className: 'ia-raf-stat-label', text: text.earnedLabel }),
448
+ ]),
449
+ ]));
450
+ const premiumUntil = futureDateLabel(stats.premiumUntil);
451
+ if (premiumUntil) {
452
+ children.push(el('p', {
453
+ className: 'ia-raf-premium',
454
+ text: fillPlaceholders(text.premiumUntil, { date: premiumUntil }),
455
+ }));
456
+ }
457
+ // App Store and Google Play codes. Both show on the web: the referrer may be on either phone.
458
+ if (stats.rewardCodes.length) {
459
+ const rewardsTitleId = `${titleId}-rewards`;
460
+ children.push(
461
+ el('h3', { className: 'ia-raf-rewards-title', text: text.rewardsHeading, attrs: { id: rewardsTitleId } }),
462
+ el('ul', { className: 'ia-raf-rewards', attrs: { 'aria-labelledby': rewardsTitleId } }, stats.rewardCodes.map((reward) => {
463
+ const redeemUrl = reward.redeemUrl;
464
+ let redeem: HTMLButtonElement | null = null;
465
+ if (isHttpsUrl(redeemUrl)) {
466
+ redeem = button(text.redeemButton, 'ia-raf-copy', () => {
467
+ window.open(redeemUrl, '_blank', 'noopener,noreferrer');
468
+ });
469
+ redeem.setAttribute('aria-label', `${text.redeemButton} ${reward.code}`);
470
+ }
471
+ return el('li', { className: 'ia-raf-row' }, [
472
+ el('span', { className: 'ia-raf-reward-code', text: reward.code }),
473
+ redeem,
474
+ ]);
475
+ }))
476
+ );
477
+ }
478
+ if (isHttpsUrl(stats.dashboardUrl)) {
479
+ const dashboardUrl = stats.dashboardUrl;
480
+ children.push(button(text.dashboardLink, 'ia-raf-btn ia-raf-btn-secondary', () => {
481
+ window.open(dashboardUrl, '_blank', 'noopener,noreferrer');
482
+ }));
483
+ }
484
+ }
485
+
486
+ children.push(status);
487
+ render(children);
488
+ }
489
+
490
+ async function start(): Promise<void> {
491
+ renderLoading();
492
+ const [config, loaded] = await Promise.all([
493
+ deps.loadConfig(),
494
+ deps.hasToken().then((has) => (has ? deps.loadDetails() : { kind: 'signedOut' } as MyDetailsFetch)),
495
+ ]);
496
+ if (closed) return;
497
+
498
+ if (config) {
499
+ companyName = config.companyName;
500
+ if (!options.primaryColor && config.primaryColor) setColor(config.primaryColor);
501
+ if (!options.headline && config.headline) title.textContent = config.headline;
502
+ if (!options.rewardText && config.rewardText) {
503
+ reward.textContent = config.rewardText;
504
+ reward.hidden = false;
505
+ }
506
+ }
507
+
508
+ if (loaded.kind === 'ok') {
509
+ if (!accountSaved && (options.appUserId || options.playPurchaseToken)) {
510
+ accountSaved = true;
511
+ void deps.saveAccount();
512
+ }
513
+ renderEnrolled(loaded.details, loaded.details);
514
+ } else if (loaded.kind === 'failed') {
515
+ renderMessage(messageFor('NETWORK_ERROR'), () => void start());
516
+ } else if (config && !config.enabled) {
517
+ renderMessage(messageFor('PROGRAM_DISABLED'));
518
+ } else {
519
+ renderEnrolForm();
520
+ }
521
+ }
522
+
523
+ const handle: ReferAFriendHandle = { close };
524
+ openModal = {
525
+ handle,
526
+ focus: () => {
527
+ if (!dialog.contains(document.activeElement)) (focusables()[0] || closeButton).focus();
528
+ },
529
+ };
530
+
531
+ void start();
532
+
533
+ return handle;
534
+ }