@vouchfor/embeds 0.0.0-experiment.eb79200 → 0.0.0-experiment.f112aaa

Sign up to get free protection for your applications and to get access to all the features.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vouchfor/embeds",
3
- "version": "0.0.0-experiment.eb79200",
3
+ "version": "0.0.0-experiment.f112aaa",
4
4
  "license": "MIT",
5
5
  "author": "Aaron Williams",
6
6
  "main": "dist/es/embeds.js",
@@ -26,7 +26,7 @@
26
26
  "lint:staged": "lint-staged",
27
27
  "prepublishOnly": "yarn build",
28
28
  "size": "size-limit",
29
- "storybook": "yarn prebuild && storybook dev -p 6006",
29
+ "storybook": "yarn prebuild && storybook dev -p 6007",
30
30
  "prebuild": "yarn build:deps && yarn generate:manifest",
31
31
  "test": "true"
32
32
  },
@@ -35,12 +35,12 @@
35
35
  "**/*.{md,json,yml}": "prettier --write"
36
36
  },
37
37
  "dependencies": {
38
- "@lit/task": "1.0.0",
39
- "@vouchfor/media-player": "0.0.0-experiment.eb79200",
38
+ "@lit/task": "^1.0.0",
39
+ "@vouchfor/media-player": "0.0.0-experiment.f112aaa",
40
40
  "uuid": "^9.0.1"
41
41
  },
