@vouchfor/embeds 0.0.0-experiment.e828084 → 0.0.0-experiment.e8b5a87

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. package/dist/es/embeds.js +1140 -13
  2. package/dist/es/embeds.js.map +1 -1
  3. package/dist/es/src/components/DialogEmbed/DialogOverlay.d.ts +20 -0
  4. package/dist/es/src/components/DialogEmbed/DialogPortal.d.ts +36 -0
  5. package/dist/es/src/components/DialogEmbed/index.d.ts +38 -0
  6. package/dist/es/src/components/PlayerEmbed/controllers/event-forwarder.d.ts +15 -0
  7. package/dist/es/src/components/PlayerEmbed/controllers/fetcher.d.ts +14 -0
  8. package/dist/es/src/components/PlayerEmbed/controllers/tracking/index.d.ts +36 -0
  9. package/dist/es/src/components/PlayerEmbed/controllers/tracking/utils.d.ts +17 -0
  10. package/dist/es/src/components/PlayerEmbed/index.d.ts +74 -0
  11. package/dist/es/src/components/PlayerEmbed/tests/data.d.ts +3 -0
  12. package/dist/es/src/index.d.ts +2 -0
  13. package/dist/es/src/utils/env.d.ts +12 -0
  14. package/dist/es/src/utils/events.d.ts +2 -0
  15. package/dist/iife/dialog-embed/embed.iife.js +1751 -0
  16. package/dist/iife/dialog-embed/embed.iife.js.map +1 -0
  17. package/dist/iife/embeds.iife.js +763 -363
  18. package/dist/iife/embeds.iife.js.map +1 -1
  19. package/dist/iife/player-embed/embed.iife.js +1613 -0
  20. package/dist/iife/player-embed/embed.iife.js.map +1 -0
  21. package/package.json +46 -32
  22. package/src/components/DialogEmbed/Dialog.stories.ts +91 -0
  23. package/src/components/DialogEmbed/DialogOverlay.ts +131 -0
  24. package/src/components/DialogEmbed/DialogPortal.ts +126 -0
  25. package/src/components/DialogEmbed/index.ts +97 -0
  26. package/src/components/PlayerEmbed/PlayerEmbed.stories.ts +79 -0
  27. package/src/components/PlayerEmbed/controllers/event-forwarder.ts +48 -0
  28. package/src/components/PlayerEmbed/controllers/fetcher.ts +152 -0
  29. package/src/components/PlayerEmbed/controllers/tracking/index.ts +224 -0
  30. package/src/components/PlayerEmbed/controllers/tracking/utils.ts +95 -0
  31. package/src/components/PlayerEmbed/index.ts +250 -0
  32. package/src/components/PlayerEmbed/tests/PlayerEmbed.spec.ts +80 -0
  33. package/src/components/PlayerEmbed/tests/data.ts +183 -0
  34. package/src/index.ts +2 -1
  35. package/src/utils/env.ts +64 -0
  36. package/src/utils/events.ts +13 -0
  37. package/dist/es/components/InlineEmbed/index.d.ts +0 -17
  38. package/dist/es/index.d.ts +0 -1
  39. package/src/components/InlineEmbed/InlineEmbed.stories.ts +0 -119
  40. package/src/components/InlineEmbed/index.ts +0 -24
@@ -0,0 +1,152 @@
1
+ import { Task } from '@lit/task';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+
4
+ import type { PlayerEmbed, PlayerEmbedProps } from '..';
5
+ import type { ReactiveControllerHost } from 'lit';
6
+ import type { Environment } from '~/utils/env';
7
+
8
+ import { getEnvUrls } from '~/utils/env';
9
+
10
+ type PlayerEmbedHost = ReactiveControllerHost & PlayerEmbed;
11
+
12
+ type FetchTaskDeps = [
13
+ PlayerEmbedProps['env'],
14
+ PlayerEmbedProps['apiKey'],
15
+ PlayerEmbedProps['data'],
16
+ PlayerEmbedProps['vouchId'],
17
+ PlayerEmbedProps['templateId']
18
+ ];
19
+
20
+ type FilterTaskDeps = [PlayerEmbedProps['data'], PlayerEmbedProps['questions']];
21
+
22
+ class FetcherController {
23
+ host: PlayerEmbedHost;
24
+
25
+ private _fetching = false;
26
+ private _vouch: PlayerEmbedProps['data'];
27
+
28
+ set fetching(value) {
29
+ if (this._fetching !== value) {
30
+ this._fetching = value;
31
+ this.host.requestUpdate();
32
+ }
33
+ }
34
+ get fetching() {
35
+ return this._fetching;
36
+ }
37
+
38
+ private getVouch = async (env: Environment, apiKey: string, vouchId: string) => {
39
+ const { embedApiUrl } = getEnvUrls(env);
40
+
41
+ const cacheCheck = uuidv4();
42
+ const res = await fetch(`${embedApiUrl}/vouches/${vouchId}`, {
43
+ method: 'GET',
44
+ headers: [
45
+ ['X-Api-Key', apiKey],
46
+ ['X-Cache-Check', cacheCheck]
47
+ ]
48
+ });
49
+
50
+ const vouch = await res.json();
51
+ this.host.dispatchEvent(new CustomEvent('vouch:loaded', { detail: vouch?.id }));
52
+
53
+ // HACK: we're currently using API Gateway caching on the embed API without any invalidation logic,
54
+ // so to ensure that the cache stays up to date, whenever we detect a cache hit we trigger another
55
+ // API call with the `Cache-Control` header which will re-fill the cache
56
+ const resCacheCheck = res?.headers?.get('X-Cache-Check');
57
+ if (resCacheCheck !== cacheCheck) {
58
+ fetch(`${embedApiUrl}/vouches/${vouchId}`, {
59
+ method: 'GET',
60
+ headers: [
61
+ ['X-Api-Key', apiKey],
62
+ ['Cache-Control', 'max-age=0']
63
+ ]
64
+ });
65
+ }
66
+
67
+ return vouch;
68
+ };
69
+
70
+ private getTemplate = async (env: Environment, apiKey: string, templateId: string) => {
71
+ const { embedApiUrl } = getEnvUrls(env);
72
+
73
+ const cacheCheck = uuidv4();
74
+ const res = await fetch(`${embedApiUrl}/templates/${templateId}`, {
75
+ method: 'GET',
76
+ headers: [
77
+ ['X-Api-Key', apiKey],
78
+ ['X-Cache-Check', cacheCheck]
79
+ ]
80
+ });
81
+ const template = await res.json();
82
+
83
+ // HACK: we're currently using API Gateway caching on the embed API without any invalidation logic,
84
+ // so to ensure that the cache stays up to date, whenever we detect a cache hit we trigger another
85
+ // API call with the `Cache-Control` header which will re-fill the cache
86
+ const resCacheCheck = res?.headers?.get('X-Cache-Check');
87
+ if (resCacheCheck !== cacheCheck) {
88
+ fetch(`${embedApiUrl}/templates/${templateId}`, {
89
+ method: 'GET',
90
+ headers: [
91
+ ['X-Api-Key', apiKey],
92
+ ['Cache-Control', 'max-age=0']
93
+ ]
94
+ });
95
+ }
96
+
97
+ return template;
98
+ };
99
+
100
+ constructor(host: PlayerEmbedHost) {
101
+ this.host = host;
102
+ new Task<FetchTaskDeps, void>(
103
+ this.host,
104
+ async ([env, apiKey, data, vouchId, templateId]: FetchTaskDeps) => {
105
+ try {
106
+ host.vouch = undefined;
107
+ host.template = undefined;
108
+
109
+ if (data) {
110
+ let template;
111
+ if (templateId) {
112
+ this.fetching = true;
113
+ template = await this.getTemplate(env, apiKey, templateId);
114
+ }
115
+ this._vouch = data;
116
+ host.template = template ?? data?.settings?.template?.instance;
117
+ } else if (vouchId) {
118
+ this.fetching = true;
119
+
120
+ const [vouch, template] = await Promise.all([
121
+ this.getVouch(env, apiKey, vouchId),
122
+ templateId ? this.getTemplate(env, apiKey, templateId) : null
123
+ ]);
124
+ this._vouch = vouch;
125
+ host.template = template ?? vouch?.settings?.template?.instance;
126
+ }
127
+ } finally {
128
+ this.fetching = false;
129
+ }
130
+ },
131
+ () => [host.env, host.apiKey, host.data, host.vouchId, host.templateId]
132
+ );
133
+
134
+ // This second task is to be able to filter the vouch without fetching it again if only the questions changed
135
+ new Task<FilterTaskDeps, void>(
136
+ this.host,
137
+ ([vouch, questions]: FilterTaskDeps) => {
138
+ host.vouch = vouch
139
+ ? {
140
+ ...vouch,
141
+ questions: {
142
+ items: vouch?.questions.items.filter((_, index) => !questions?.length || questions?.includes(index + 1))
143
+ }
144
+ }
145
+ : undefined;
146
+ },
147
+ () => [this._vouch, host.questions]
148
+ );
149
+ }
150
+ }
151
+
152
+ export { FetcherController };
@@ -0,0 +1,224 @@
1
+ import type { PlayerEmbed } from '../..';
2
+ import type { VideoEventDetail } from '@vouchfor/media-player';
3
+ import type { ReactiveController, ReactiveControllerHost } from 'lit';
4
+
5
+ import { findVouchId, getReportingMetadata, getUids } from './utils';
6
+ import { getEnvUrls } from '~/utils/env';
7
+
8
+ const MINIMUM_SEND_THRESHOLD = 1;
9
+
10
+ type PlayerEmbedHost = ReactiveControllerHost & PlayerEmbed;
11
+
12
+ type TrackingEvent = 'VOUCH_LOADED' | 'VOUCH_RESPONSE_VIEWED' | 'VIDEO_PLAYED' | 'VIDEO_STREAMED';
13
+ type TrackingPayload = {
14
+ vouchId?: string;
15
+ answerId?: string;
16
+ streamStart?: number;
17
+ streamEnd?: number;
18
+ };
19
+
20
+ type BatchEvent = {
21
+ event: TrackingEvent;
22
+ payload: TrackingPayload & {
23
+ time: string;
24
+ };
25
+ };
26
+
27
+ type TimeMap = {
28
+ [key: string]: number;
29
+ };
30
+
31
+ type BooleanMap = {
32
+ [key: string]: boolean;
33
+ };
34
+
35
+ class TrackingController implements ReactiveController {
36
+ host: PlayerEmbedHost;
37
+
38
+ private _batchedEvents: BatchEvent[] = [];
39
+ private _hasPlayed = false;
40
+ private _hasLoaded: BooleanMap = {};
41
+ private _answersViewed: BooleanMap = {};
42
+ private _streamStartTime: TimeMap = {};
43
+ private _streamLatestTime: TimeMap = {};
44
+ private _currentlyPlayingVideo: VideoEventDetail | null = null;
45
+
46
+ constructor(host: PlayerEmbedHost) {
47
+ this.host = host;
48
+ host.addController(this);
49
+ }
50
+
51
+ private _createTrackingEvent = (event: TrackingEvent, payload?: TrackingPayload) => {
52
+ const vouchId = findVouchId(payload, this.host.vouch);
53
+
54
+ if (!vouchId || this.host.disableTracking) {
55
+ return;
56
+ }
57
+
58
+ this._batchedEvents.push({
59
+ event,
60
+ payload: {
61
+ ...payload,
62
+ vouchId,
63
+ time: new Date().toISOString()
64
+ }
65
+ });
66
+ };
67
+
68
+ private _sendTrackingEvent = () => {
69
+ if (this._batchedEvents.length <= 0) {
70
+ return;
71
+ }
72
+
73
+ const { publicApiUrl } = getEnvUrls(this.host.env);
74
+ const { client, tab, request, visitor } = getUids(this.host.env);
75
+
76
+ navigator.sendBeacon(
77
+ `${publicApiUrl}/api/batchevents`,
78
+ JSON.stringify({
79
+ payload: {
80
+ events: this._batchedEvents
81
+ },
82
+ context: {
83
+ 'x-uid-client': client,
84
+ 'x-uid-tab': tab,
85
+ 'x-uid-request': request,
86
+ 'x-uid-visitor': visitor,
87
+ 'x-reporting-metadata': getReportingMetadata(this.host.trackingSource)
88
+ }
89
+ })
90
+ );
91
+
92
+ this._batchedEvents = [];
93
+ };
94
+
95
+ private _streamEnded = () => {
96
+ if (this._currentlyPlayingVideo) {
97
+ const { id, key } = this._currentlyPlayingVideo;
98
+ // Don't send a tracking event when seeking backwards
99
+ if (this._streamLatestTime[key] > this._streamStartTime[key] + MINIMUM_SEND_THRESHOLD) {
100
+ // Send a video streamed event any time the stream ends to capture the time between starting
101
+ // the video and the video stopping for any reason (pausing, deleting the embed node or closing the browser)
102
+ this._createTrackingEvent('VIDEO_STREAMED', {
103
+ answerId: id,
104
+ streamStart: this._streamStartTime[key],
105
+ streamEnd: this._streamLatestTime[key]
106
+ });
107
+ }
108
+
109
+ // Make sure these events are only sent once by deleting the start and latest times
110
+ delete this._streamStartTime[key];
111
+ delete this._streamLatestTime[key];
112
+ }
113
+ };
114
+
115
+ private _handleVouchLoaded = ({ detail: vouchId }: CustomEvent<string>) => {
116
+ if (!vouchId) {
117
+ return;
118
+ }
119
+
120
+ // Only send loaded event once per session
121
+ if (!this._hasLoaded[vouchId]) {
122
+ this._createTrackingEvent('VOUCH_LOADED', { vouchId });
123
+ this._hasLoaded[vouchId] = true;
124
+ }
125
+ };
126
+
127
+ private _handlePlay = () => {
128
+ // Only send the video played event once per session
129
+ if (!this._hasPlayed) {
130
+ this._createTrackingEvent('VIDEO_PLAYED', {
131
+ streamStart: this.host.currentTime
132
+ });
133
+ this._hasPlayed = true;
134
+ }
135
+ };
136
+
137
+ private _handleVideoPlay = ({ detail: { id, key } }: CustomEvent<VideoEventDetail>) => {
138
+ // Only increment play count once per session
139
+ if (!this._answersViewed[key]) {
140
+ this._createTrackingEvent('VOUCH_RESPONSE_VIEWED', {
141
+ answerId: id
142
+ });
143
+ this._answersViewed[key] = true;
144
+ }
145
+ };
146
+
147
+ private _handleVideoTimeUpdate = ({ detail: { id, key, node } }: CustomEvent<VideoEventDetail>) => {
148
+ if (
149
+ // We only want to count any time that the video is actually playing
150
+ !this.host.paused &&
151
+ // Only update the latest time if this event fires for the currently active video
152
+ id === this.host.scene?.video?.id
153
+ ) {
154
+ this._currentlyPlayingVideo = { id, key, node };
155
+ this._streamLatestTime[key] = node.currentTime;
156
+
157
+ if (!this._streamStartTime[key]) {
158
+ this._streamStartTime[key] = node.currentTime;
159
+ this._streamLatestTime[key] = node.currentTime;
160
+ }
161
+ }
162
+ };
163
+
164
+ private _handleVideoPause = ({ detail: { id, key } }: CustomEvent<VideoEventDetail>) => {
165
+ if (this._streamLatestTime[key] > this._streamStartTime[key] + MINIMUM_SEND_THRESHOLD) {
166
+ this._createTrackingEvent('VIDEO_STREAMED', {
167
+ answerId: id,
168
+ streamStart: this._streamStartTime[key],
169
+ streamEnd: this._streamLatestTime[key]
170
+ });
171
+ }
172
+ delete this._streamStartTime[key];
173
+ delete this._streamLatestTime[key];
174
+ };
175
+
176
+ private _pageUnloading = () => {
177
+ this._streamEnded();
178
+ this._sendTrackingEvent();
179
+ };
180
+
181
+ private _handleVisibilityChange = () => {
182
+ if (document.visibilityState === 'hidden') {
183
+ this._pageUnloading();
184
+ }
185
+ };
186
+
187
+ private _handlePageHide = () => {
188
+ this._pageUnloading();
189
+ };
190
+
191
+ hostConnected() {
192
+ requestAnimationFrame(() => {
193
+ if ('onvisibilitychange' in document) {
194
+ document.addEventListener('visibilitychange', this._handleVisibilityChange);
195
+ } else {
196
+ window.addEventListener('pagehide', this._handlePageHide);
197
+ }
198
+ this.host.addEventListener('vouch:loaded', this._handleVouchLoaded);
199
+ this.host.mediaPlayer?.addEventListener('play', this._handlePlay);
200
+ this.host.mediaPlayer?.addEventListener('video:play', this._handleVideoPlay);
201
+ this.host.mediaPlayer?.addEventListener('video:pause', this._handleVideoPause);
202
+ this.host.mediaPlayer?.addEventListener('video:timeupdate', this._handleVideoTimeUpdate);
203
+ });
204
+ }
205
+
206
+ hostDisconnected() {
207
+ // Send events if DOM node is destroyed
208
+ this._pageUnloading();
209
+
210
+ if ('onvisibilitychange' in document) {
211
+ document.removeEventListener('visibilitychange', this._handleVisibilityChange);
212
+ } else {
213
+ window.removeEventListener('pagehide', this._handlePageHide);
214
+ }
215
+ this.host.removeEventListener('vouch:loaded', this._handleVouchLoaded);
216
+ this.host.mediaPlayer?.removeEventListener('play', this._handlePlay);
217
+ this.host.mediaPlayer?.removeEventListener('video:play', this._handleVideoPlay);
218
+ this.host.mediaPlayer?.removeEventListener('video:pause', this._handleVideoPause);
219
+ this.host.mediaPlayer?.removeEventListener('video:timeupdate', this._handleVideoTimeUpdate);
220
+ }
221
+ }
222
+
223
+ export { TrackingController };
224
+ export type { TrackingEvent, TrackingPayload };
@@ -0,0 +1,95 @@
1
+ import { TEMPLATE_VERSION } from '@vouchfor/canvas-video';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+
4
+ import type { TrackingPayload } from '.';
5
+ import type { Vouch } from '@vouchfor/video-utils';
6
+ import type { Environment } from '~/utils/env';
7
+
8
+ import packageJson from '~/../package.json';
9
+ import { getEnvUrls } from '~/utils/env';
10
+
11
+ function createVisitor(env: Environment) {
12
+ const { publicApiUrl } = getEnvUrls(env);
13
+ const visitorId = uuidv4();
14
+ navigator.sendBeacon(`${publicApiUrl}/api/visitor`, JSON.stringify({ visitorId }));
15
+ return visitorId;
16
+ }
17
+
18
+ function getUids(env: Environment) {
19
+ if (typeof window === 'undefined') {
20
+ return {
21
+ client: null,
22
+ tab: null,
23
+ request: uuidv4()
24
+ };
25
+ }
26
+
27
+ // Persisted for a user for the same device + browser, so we can e.g. search for all logs related to that browser
28
+ let visitorId = window.localStorage?.getItem?.('vouch-uid-visitor');
29
+ // Persisted for a user for the same device + browser, so we can e.g. search for all logs related to that browser
30
+ let clientId = window.localStorage?.getItem?.('vouch-uid-client');
31
+ // Persisted in session storage, so we can search for everything the user has done in a specific tab
32
+ let tabId = window.sessionStorage?.getItem?.('vouch-uid-tab');
33
+ // Not persisted, allows us to search for any logs related to a single FE request
34
+ // E.g. BE should pass this request ID through all other services to be able to group logs
35
+ const requestId = uuidv4();
36
+
37
+ // Cache uids
38
+ if (!visitorId) {
39
+ visitorId = createVisitor(env);
40
+ window.localStorage?.setItem?.('vouch-uid-visitor', visitorId);
41
+ }
42
+
43
+ if (!clientId) {
44
+ clientId = uuidv4();
45
+ window.localStorage?.setItem?.('vouch-uid-client', clientId);
46
+ }
47
+
48
+ if (!tabId) {
49
+ tabId = uuidv4();
50
+ window.sessionStorage?.setItem?.('vouch-uid-tab', tabId);
51
+ }
52
+
53
+ return {
54
+ client: clientId,
55
+ tab: tabId,
56
+ request: requestId,
57
+ visitor: visitorId
58
+ };
59
+ }
60
+
61
+ function findVouchId(payload?: TrackingPayload, vouch?: Vouch) {
62
+ if (payload && 'vouchId' in payload) {
63
+ return payload.vouchId;
64
+ }
65
+ return vouch?.id ?? null;
66
+ }
67
+
68
+ function getReportingMetadata(source = 'embedded_player') {
69
+ const [country, region] = Intl.DateTimeFormat().resolvedOptions().timeZone?.split?.('/') ?? [];
70
+
71
+ const utmParams: any = {};
72
+ [...new URLSearchParams(location.search).entries()].forEach(([key, value]) => {
73
+ if (/utm/.test(key)) {
74
+ const param = key.toLowerCase().replace(/[-_][a-z0-9]/g, (group) => group.slice(-1).toUpperCase());
75
+ utmParams[param] = value;
76
+ }
77
+ });
78
+
79
+ return {
80
+ source,
81
+ time: new Date(),
82
+ region,
83
+ country,
84
+ screenHeight: window.screen.height,
85
+ screenWidth: window.screen.width,
86
+ referrer: document.referrer,
87
+ currentUrl: location.href,
88
+ embedType: 'media-player-embed',
89
+ embedVersion: packageJson.version,
90
+ templateVersion: TEMPLATE_VERSION,
91
+ ...utmParams
92
+ };
93
+ }
94
+
95
+ export { getUids, findVouchId, getReportingMetadata };