• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kiva / ui / 29883288471

22 Jul 2026 01:30AM UTC coverage: 85.13% (+0.04%) from 85.092%
29883288471

Pull #7082

github

web-flow
Merge 92a465534 into 140a78ea5
Pull Request #7082: fix: misc fixes and improvements to fb/meta tracking

6294 of 7013 branches covered (89.75%)

Branch coverage included in aggregate %.

104 of 114 new or added lines in 7 files covered. (91.23%)

1 existing line in 1 file now uncovered.

51013 of 60304 relevant lines covered (84.59%)

30.57 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

76.81
/src/plugins/kv-analytics-plugin.js
1
/* eslint-disable no-underscore-dangle */
1✔
2
import logFormatter from '#src/util/logFormatter';
1✔
3
import SimpleQueue from '#src/util/simpleQueue';
1✔
4
import { getUserType, trackFBCustomEvent } from '@kiva/kv-analytics';
1✔
5
import { hasLentBeforeCookie, hasDepositBeforeCookie } from '#src/util/optimizelyUserMetrics';
1✔
6

7
// Best-effort Meta pixel call: no-ops when fbq is absent and never throws into the caller even if
1✔
8
// a broken/blocked fbq shim throws when invoked (mirrors fireFbq in @kiva/kv-analytics).
1✔
9
function fireFbq(...args) {
16✔
10
        if (typeof window !== 'undefined' && typeof window.fbq === 'function') {
16✔
11
                try {
16✔
12
                        window.fbq(...args);
16✔
13
                } catch (e) {
16!
NEW
14
                        logFormatter(e, 'error');
×
NEW
15
                }
×
16
        }
16✔
17
}
16✔
18