42
42
  "peerDependencies": {
43
- "lit": "^3.0.2"
43
+ "lit": "^3.1.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@esm-bundle/chai": "^4.3.4-fix.0",
@@ -62,7 +62,7 @@
62
62
  "eslint": "^8.50.0",
63
63
  "eslint-plugin-import": "^2.28.1",
64
64
  "lint-staged": "^14.0.1",
65
- "lit": "^2.8.0",
65
+ "lit": "^3.1.0",
66
66
  "prettier": "^3.0.3",
67
67
  "react": "^18.2.0",
68
68
  "react-dom": "^18.2.0",
@@ -11,16 +11,14 @@ type EmbedArgs = EmbedProps & {
11
11
  };
12
12
 
13
13
  const _Embed = ({
14
- vouchHashId,
14
+ vouchId,
15
+ templateId,
16
+ questions,
15
17
  preload,
16
18
  autoplay,
17
19
  env,
18
20
  apiKey,
19
21
  controls,
20
- enableTracking,
21
- trackingSource,
22
-
23
- resolution,
24
22
  aspectRatio
25
23
  }: EmbedArgs) => {
26
24
  return html`
@@ -28,14 +26,14 @@ const _Embed = ({
28
26
  <vouch-embed
29
27
  env=${ifDefined(env)}
30
28
  apiKey=${ifDefined(apiKey)}
29
+ vouchId=${ifDefined(vouchId)}
30
+ templateId=${ifDefined(templateId)}
31
+ .questions=${questions}
32
+ .controls=${controls}
31
33
  ?autoplay=${autoplay}
32
- ?enableTracking=${enableTracking}
33
- vouchHashId=${ifDefined(vouchHashId)}
34
- resolution=${ifDefined(resolution)}
35
- aspectRatio=${ifDefined(aspectRatio)}
36
34
  preload=${ifDefined(preload)}
37
- .controls=${controls}
38
- trackingSource=${ifDefined(trackingSource)}
35
+ aspectRatio=${ifDefined(aspectRatio)}
36
+ @error=${console.log}
39
37
  ></vouch-embed>
40
38
  </div>
41
39
  `;
@@ -53,20 +51,19 @@ type Story = StoryObj<EmbedArgs>;
53
51
 
54
52
  const Embed: Story = {
55
53
  args: {
56
- env: 'dev',
54
+ env: 'local',
57
55
  apiKey: 'TVik9uTMgE-PD25UTHIS6gyl0hMBWC7AT4dkpdlLBT4VIfDWZJrQiCk6Ak7m1',
58
- vouchHashId: '6JQEIPeStt',
59
- resolution: 1080,
56
+ vouchId: '6JQEIPeStt',
57
+ templateId: '357fc118-e179-4171-9446-ff2b8e9d1b29',
58
+ questions: [],
60
59
  aspectRatio: 0,
61
60
  preload: 'none',
62
- autoplay: false,
63
- enableTracking: true,
64
- trackingSource: 'media_player_storybook'
61
+ autoplay: false
65
62
  },
66
63
  argTypes: {
67
64
  env: {
68
65
  control: 'radio',
69
- options: ['prod', 'staging', 'dev']
66
+ options: ['local', 'dev', 'staging', 'prod']
70
67
  },
71
68
  preload: {
72
69
  control: 'radio',
@@ -1,41 +1,150 @@
1
1
  import { Task } from '@lit/task';
2
+ import { v4 as uuidv4 } from 'uuid';
2
3
 
3
4
  import type { Embed, EmbedProps } from '..';
4
5
  import type { ReactiveControllerHost } from 'lit';
6
+ import type { Environment } from '~/utils/env';
5
7
 
6
8
  import { getEnvUrls } from '~/utils/env';
7
9
 
8
10
  type EmbedHost = ReactiveControllerHost & Embed;
9
11
 
10
- type TaskDeps = [EmbedProps['env'], EmbedProps['apiKey'], EmbedProps['vouchHashId']];
12
+ type FetchTaskDeps = [
13
+ EmbedProps['env'],
14
+ EmbedProps['apiKey'],
15
+ EmbedProps['data'],
16
+ EmbedProps['vouchId'],
17
+ EmbedProps['templateId']
18
+ ];
19
+
20
+ type FilterTaskDeps = [EmbedProps['data'], EmbedProps['questions']];
11
21
 
12
22
  class FetcherController {
13
23
  host: EmbedHost;
14
24
 
25
+ private _fetching = false;
26
+ private _vouch: EmbedProps['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
+
15
100
  constructor(host: EmbedHost) {
16
101
  this.host = host;
17
- new Task<TaskDeps, void>(
102
+ new Task<FetchTaskDeps, void>(
18
103
  this.host,
19
- async ([env, apiKey, vouchHashId]: TaskDeps) => {
104
+ async ([env, apiKey, data, vouchId, templateId]: FetchTaskDeps) => {
20
105
  try {
21
- if (vouchHashId) {
22
- host.fetching = true;
23
-
24
- const { embedApiUrl } = getEnvUrls(env);
106
+ host.vouch = undefined;
107
+ host.template = undefined;
25
108
 
26
- const data = await fetch(`${embedApiUrl}/vouches/${vouchHashId}`, {
27
- method: 'GET',
28
- headers: [['X-Api-Key', apiKey]]
29
- }).then((response) => response.json());
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;
30
119
 
31
- host.data = data;
32
- host.instance = data?.settings?.template?.instance;
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;
33
126
  }
34
127
  } finally {
35
- host.fetching = false;
128
+ this.fetching = false;
36
129
  }
37
130
  },
38
- () => [host.env, host.apiKey, host.vouchHashId]
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]
39
148
  );
40
149
  }
41
150
  }
@@ -1,18 +1,20 @@
1
+ import { TEMPLATE_VERSION } from '@vouchfor/canvas-video';
1
2
  import { v4 as uuidv4 } from 'uuid';
2
3
 
3
4
  import type { Embed } from '..';
4
5
  import type { VideoEventDetail } from '@vouchfor/media-player';
5
6
  import type { ReactiveController, ReactiveControllerHost } from 'lit';
6
7
 
8
+ import packageJson from '../../../../package.json';
7
9
  import { getEnvUrls } from '~/utils/env';
8
10
 
9
- const STREAMED_THROTTLE = 2000;
11
+ const MINIMUM_SEND_THRESHOLD = 1;
10
12
 
11
13
  type EmbedHost = ReactiveControllerHost & Embed;
12
14
 
13
15
  type TrackingEvent = 'VOUCH_LOADED' | 'VOUCH_RESPONSE_VIEWED' | 'VIDEO_PLAYED' | 'VIDEO_STREAMED';
14
16
  type TrackingPayload = {
15
- vouchId: string;
17
+ vouchId?: string;
16
18
  answerId?: string;
17
19
  streamStart?: number;
18
20
  streamEnd?: number;
@@ -36,21 +38,23 @@ class TrackingController implements ReactiveController {
36
38
  private _hasPlayed = false;
37
39
  private _hasLoaded: BooleanMap = {};
38
40
  private _answersViewed: BooleanMap = {};
39
- private _streamedTime: TimeMap = {};
40
- private _streamedPrevTimestamp: TimeMap = {};
41
+ private _streamStartTime: TimeMap = {};
42
+ private _streamLatestTime: TimeMap = {};
43
+ private _currentlyPlayingVideo: VideoEventDetail | null = null;
41
44
 
42
45
  constructor(host: EmbedHost) {
43
46
  this.host = host;
44
47
  host.addController(this);
45
48
  }
46
49
 
47
- private _findVouchId() {
48
- if (this.host.data) {
49
- if ('uuid' in this.host.data) {
50
- return this.host.data.uuid;
51
- }
52
- return this.host.data.id;
50
+ private _findVouchId(payload?: TrackingPayload) {
51
+ if (payload && 'vouchId' in payload) {
52
+ return payload.vouchId;
53
+ }
54
+ if (this.host.vouch) {
55
+ return this.host.vouch.id;
53
56
  }
57
+ return null;
54
58
  }
55
59
 
56
60
  private _createVisitor = (visitorId: string) => {
@@ -116,7 +120,6 @@ class TrackingController implements ReactiveController {
116
120
  });
117
121
 
118
122
  return {
119
- // Source might be embeds, could be playlink etc.
120
123
  source: this.host.trackingSource,
121
124
  time: new Date(),
122
125
  region,
@@ -125,30 +128,58 @@ class TrackingController implements ReactiveController {
125
128
  screenWidth: window.screen.width,
126
129
  referrer: document.referrer,
127
130
  currentUrl: location.href,
131
+ embedVersion: packageJson.version,
132
+ templateVersion: TEMPLATE_VERSION,
128
133
  ...utmParams
129
134
  };
130
135
  };
131
136
 
132
- private _sendTrackingEvent = (event: TrackingEvent, payload: TrackingPayload) => {
137
+ private _sendTrackingEvent = (event: TrackingEvent, payload?: TrackingPayload) => {
138
+ const vouchId = this._findVouchId(payload);
139
+
140
+ if (!vouchId || this.host.disableTracking) {
141
+ return;
142
+ }
143
+
133
144
  const { publicApiUrl } = getEnvUrls(this.host.env);
134
145
  const { client, tab, request, visitor } = this._getUids();
135
146
 
136
- // Don't send tracking if we don't have a source
137
- if (this.host.enableTracking && this.host.trackingSource) {
138
- navigator.sendBeacon(
139
- `${publicApiUrl}/api/events`,
140
- JSON.stringify({
141
- event,
142
- payload,
143
- context: {
144
- 'x-uid-client': client,
145
- 'x-uid-tab': tab,
146
- 'x-uid-request': request,
147
- 'x-uid-visitor': visitor,
148
- 'x-reporting-metadata': this._getReportingMetadata()
149
- }
150
- })
151
- );
147
+ navigator.sendBeacon(
148
+ `${publicApiUrl}/api/v2/events`,
149
+ JSON.stringify({
150
+ event,
151
+ payload: {
152
+ ...payload,
153
+ vouchId
154
+ },
155
+ context: {
156
+ 'x-uid-client': client,
157
+ 'x-uid-tab': tab,
158
+ 'x-uid-request': request,
159
+ 'x-uid-visitor': visitor,
160
+ 'x-reporting-metadata': this._getReportingMetadata()
161
+ }
162
+ })
163
+ );
164
+ };
165
+
166
+ private _streamEnded = () => {
167
+ if (this._currentlyPlayingVideo) {
168
+ const { id, key } = this._currentlyPlayingVideo;
169
+ // Don't send a tracking event when seeking backwards
170
+ if (this._streamLatestTime[key] > this._streamStartTime[key] + MINIMUM_SEND_THRESHOLD) {
171
+ // Send a video streamed event any time the stream ends to capture the time between starting
172
+ // the video and the video stopping for any reason (pausing, deleting the embed node or closing the browser)
173
+ this._sendTrackingEvent('VIDEO_STREAMED', {
174
+ answerId: id,
175
+ streamStart: this._streamStartTime[key],
176
+ streamEnd: this._streamLatestTime[key]
177
+ });
178
+ }
179
+
180
+ // Make sure these events are only sent once by deleting the start and latest times
181
+ delete this._streamStartTime[key];
182
+ delete this._streamLatestTime[key];
152
183
  }
153
184
  };
154
185
 
@@ -159,109 +190,104 @@ class TrackingController implements ReactiveController {
159
190
 
160
191
  // Only send loaded event once per session
161
192
  if (!this._hasLoaded[vouchId]) {
162
- this._sendTrackingEvent('VOUCH_LOADED', {
163
- vouchId
164
- });
193
+ this._sendTrackingEvent('VOUCH_LOADED', { vouchId });
165
194
  this._hasLoaded[vouchId] = true;
166
195
  }
167
196
  };
168
197
 
169
198
  private _handlePlay = () => {
170
- const vouchId = this._findVouchId();
171
-
172
- if (!vouchId) {
173
- return;
174
- }
175
-
176
199
  // Only send the video played event once per session
177
200
  if (!this._hasPlayed) {
178
201
  this._sendTrackingEvent('VIDEO_PLAYED', {
179
- vouchId,
180
202
  streamStart: this.host.currentTime
181
203
  });
182
204
  this._hasPlayed = true;
183
205
  }
184
206
  };
185
207
 
186
- private _handleVideoPlay = ({ detail: { id, node } }: CustomEvent<VideoEventDetail>) => {
187
- const vouchId = this._findVouchId();
188
- if (!vouchId) {
189
- return;
190
- }
208
+ private _handleVideoPlay = ({ detail: { id, key, node } }: CustomEvent<VideoEventDetail>) => {
191
209
  // Only increment play count once per session
192
- if (!this._answersViewed[id]) {
210
+ if (!this._answersViewed[key]) {
193
211
  this._sendTrackingEvent('VOUCH_RESPONSE_VIEWED', {
194
- vouchId,
195
212
  answerId: id
196
213
  });
197
- this._answersViewed[id] = true;
214
+ this._answersViewed[key] = true;
198
215
  }
199
- this._streamedTime[id] = node.currentTime;
200
- this._streamedPrevTimestamp[id] = Date.now();
201
- };
202
216
 
203
- private _handleVideoTimeUpdate = ({ detail: { id, node } }: CustomEvent<VideoEventDetail>) => {
204
- const vouchId = this._findVouchId();
205
- if (!vouchId) {
206
- return;
217
+ if (!this._streamStartTime[key]) {
218
+ this._streamStartTime[key] = node.currentTime;
219
+ this._streamLatestTime[key] = node.currentTime;
207
220
  }
208
- const currentTimestamp = Date.now();
221
+ };
222
+
223
+ private _handleVideoTimeUpdate = ({ detail: { id, key, node } }: CustomEvent<VideoEventDetail>) => {
209
224
  if (
225
+ // We only want to count any time that the video is actually playing
210
226
  !this.host.paused &&
211
- // Only fire the video seeked event when this video is the active one
212
- id === this.host.scene?.video?.id &&
213
- // Throttle the frequency that we send streamed events while playing
214
- currentTimestamp - this._streamedPrevTimestamp[id] > STREAMED_THROTTLE
227
+ // Only update the latest time if this event fires for the currently active video
228
+ id === this.host.scene?.video?.id
215
229
  ) {
230
+ this._currentlyPlayingVideo = { id, key, node };
231
+ this._streamLatestTime[key] = node.currentTime;
232
+ }
233
+ };
234
+
235
+ private _handleVideoPause = ({ detail: { id, key } }: CustomEvent<VideoEventDetail>) => {
236
+ if (this._streamLatestTime[key] > this._streamStartTime[key] + MINIMUM_SEND_THRESHOLD) {
216
237
  this._sendTrackingEvent('VIDEO_STREAMED', {
217
- vouchId,
218
238
  answerId: id,
219
- streamStart: this._streamedTime[id],
220
- streamEnd: node.currentTime
239
+ streamStart: this._streamStartTime[key],
240
+ streamEnd: this._streamLatestTime[key]
221
241
  });
222
- this._streamedTime[id] = node.currentTime;
223
- this._streamedPrevTimestamp[id] = currentTimestamp;
224
242
  }
243
+ delete this._streamStartTime[key];
244
+ delete this._streamLatestTime[key];
225
245
  };
226
246
 
227
- private _handleVideoPause = ({ detail: { id, node } }: CustomEvent<VideoEventDetail>) => {
228
- const vouchId = this._findVouchId();
229
- if (!vouchId) {
230
- return;
247
+ private _pageUnloading = () => {
248
+ this._streamEnded();
249
+ // This will try to send the same stream event again so we delete the start and latest
250
+ // time in stream ended so that there is no times to send and the pause event does nothing
251
+ this.host.pause();
252
+ };
253
+
254
+ private _handleVisibilityChange = () => {
255
+ if (document.visibilityState === 'hidden') {
256
+ this._pageUnloading();
231
257
  }
232
- // Send a video streamed event any time the video pauses then reset the streamed state
233
- // We do this to capture the last bit of time that the video was played between the previous
234
- // stream event and the video being paused manually or stopping because it ended
235
- this._sendTrackingEvent('VIDEO_STREAMED', {
236
- vouchId,
237
- answerId: id,
238
- streamStart: this._streamedTime[id],
239
- streamEnd: node.currentTime
240
- });
241
- delete this._streamedTime[id];
242
- delete this._streamedPrevTimestamp[id];
258
+ };
259
+
260
+ private _handlePageHide = () => {
261
+ this._pageUnloading();
243
262
  };
244
263
 
245
264
  hostConnected() {
246
- if (this.host.enableTracking && this.host.trackingSource) {
247
- requestAnimationFrame(() => {
248
- this.host.addEventListener('vouch:loaded', this._handleVouchLoaded);
249
- this.host.mediaPlayer?.addEventListener('play', this._handlePlay);
250
- this.host.mediaPlayer?.addEventListener('video:play', this._handleVideoPlay);
251
- this.host.mediaPlayer?.addEventListener('video:pause', this._handleVideoPause);
252
- this.host.mediaPlayer?.addEventListener('video:timeupdate', this._handleVideoTimeUpdate);
253
- });
254
- }
265
+ requestAnimationFrame(() => {
266
+ if ('onvisibilitychange' in document) {
267
+ document.addEventListener('visibilitychange', this._handleVisibilityChange);
268
+ } else {
269
+ window.addEventListener('pagehide', this._handlePageHide);
270
+ }
271
+ this.host.addEventListener('vouch:loaded', this._handleVouchLoaded);
272
+ this.host.mediaPlayer?.addEventListener('play', this._handlePlay);
273
+ this.host.mediaPlayer?.addEventListener('video:play', this._handleVideoPlay);
274
+ this.host.mediaPlayer?.addEventListener('video:pause', this._handleVideoPause);
275
+ this.host.mediaPlayer?.addEventListener('video:timeupdate', this._handleVideoTimeUpdate);
276
+ });
255
277
  }
256
278
 
257
279
  hostDisconnected() {
258
- if (this.host.enableTracking && this.host.trackingSource) {
259
- this.host.removeEventListener('vouch:loaded', this._handleVouchLoaded);
260
- this.host.mediaPlayer?.removeEventListener('play', this._handlePlay);
261
- this.host.mediaPlayer?.removeEventListener('video:play', this._handleVideoPlay);
262
- this.host.mediaPlayer?.removeEventListener('video:pause', this._handleVideoPause);
263
- this.host.mediaPlayer?.removeEventListener('video:timeupdate', this._handleVideoTimeUpdate);
280
+ this._streamEnded();
281
+ if ('onvisibilitychange' in document) {
282
+ document.removeEventListener('visibilitychange', this._handleVisibilityChange);
283
+ } else {
284
+ window.removeEventListener('pagehide', this._handlePageHide);
264
285
  }
286
+ this.host.removeEventListener('vouch:loaded', this._handleVouchLoaded);
287
+ this.host.mediaPlayer?.removeEventListener('play', this._handlePlay);
288
+ this.host.mediaPlayer?.removeEventListener('video:play', this._handleVideoPlay);
289
+ this.host.mediaPlayer?.removeEventListener('video:pause', this._handleVideoPause);
290
+ this.host.mediaPlayer?.removeEventListener('video:timeupdate', this._handleVideoTimeUpdate);
265
291
  }
266
292
  }
267
293