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

kiva / ui / 29877311652

21 Jul 2026 11:30PM UTC coverage: 85.128% (+0.04%) from 85.092%
29877311652

Pull #7082

github

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

6294 of 7012 branches covered (89.76%)

Branch coverage included in aggregate %.

95 of 103 new or added lines in 7 files covered. (92.23%)

1 existing line in 1 file now uncovered.

51008 of 60301 relevant lines covered (84.59%)

30.57 hits per line

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

76.8
/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
// install method for plugin
1✔
8
export default {
1✔
9
        install: app => {
1✔
10
                const inBrowser = typeof window !== 'undefined';
46✔
11
                let snowplowLoaded;
46✔
12
                let gtagLoaded;
46✔
13
                let fbLoaded;
46✔
14
                let optimizelyLoaded;
46✔
15
                const queue = new SimpleQueue();
46✔
16
                // Transaction ids already tracked — guards against a Purchase firing twice for one order
46✔
17
                // (double-submit, retry, or component re-mount).
46✔
18
                const trackedTransactionIds = new Set();
46✔
19

20
                const kvActions = {
46✔
21
                        checkLibs: () => {
46✔
22
                                gtagLoaded = inBrowser && typeof window.gtag === 'function';
20✔
23
                                snowplowLoaded = inBrowser && typeof window.snowplow === 'function';
20✔
24
                                fbLoaded = inBrowser && typeof window.fbq === 'function';
20✔
25
                                optimizelyLoaded = inBrowser && typeof window.optimizely === 'object';
20✔
26

27
                                if (typeof window.gtag === 'function' && typeof window.snowplow === 'function') {
20✔
28
                                        return true;
19✔
29
                                }
19✔
30
                                return false;
1✔
31
                        },
46✔
32
                        pageview: (to, from) => {
46✔
33
                                if (!inBrowser) return false;
10!
34
                                kvActions.checkLibs();
10✔
35

36
                                let toUrl = typeof to === 'string' ? to : window.location.href;
10✔
37
                                let fromUrl = typeof from === 'string' ? from : document.referrer;
10✔
38

39
                                // update urls for async page changes
10✔
40
                                if (to && to.matched && to.matched.length) {
10✔
41
                                        toUrl = window.location.origin + to.fullPath;
3✔
42
                                }
3✔
43
                                if (from && from.matched && from.matched.length) {
10✔
44
                                        fromUrl = window.location.origin + from.fullPath;
1✔
45
                                }
1✔
46

47
                                // Snowplow pageview
10✔
48
                                if (snowplowLoaded) {
10✔
49
                                        // - snowplow seems to know better than the path rewriting performed by vue-router
9✔
50
                                        window.snowplow('setCustomUrl', toUrl);
9✔
51
                                        // set referrer for async page transitions
9✔
52
                                        if (from && from.matched && from.path !== '') {
9✔
53
                                                window.snowplow('setReferrerUrl', fromUrl); // asyncFromUrl
1✔
54
                                        }
1✔
55
                                        window.snowplow('trackPageView');
9✔
56
                                }
9✔
57

58
                                // Google Analytics gtag.js pageview
10✔
59
                                if (gtagLoaded) {
10✔
60
                                        let gaPath = `${window.location.pathname}${window.location.search || ''}`;
9✔
61
                                        if (to && to.matched && to.matched.length) {
9✔
62
                                                gaPath = to.fullPath;
3✔
63
                                        }
3✔
64
                                        window.gtag('event', 'page_view', {
9✔
65
                                                page_path: gaPath
9✔
66
                                        });
9✔
67
                                }
9✔
68

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

95
                                // Attempt gtag event
7✔
96
                                if (gtagLoaded) {
7!
97
                                        window.gtag('event', String(action), {
×
98
                                                event_category: String(category),
×
99
                                                event_label: eventLabel,
×
100
                                                value: eventValue
×
101
                                        });
×
102
                                }
×
103

104
                                // Attempt Snowplow event
7✔
105
                                if (snowplowLoaded) {
7!
106
                                        kvActions.trackSnowplowEvent({
×
107
                                                category,
×
108
                                                action,
×
109
                                                eventLabel,
×
110
                                                eventProperty,
×
111
                                                eventValue,
×
112
                                                callback
×
113
                                        });
×
114
                                } else {
7✔
115
                                        callback({ error: 'not loaded' });
7✔
116
                                        // add missed snowplow event to queue
7✔
117
                                        queue.add({
7✔
118
                                                eventType: 'trackSnowplowEvent',
7✔
119
                                                eventLib: 'snowplow',
7✔
120
                                                eventData: {
7✔
121
                                                        category,
7✔
122
                                                        action,
7✔
123
                                                        eventLabel,
7✔
124
                                                        eventProperty,
7✔
125
                                                        eventValue,
7✔
126
                                                        callback
7✔
127
                                                }
7✔
128
                                        });
7✔
129
                                }
7✔
130

131
                                return true;
7✔
132
                        },
46✔
133
                        trackSnowplowEvent: eventData => {
46✔
134
                                kvActions.checkLibs();
×
135
                                if (!snowplowLoaded) return false;
×
136

137
                                // In case there is a problem with the tracking event ensure that the callback gets called after 500ms
×
138
                                let callbackCalled = false;
×
139
                                const callbackTimeout = setTimeout(() => {
×
140
                                        if (!callbackCalled) {
×
141
                                                callbackCalled = true;
×
142
                                                eventData.callback({ error: 'timeout' });
×
143
                                        }
×
144
                                }, 500);
×
145

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

192
                                return true;
2✔
193
                        },
46✔
194
                        fireQueuedEvents() {
46✔
195
                                kvActions.checkLibs();
×
196

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

232
                                // Only track a given transaction once
9✔
233
                                const transactionKey = String(transactionData.transactionId);
9✔
234
                                if (trackedTransactionIds.has(transactionKey)) {
10✔
235
                                        return false;
1✔
236
                                }
1✔
237
                                trackedTransactionIds.add(transactionKey);
8✔
238

239
                                if (fbLoaded) {
8✔
240
                                        kvActions.trackFBTransaction(transactionData);
8✔
241
                                }
8✔
242
                                if (gtagLoaded) {
8✔
243
                                        kvActions.trackGATransaction(transactionData);
8✔
244
                                }
8✔
245
                                if (optimizelyLoaded) {
8✔
246
                                        kvActions.trackOPTransaction(transactionData);
8✔
247
                                }
8✔
248
                        },
46✔
249
                        trackFBTransaction: transactionData => {
46✔
250
                                const itemTotal = Number(transactionData.itemTotal) || 0;
8✔
251
                                // Skip Purchase when there's no valid amount (omit rather than send a $0/invalid-value purchase)
8✔
252
                                if (typeof window.fbq === 'function' && itemTotal > 0) {
8✔
253
                                        const purchase = {
7✔
254
                                                currency: 'USD',
7✔
255
                                                value: itemTotal
7✔
256
                                        };
7✔
257
                                        // Only assert content_type when FTD status is actually known. Guest checkouts have no
7✔
258
                                        // FTD lookup, and defaulting to 'ReturningLender' would be a false claim.
7✔
259
                                        if (typeof transactionData.isFTD === 'boolean') {
7✔
260
                                                purchase.content_type = transactionData.isFTD ? 'FirstTimeDepositor' : 'ReturningLender';
6✔
261
                                        }
6✔
262
                                        window.fbq('track', 'Purchase', purchase);
7✔
263
                                }
7✔
264

265
                                // signify transaction has kiva cards
8✔
266
                                if (transactionData.kivaCards && transactionData.kivaCards.length) {
8✔
267
                                        trackFBCustomEvent(
1✔
268
                                                'transactionContainsKivaCards',
1✔
269
                                                {
1✔
270
                                                        kivaCardTotal: transactionData.kivaCardTotal,
1✔
271
                                                        value: Number(transactionData.kivaCardTotal) || 0,
1!
272
                                                        currency: 'USD'
1✔
273
                                                }
1✔
274
                                        );
1✔
275
                                }
1✔
276
                                // signifiy transaction ftd status
8✔
277
                                if (transactionData.isFTD) {
8✔
278
                                        trackFBCustomEvent(
1✔
279
                                                'firstTimeDepositorTransaction',
1✔
280
                                                {
1✔
281
                                                        itemTotal,
1✔
282
                                                        value: itemTotal,
1✔
283
                                                        currency: 'USD'
1✔
284
                                                }
1✔
285
                                        );
1✔
286
                                }
1✔
287
                        },
46✔
288
                        trackGATransaction: transactionData => {
46✔
289
                                // push to dataLayer
8✔
290
                                if (typeof window.dataLayer === 'object') {
8✔
291
                                        window.dataLayer.push({
8✔
292
                                                event: 'setTransactionData',
8✔
293
                                                ...transactionData
8✔
294
                                        });
8✔
295
                                }
8✔
296

297
                                // Add each purchased item to the tracker
8✔
298
                                const allItems = transactionData.loans.concat(transactionData.donations);
8✔
299

300
                                // Setup purchased items
8✔
301
                                const purchasedItems = allItems.map(item => {
8✔
302
                                        return {
7✔
303
                                                id: item.id,
7✔
304
                                                name: item.__typename,
7✔
305
                                                price: item.price,
7✔
306
                                                quantity: 1
7✔
307
                                        };
7✔
308
                                });
8✔
309

310
                                // Post transaction information to GA
8✔
311
                                window.gtag('event', 'purchase', {
8✔
312
                                        transaction_id: transactionData.transactionId,
8✔
313
                                        value: transactionData.itemTotal,
8✔
314
                                        currency: 'USD',
8✔
315
                                        items: purchasedItems,
8✔
316
                                        non_interaction: true
8✔
317
                                });
8✔
318
                        },
46✔
319
                        trackOPTransaction: transactionData => {
46✔
320
                                if (transactionData.depositTotal > 0) {
8✔
321
                                        window.optimizely.push({
1✔
322
                                                type: 'event',
1✔
323
                                                eventName: 'deposit',
1✔
324
                                                tags: {
1✔
325
                                                        revenue: transactionData.depositTotal * 100,
1✔
326
                                                        deposit_amount: transactionData.depositTotal
1✔
327
                                                }
1✔
328
                                        });
1✔
329
                                }
1✔
330

331
                                if (transactionData.loanTotal > 0) {
8✔
332
                                        window.optimizely.push({
5✔
333
                                                type: 'event',
5✔
334
                                                eventName: 'loan_share_purchase',
5✔
335
                                                tags: {
5✔
336
                                                        revenue: transactionData.loanTotal * 100,
5✔
337
                                                        loan_share_purchase_amount: transactionData.loanTotal
5✔
338
                                                }
5✔
339
                                        });
5✔
340
                                }
5✔
341

342
                                if (transactionData.donationTotal > 0) {
8✔
343
                                        window.optimizely.push({
1✔
344
                                                type: 'event',
1✔
345
                                                eventName: 'donation',
1✔
346
                                                tags: {
1✔
347
                                                        revenue: transactionData.donationTotal * 100,
1✔
348
                                                        donation_amount: transactionData.donationTotal
1✔
349
                                                }
1✔
350
                                        });
1✔
351
                                }
1✔
352
                        }
8✔
353
                };
46✔
354

355
                app.directive('kv-track-event', {
46✔
356
                        beforeMount: (el, binding) => {
46✔
357
                                // TODO: add arg for once, submit + change events
1✔
358
                                if (typeof el === 'object' && binding.value) {
1✔
359
                                        el.addEventListener('click', () => {
1✔
360
                                                try {
×
361
                                                        kvActions.parseEventProperties(binding.value);
×
362
                                                } catch (e) {
×
363
                                                        logFormatter(e, 'error');
×
364
                                                }
×
365
                                        });
1✔
366
                                }
1✔
367
                        }
1✔
368
                });
46✔
369

370
                // eslint-disable-next-line no-param-reassign
46✔
371
                app.config.globalProperties.$setKvAnalyticsData = (userId = null) => {
46✔
372
                        return new Promise(resolve => {
3✔
373
                                let readyStateTimeout;
3✔
374
                                const readyStateInterval = window.setInterval(() => {
3✔
375
                                        if (kvActions.checkLibs()) {
×
376
                                                clearInterval(readyStateInterval);
×
377
                                                clearTimeout(readyStateTimeout);
×
378
                                                // Setup Global Snowplow
×
379
                                                if (snowplowLoaded) {
×
380
                                                        window.snowplow('setUserId', userId);
×
381
                                                }
×
382
                                                // Setup Global GA Data
×
383
                                                if (userId && gtagLoaded && window.__KV_CONFIG__ && window.__KV_CONFIG__.gaId) {
×
384
                                                        window.gtag('config', window.__KV_CONFIG__.gaId, {
×
385
                                                                user_id: userId,
×
386
                                                                dimension1: userId,
×
387
                                                                send_page_view: false
×
388
                                                        });
×
389
                                                }
×
390
                                                // set id on dataLayer
×
391
                                                if (userId && typeof window.dataLayer === 'object') {
×
392
                                                        window.dataLayer.push({
×
393
                                                                kvuid: userId
×
394
                                                        });
×
395
                                                }
×
396
                                                // resovle for next steps
×
397
                                                resolve();
×
398
                                        }
×
399
                                }, 100);
3✔
400

401
                                readyStateTimeout = window.setTimeout(() => {
3✔
402
                                        // clean up interval and timeout
×
403
                                        clearInterval(readyStateInterval);
×
404
                                        clearTimeout(readyStateTimeout);
×
405
                                        // resolve the promise
×
406
                                        resolve();
×
407
                                }, 3000);
3✔
408
                        });
3✔
409
                };
46✔
410

411
                // eslint-disable-next-line no-param-reassign
46✔
412
                app.config.globalProperties.$fireAsyncPageView = (to, from) => {
46✔
413
                        kvActions.pageview(to, from);
9✔
414
                };
46✔
415

416
                // eslint-disable-next-line no-param-reassign
46✔
417
                app.config.globalProperties.$fireServerPageView = () => {
46✔
418
                        const to = { path: window.location.pathname };
1✔
419
                        const from = { path: document.referrer };
1✔
420
                        // delay pageview call to ensure window.performance.timing is fully populated
1✔
421
                        let pageviewFired = false;
1✔
422
                        // fallback if readyState = complete is delayed
1✔
423
                        const fallbackPageview = setTimeout(() => {
1✔
424
                                pageviewFired = true;
×
425
                                kvActions.pageview(to, from);
×
426
                        }, 500);
1✔
427
                        document.onreadystatechange = () => {
1✔
428
                                // fire on complete if not already fired
1✔
429
                                if (document.readyState === 'complete') {
1✔
430
                                        if (!pageviewFired) {
1✔
431
                                                clearTimeout(fallbackPageview);
1✔
432
                                                kvActions.pageview(to, from);
1✔
433
                                        }
1✔
434
                                }
1✔
435
                        };
1✔
436
                };
46✔
437

438
                // eslint-disable-next-line no-param-reassign
46✔
439
                app.config.globalProperties.$fireQueuedEvents = () => {
46✔
440
                        kvActions.fireQueuedEvents();
×
441
                };
46✔
442

443
                // eslint-disable-next-line no-param-reassign
46✔
444
                app.config.globalProperties.$kvSetCustomUrl = (url = window.location.href) => {
46✔
445
                        kvActions.setCustomUrl(url);
2✔
446
                };
46✔
447

448
                // eslint-disable-next-line no-param-reassign
46✔
449
                app.config.globalProperties.$kvTrackEvent = (category, action, label, property, value, callback) => {
46✔
450
                        kvActions.trackEvent(category, action, label, property, value, callback);
7✔
451
                };
46✔
452

453
                // eslint-disable-next-line no-param-reassign
46✔
454
                app.config.globalProperties.$kvTrackSelfDescribingEvent = data => {
46✔
455
                        kvActions.trackSelfDescribingEvent(data);
2✔
456
                };
46✔
457

458
                // eslint-disable-next-line no-param-reassign
46✔
459
                app.config.globalProperties.$kvTrackTransaction = transactionData => {
46✔
460
                        kvActions.trackTransaction(transactionData);
10✔
461
                };
46✔
462

463
                // eslint-disable-next-line no-param-reassign
46✔
464
                app.config.globalProperties.$kvTrackFBCustomEvent = (eventName, eventData = null) => {
46✔
465
                        trackFBCustomEvent(eventName, eventData);
2✔
466
                };
46✔
467
        }
46✔
468
};
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