19
// install method for plugin
1✔
20
export default {
1✔
21
        install: app => {
1✔
22
                const inBrowser = typeof window !== 'undefined';
46✔
23
                let snowplowLoaded;
46✔
24
                let gtagLoaded;
46✔
25
                let fbLoaded;
46✔
26
                let optimizelyLoaded;
46✔
27
                const queue = new SimpleQueue();
46✔
28
                // Transaction ids already tracked — guards against a Purchase firing twice for one order
46✔
29
                // (double-submit, retry, or component re-mount).
46✔
30
                const trackedTransactionIds = new Set();
46✔
31

32
                const kvActions = {
46✔
33
                        checkLibs: () => {
46✔
34
                                gtagLoaded = inBrowser && typeof window.gtag === 'function';
20✔
35
                                snowplowLoaded = inBrowser && typeof window.snowplow === 'function';
20✔
36
                                fbLoaded = inBrowser && typeof window.fbq === 'function';
20✔
37
                                optimizelyLoaded = inBrowser && typeof window.optimizely === 'object';
20✔
38

39
                                if (typeof window.gtag === 'function' && typeof window.snowplow === 'function') {
20✔
40
                                        return true;
19✔
41
                                }
19✔
42
                                return false;
1✔
43
                        },
46✔
44
                        pageview: (to, from) => {
46✔
45
                                if (!inBrowser) return false;
10!
46
                                kvActions.checkLibs();
10✔
47

48
                                let toUrl = typeof to === 'string' ? to : window.location.href;
10✔
49
                                let fromUrl = typeof from === 'string' ? from : document.referrer;
10✔
50

51
                                // update urls for async page changes
10✔
52
                                if (to && to.matched && to.matched.length) {
10✔
53
                                        toUrl = window.location.origin + to.fullPath;
3✔
54
                                }
3✔
55
                                if (from && from.matched && from.matched.length) {
10✔
56
                                        fromUrl = window.location.origin + from.fullPath;
1✔
57
                                }
1✔
58

59
                                // Snowplow pageview
10✔
60
                                if (snowplowLoaded) {
10✔
61
                                        // - snowplow seems to know better than the path rewriting performed by vue-router
9✔
62
                                        window.snowplow('setCustomUrl', toUrl);
9✔
63
                                        // set referrer for async page transitions
9✔
64
                                        if (from && from.matched && from.path !== '') {
9✔
65
                                                window.snowplow('setReferrerUrl', fromUrl); // asyncFromUrl
1✔
66
                                        }
1✔
67
                                        window.snowplow('trackPageView');
9✔
68
                                }
9✔
69

70
                                // Google Analytics gtag.js pageview
10✔
71
                                if (gtagLoaded) {
10✔
72
                                        let gaPath = `${window.location.pathname}${window.location.search || ''}`;
9✔
73
                                        if (to && to.matched && to.matched.length) {
9✔
74
                                                gaPath = to.fullPath;
3✔
75
                                        }
3✔
76
                                        window.gtag('event', 'page_view', {
9✔
77
                                                page_path: gaPath
9✔
78
                                        });
9✔
79
                                }
9✔
80

81
                                // facebook pixel pageview
10✔
82
                                if (fbLoaded) {
10✔
83
                                        // Segment by transactor status (has ever lent or deposited) via the kvu_lb / kvu_db
9✔
84
                                        // cookies. Uses the shared cookie-name constants and matches the exact 'true' value
9✔
85
                                        // the cookies are written with (see optimizelyUserMetrics.setUserDataCookies), so the
9✔
86
                                        // read stays in step with how cms derives the same flag.
9✔
87
                                        const readCookie = name => {
9✔
88
                                                const cookies = (typeof document !== 'undefined' && document.cookie) || '';
18✔
89
                                                const match = cookies.split(';').map(c => c.trim()).find(c => c.startsWith(`${name}=`));
18✔
90
                                                return match ? match.slice(name.length + 1) : '';
18✔
91
                                        };
9✔
92
                                        const hasLentBefore = readCookie(hasLentBeforeCookie) === 'true';
9✔
93
                                        const hasDepositBefore = readCookie(hasDepositBeforeCookie) === 'true';
9✔
94
                                        fireFbq('track', 'PageView', { user_type: getUserType(hasLentBefore || hasDepositBefore) });
9✔
95
                                }
9✔
96
                        },
46✔
97
                        setCustomUrl: url => {
46✔
98
                                if (snowplowLoaded) {
2!
99
                                        window.snowplow('setCustomUrl', url);
×
100
                                }
×
101
                        },
46✔
102
                        trackEvent: (category, action, label, property, value, callback = () => {}) => {
46✔
103
                                const eventLabel = (label !== undefined && label !== null) ? String(label) : undefined;
7✔
104
                                const eventValue = (value !== undefined && value !== null) ? parseInt(value, 10) : undefined;
7✔
105
                                const eventProperty = (property !== undefined && property !== null) ? String(property) : undefined;
7✔
106

107
                                // Attempt gtag event
7✔
108
                                if (gtagLoaded) {
7!
109
                                        window.gtag('event', String(action), {
×
110
                                                event_category: String(category),
×
111
                                                event_label: eventLabel,
×
112
                                                value: eventValue
×
113
                                        });
×
114
                                }
×
115

116
                                // Attempt Snowplow event
7✔
117
                                if (snowplowLoaded) {
7!
118
                                        kvActions.trackSnowplowEvent({
×
119
                                                category,
×
120
                                                action,
×
121
                                                eventLabel,
×
122
                                                eventProperty,
×
123
                                                eventValue,
×
124
                                                callback
×
125
                                        });
×
126
                                } else {
7✔
127
                                        callback({ error: 'not loaded' });
7✔
128
                                        // add missed snowplow event to queue
7✔
129
                                        queue.add({
7✔
130
                                                eventType: 'trackSnowplowEvent',
7✔
131
                                                eventLib: 'snowplow',
7✔
132
                                                eventData: {
7✔
133
                                                        category,
7✔
134
                                                        action,
7✔
135
                                                        eventLabel,
7✔
136
                                                        eventProperty,
7✔
137
                                                        eventValue,
7✔
138
                                                        callback
7✔
139
                                                }
7✔
140
                                        });
7✔
141
                                }
7✔
142

143
                                return true;
7✔
144
                        },
46✔
145
                        trackSnowplowEvent: eventData => {
46✔
146
                                kvActions.checkLibs();
×
147
                                if (!snowplowLoaded) return false;
×
148

149
                                // In case there is a problem with the tracking event ensure that the callback gets called after 500ms
×
150
                                let callbackCalled = false;
×
151
                                const callbackTimeout = setTimeout(() => {
×
152
                                        if (!callbackCalled) {
×
153
                                                callbackCalled = true;
×
154
                                                eventData.callback({ error: 'timeout' });
×
155
                                        }
×
156
                                }, 500);
×
157

158
                                // Snowplow API
×
159
                                /* eslint-disable max-len */
×
160
                                // https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/javascript-tracker/tracking-specific-events/#tracking-custom-structured-events
×
161
                                // https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/javascript-tracker/tracking-specific-events/#callback-after-track-2-15-0
×
162
                                /* eslint-eable max-len */
×
163
                                // snowplow('trackStructEvent', 'category', 'action', 'label', 'property', 'value', context, timestamp, afterTrack);
×
164
                                window.snowplow(
×
165
                                        'trackStructEvent',
×
166
                                        eventData.category,
×
167
                                        eventData.action,
×
168
                                        eventData.eventLabel,
×
169
                                        eventData.eventProperty,
×
170
                                        eventData.eventValue,
×
171
                                        undefined,
×
172
                                        undefined,
×
173
                                        payload => {
×
174
                                                if (!callbackCalled) {
×
175
                                                        callbackCalled = true;
×
176
                                                        clearTimeout(callbackTimeout);
×
177
                                                        eventData.callback({ payload });
×
178
                                                }
×
179
                                        }
×
180
                                );
×
181
                        },
46✔
182
                        trackSelfDescribingEvent: eventData => {
46✔
183
                                // the data passed into this should be a JSON object similar to the following
2✔
184
                                // and should be defined by a schema in github.com/kiva/snowplow/tree/master/conf
2✔
185
                                // {
2✔
186
                                //     schema: 'https://raw.githubusercontent.com/kiva/...',
2✔
187
                                //     data: {
2✔
188
                                //         "loanId": 654321,
2✔
189
                                //         "amount": 500,
2✔
190
                                //                        ...
2✔
191
                                //     }
2✔
192
                                // });
2✔
193
                                if (snowplowLoaded) {
2!
194
                                        window.snowplow('trackSelfDescribingEvent', eventData);
×
195
                                } else {
2✔
196
                                        // add missed snowplow event to queue
2✔
197
                                        queue.add({
2✔
198
                                                eventType: 'trackSelfDescribingEvent',
2✔
199
                                                eventLib: 'snowplow',
2✔
200
                                                eventData,
2✔
201
                                        });
2✔
202
                                }
2✔
203

204
                                return true;
2✔
205
                        },
46✔
206
                        fireQueuedEvents() {
46✔
207
                                kvActions.checkLibs();
×
208

209
                                while (!queue.isEmpty()) {
×
210
                                        const item = queue.remove();
×
211
                                        const method = item.eventType;
×
212
                                        const { eventData } = item;
×
213
                                        if (inBrowser && typeof kvActions[method] === 'function') {
×
214
                                                // Wrapping the event call in a setTimeout ensures that this while loop
×
215
                                                // completes before the event functions are called. This is needed because
×
216
                                                // the event functions can add more events to this queue, and we only want
×
217
                                                // to process this queue once.
×
218
                                                window.setTimeout(() => {
×
219
                                                        kvActions[method](eventData, true);
×
220
                                                });
×
221
                                        }
×
222
                                }
×
223
                        },
46✔
224
                        parseEventProperties: eventValue => {
46✔
UNCOV
225
                                // Ensure we have a non-empty array to begin with
×
226
                                if (Array.isArray(eventValue) && eventValue.length) {
×
227
                                        // Handle multiple events being pass as an array
×
228
                                        if (Array.isArray(eventValue[0])) {
×
229
                                                eventValue.forEach(params => kvActions.trackEvent.apply(this, params));
×
230
                                        } else {
×
231
                                                kvActions.trackEvent.apply(this, eventValue);
×
232
                                        }
×
233
                                } else {
×
234
                                        throw new TypeError(`Expected non-empty array, but got ${eventValue}`);
×
235
                                }
×
236
                        },
46✔
237
                        trackTransaction: transactionData => {
46✔
238
                                kvActions.checkLibs();
10✔
239
                                // Nothing to track
10✔
240
                                if (transactionData.transactionId === '') {
10✔
241
                                        return false;
1✔
242
                                }
1✔
243

244
                                // Only track a given transaction once
9✔
245
                                const transactionKey = String(transactionData.transactionId);
9✔
246
                                if (trackedTransactionIds.has(transactionKey)) {
10✔
247
                                        return false;
1✔
248
                                }
1✔
249
                                trackedTransactionIds.add(transactionKey);
8✔
250

251
                                if (fbLoaded) {
8✔
252
                                        kvActions.trackFBTransaction(transactionData);
8✔
253
                                }
8✔
254
                                if (gtagLoaded) {
8✔
255
                                        kvActions.trackGATransaction(transactionData);
8✔
256
                                }
8✔
257
                                if (optimizelyLoaded) {
8✔
258
                                        kvActions.trackOPTransaction(transactionData);
8✔
259
                                }
8✔
260
                        },
46✔
261
                        trackFBTransaction: transactionData => {
46✔
262
                                const itemTotal = Number(transactionData.itemTotal) || 0;
8✔
263
                                // Skip Purchase when there's no valid amount (omit rather than send a $0/invalid-value purchase)
8✔
264
                                if (itemTotal > 0) {
8✔
265
                                        const purchase = {
7✔
266
                                                currency: 'USD',
7✔
267
                                                value: itemTotal
7✔
268
                                        };
7✔
269
                                        // Only assert content_type when FTD status is actually known. Guest checkouts have no
7✔
270
                                        // FTD lookup, and defaulting to 'ReturningLender' would be a false claim.
7✔
271
                                        if (typeof transactionData.isFTD === 'boolean') {
7✔
272
                                                purchase.content_type = transactionData.isFTD ? 'FirstTimeDepositor' : 'ReturningLender';
6✔
273
                                        }
6✔
274
                                        fireFbq('track', 'Purchase', purchase);
7✔
275
                                }
7✔
276

277
                                // signify transaction has kiva cards
8✔
278
                                if (transactionData.kivaCards && transactionData.kivaCards.length) {
8✔
279
                                        trackFBCustomEvent(
1✔
280
                                                'transactionContainsKivaCards',
1✔
281
                                                {
1✔
282
                                                        kivaCardTotal: transactionData.kivaCardTotal,
1✔
283
                                                        value: Number(transactionData.kivaCardTotal) || 0,
1!
284
                                                        currency: 'USD'
1✔
285
                                                }
1✔
286
                                        );
1✔
287
                                }
1✔
288
                                // signifiy transaction ftd status
8✔
289
                                if (transactionData.isFTD) {
8✔
290
                                        trackFBCustomEvent(
1✔
291
                                                'firstTimeDepositorTransaction',
1✔
292
                                                {
1✔
293
                                                        itemTotal,
1✔
294
                                                        value: itemTotal,
1✔
295
                                                        currency: 'USD'
1✔
296
                                                }
1✔
297
                                        );
1✔
298
                                }
1✔
299
                        },
46✔
300
                        trackGATransaction: transactionData => {
46✔
301
                                // push to dataLayer
8✔
302
                                if (typeof window.dataLayer === 'object') {
8✔
303
                                        window.dataLayer.push({
8✔
304
                                                event: 'setTransactionData',
8✔
305
                                                ...transactionData
8✔
306
                                        });
8✔
307
                                }
8✔
308

309
                                // Add each purchased item to the tracker
8✔
310
                                const allItems = transactionData.loans.concat(transactionData.donations);
8✔
311

312
                                // Setup purchased items
8✔
313
                                const purchasedItems = allItems.map(item => {
8✔
314
                                        return {
7✔
315
                                                id: item.id,
7✔
316
                                                name: item.__typename,
7✔
317
                                                price: item.price,
7✔
318
                                                quantity: 1
7✔
319
                                        };
7✔
320
                                });
8✔
321

322
                                // Post transaction information to GA
8✔
323
                                window.gtag('event', 'purchase', {
8✔
324
                                        transaction_id: transactionData.transactionId,
8✔
325
                                        value: transactionData.itemTotal,
8✔
326
                                        currency: 'USD',
8✔
327
                                        items: purchasedItems,
8✔
328
                                        non_interaction: true
8✔
329
                                });
8✔
330
                        },
46✔
331
                        trackOPTransaction: transactionData => {
46✔
332
                                if (transactionData.depositTotal > 0) {
8✔
333
                                        window.optimizely.push({
1✔
334
                                                type: 'event',
1✔
335
                                                eventName: 'deposit',
1✔
336
                                                tags: {
1✔
337
                                                        revenue: transactionData.depositTotal * 100,
1✔
338
                                                        deposit_amount: transactionData.depositTotal
1✔
339
                                                }
1✔
340
                                        });
1✔
341
                                }
1✔
342

343
                                if (transactionData.loanTotal > 0) {
8✔
344
                                        window.optimizely.push({
5✔
345
                                                type: 'event',
5✔
346
                                                eventName: 'loan_share_purchase',
5✔
347
                                                tags: {
5✔
348
                                                        revenue: transactionData.loanTotal * 100,
5✔
349
                                                        loan_share_purchase_amount: transactionData.loanTotal
5✔
350
                                                }
5✔
351
                                        });
5✔
352
                                }
5✔
353

354
                                if (transactionData.donationTotal > 0) {
8✔
355
                                        window.optimizely.push({
1✔
356
                                                type: 'event',
1✔
357
                                                eventName: 'donation',
1✔
358
                                                tags: {
1✔
359
                                                        revenue: transactionData.donationTotal * 100,
1✔
360
                                                        donation_amount: transactionData.donationTotal
1✔
361
                                                }
1✔
362
                                        });
1✔
363
                                }
1✔
364
                        }
8✔
365
                };
46✔
366

367
                app.directive('kv-track-event', {
46✔
368
                        beforeMount: (el, binding) => {
46✔
369
                                // TODO: add arg for once, submit + change events
1✔
370
                                if (typeof el === 'object' && binding.value) {
1✔
371
                                        el.addEventListener('click', () => {
1✔
372
                                                try {
×
373
                                                        kvActions.parseEventProperties(binding.value);
×
374
                                                } catch (e) {
×
375
                                                        logFormatter(e, 'error');
×
376
                                                }
×
377
                                        });
1✔
378
                                }
1✔
379
                        }
1✔
380
                });
46✔
381

382
                // eslint-disable-next-line no-param-reassign
46✔
383
                app.config.globalProperties.$setKvAnalyticsData = (userId = null) => {
46✔
384
                        return new Promise(resolve => {
3✔
385
                                let readyStateTimeout;
3✔
386
                                const readyStateInterval = window.setInterval(() => {
3✔
387
                                        if (kvActions.checkLibs()) {
×
388
                                                clearInterval(readyStateInterval);
×
389
                                                clearTimeout(readyStateTimeout);
×
390
                                                // Setup Global Snowplow
×
391
                                                if (snowplowLoaded) {
×
392
                                                        window.snowplow('setUserId', userId);
×
393
                                                }
×
394
                                                // Setup Global GA Data
×
395
                                                if (userId && gtagLoaded && window.__KV_CONFIG__ && window.__KV_CONFIG__.gaId) {
×
396
                                                        window.gtag('config', window.__KV_CONFIG__.gaId, {
×
397
                                                                user_id: userId,
×
398
                                                                dimension1: userId,
×
399
                                                                send_page_view: false
×
400
                                                        });
×
401
                                                }
×
402
                                                // set id on dataLayer
×
403
                                                if (userId && typeof window.dataLayer === 'object') {
×
404
                                                        window.dataLayer.push({
×
405
                                                                kvuid: userId
×
406
                                                        });
×
407
                                                }
×
408
                                                // resovle for next steps
×
409
                                                resolve();
×
410
                                        }
×
411
                                }, 100);
3✔
412

413
                                readyStateTimeout = window.setTimeout(() => {
3✔
414
                                        // clean up interval and timeout
×
415
                                        clearInterval(readyStateInterval);
×
416
                                        clearTimeout(readyStateTimeout);
×
417
                                        // resolve the promise
×
418
                                        resolve();
×
419
                                }, 3000);
3✔
420
                        });
3✔
421
                };
46✔
422

423
                // eslint-disable-next-line no-param-reassign
46✔
424
                app.config.globalProperties.$fireAsyncPageView = (to, from) => {
46✔
425
                        kvActions.pageview(to, from);
9✔
426
                };
46✔
427

428
                // eslint-disable-next-line no-param-reassign
46✔
429
                app.config.globalProperties.$fireServerPageView = () => {
46✔
430
                        const to = { path: window.location.pathname };
1✔
431
                        const from = { path: document.referrer };
1✔
432
                        // delay pageview call to ensure window.performance.timing is fully populated
1✔
433
                        let pageviewFired = false;
1✔
434
                        // fallback if readyState = complete is delayed
1✔
435
                        const fallbackPageview = setTimeout(() => {
1✔
436
                                pageviewFired = true;
×
437
                                kvActions.pageview(to, from);
×
438
                        }, 500);
1✔
439
                        document.onreadystatechange = () => {
1✔
440
                                // fire on complete if not already fired
1✔
441
                                if (document.readyState === 'complete') {
1✔
442
                                        if (!pageviewFired) {
1✔
443
                                                clearTimeout(fallbackPageview);
1✔
444
                                                kvActions.pageview(to, from);
1✔
445
                                        }
1✔
446
                                }
1✔
447
                        };
1✔
448
                };
46✔
449

450
                // eslint-disable-next-line no-param-reassign
46✔
451
                app.config.globalProperties.$fireQueuedEvents = () => {
46✔
452
                        kvActions.fireQueuedEvents();
×
453
                };
46✔
454

455
                // eslint-disable-next-line no-param-reassign
46✔
456
                app.config.globalProperties.$kvSetCustomUrl = (url = window.location.href) => {
46✔
457
                        kvActions.setCustomUrl(url);
2✔
458
                };
46✔
459

460
                // eslint-disable-next-line no-param-reassign
46✔
461
                app.config.globalProperties.$kvTrackEvent = (category, action, label, property, value, callback) => {
46✔
462
                        kvActions.trackEvent(category, action, label, property, value, callback);
7✔
463
                };
46✔
464

465
                // eslint-disable-next-line no-param-reassign
46✔
466
                app.config.globalProperties.$kvTrackSelfDescribingEvent = data => {
46✔
467
                        kvActions.trackSelfDescribingEvent(data);
2✔
468
                };
46✔
469

470
                // eslint-disable-next-line no-param-reassign
46✔
471
                app.config.globalProperties.$kvTrackTransaction = transactionData => {
46✔
472
                        kvActions.trackTransaction(transactionData);
10✔
473
                };
46✔
474

475
                // eslint-disable-next-line no-param-reassign
46✔
476
                app.config.globalProperties.$kvTrackFBCustomEvent = (eventName, eventData = null) => {
46✔
477
                        trackFBCustomEvent(eventName, eventData);
2✔
478
                };
46✔
479
        }
46✔
480
};
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc