@vouchfor/embeds 0.0.0-experiment.019a8c5

Sign up to get free protection for your applications and to get access to all the features.
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@vouchfor/embeds",
3
+ "version": "0.0.0-experiment.019a8c5",
4
+ "license": "MIT",
5
+ "author": "Aaron Williams",
6
+ "main": "dist/es/embeds.js",
7
+ "module": "dist/es/embeds.js",
8
+ "type": "module",
9
+ "types": "dist/es/index.d.ts",
10
+ "exports": {
11
+ ".": "./dist/es/embeds.js"
12
+ },
13
+ "files": ["dist", "src"],
14
+ "publishConfig": {
15
+ "tag": "experiment",
16
+ "access": "public"
17
+ },
18
+ "scripts": {
19
+ "build": "rm -rf dist && tsc && vite build --mode iife && vite build --mode es",
20
+ "build:deps": "yarn --cwd ../media-player build",
21
+ "build:package": "yarn build",
22
+ "build:storybook": "yarn prebuild && storybook build",
23
+ "generate:manifest": "wca src --outFile custom-elements.json",
24
+ "lint": "eslint . --quiet",
25
+ "lint:fix": "eslint . --fix",
26
+ "lint:staged": "lint-staged",
27
+ "prepublishOnly": "yarn build",
28
+ "size": "size-limit",
29
+ "storybook": "yarn prebuild && storybook dev -p 6007",
30
+ "prebuild": "yarn build:deps && yarn generate:manifest",
31
+ "test": "true"
32
+ },
33
+ "lint-staged": {
34
+ "**/*.{ts,tsx,js}": "eslint --fix --quiet",
35
+ "**/*.{md,json,yml}": "prettier --write"
36
+ },
37
+ "dependencies": {
38
+ "@lit/task": "^1.0.0",
39
+ "@vouchfor/media-player": "0.0.0-experiment.019a8c5",
40
+ "uuid": "^9.0.1"
41
+ },
42
+ "peerDependencies": {
43
+ "lit": "^3.1.0"
44
+ },
45
+ "devDependencies": {
46
+ "@esm-bundle/chai": "^4.3.4-fix.0",
47
+ "@open-wc/testing": "^3.2.0",
48
+ "@storybook/addon-essentials": "^7.4.5",
49
+ "@storybook/addon-links": "^7.4.5",
50
+ "@storybook/blocks": "^7.4.5",
51
+ "@storybook/web-components": "^7.4.5",
52
+ "@storybook/web-components-vite": "^7.4.5",
53
+ "@types/mocha": "^10.0.1",
54
+ "@vouchfor/eslint-config": "^1.0.0",
55
+ "@vouchfor/prettier-config": "^1.0.0",
56
+ "@web/dev-server-esbuild": "^0.4.1",
57
+ "@web/test-runner": "^0.17.1",
58
+ "@web/test-runner-browserstack": "^0.6.1",
59
+ "@web/test-runner-mocha": "^0.8.1",
60
+ "@web/test-runner-playwright": "^0.10.1",
61
+ "dotenv": "^16.3.1",
62
+ "eslint": "^8.50.0",
63
+ "eslint-plugin-import": "^2.28.1",
64
+ "lint-staged": "^14.0.1",
65
+ "lit": "^3.1.0",
66
+ "prettier": "^3.0.3",
67
+ "react": "^18.2.0",
68
+ "react-dom": "^18.2.0",
69
+ "storybook": "^7.4.5",
70
+ "typescript": "^5.1.3",
71
+ "vite": "^4.4.9",
72
+ "vite-plugin-dts": "^3.6.0",
73
+ "web-component-analyzer": "^1.1.7"
74
+ }
75
+ }
@@ -0,0 +1,66 @@
1
+ import { html } from 'lit';
2
+ import { ifDefined } from 'lit/directives/if-defined.js';
3
+
4
+ import type { EmbedProps } from './';
5
+ import type { Meta, StoryObj } from '@storybook/web-components';
6
+
7
+ import './';
8
+
9
+ type EmbedArgs = EmbedProps & {
10
+ showVouch?: boolean;
11
+ };
12
+
13
+ const _Embed = ({ vouchId, templateId, preload, autoplay, env, apiKey, controls, aspectRatio }: EmbedArgs) => {
14
+ return html`
15
+ <div style="height: 100vh">
16
+ <vouch-embed
17
+ env=${ifDefined(env)}
18
+ apiKey=${ifDefined(apiKey)}
19
+ vouchId=${ifDefined(vouchId)}
20
+ templateId=${ifDefined(templateId)}
21
+ .controls=${controls}
22
+ ?autoplay=${autoplay}
23
+ preload=${ifDefined(preload)}
24
+ aspectRatio=${ifDefined(aspectRatio)}
25
+ ></vouch-embed>
26
+ </div>
27
+ `;
28
+ };
29
+
30
+ // More on how to set up stories at: https://storybook.js.org/docs/web-components/writing-stories/introduction
31
+ const meta = {
32
+ title: 'Embed',
33
+ tags: ['autodocs'],
34
+ render: (args) => _Embed(args),
35
+ component: 'vouch-embed'
36
+ } satisfies Meta<EmbedProps>;
37
+
38
+ type Story = StoryObj<EmbedArgs>;
39
+
40
+ const Embed: Story = {
41
+ args: {
42
+ env: 'local',
43
+ apiKey: 'TVik9uTMgE-PD25UTHIS6gyl0hMBWC7AT4dkpdlLBT4VIfDWZJrQiCk6Ak7m1',
44
+ vouchId: '6JQEIPeStt',
45
+ templateId: '357fc118-e179-4171-9446-ff2b8e9d1b29',
46
+ aspectRatio: 0,
47
+ preload: 'none',
48
+ autoplay: false
49
+ },
50
+ argTypes: {
51
+ env: {
52
+ control: 'radio',
53
+ options: ['local', 'dev', 'staging', 'prod']
54
+ },
55
+ preload: {
56
+ control: 'radio',
57
+ options: ['auto', 'none']
58
+ }
59
+ },
60
+ parameters: {
61
+ layout: 'fullscreen'
62
+ }
63
+ };
64
+
65
+ export default meta;
66
+ export { Embed };
@@ -0,0 +1,47 @@
1
+ import { createRef, ref } from 'lit/directives/ref.js';
2
+
3
+ import type { Embed } from '..';
4
+ import type { ReactiveController, ReactiveControllerHost } from 'lit';
5
+ import type { Ref } from 'lit/directives/ref.js';
6
+
7
+ import { forwardEvent } from '~/utils/events';
8
+
9
+ type EmbedHost = ReactiveControllerHost & Embed;
10
+
11
+ class EventForwardController implements ReactiveController {
12
+ host: EmbedHost;
13
+
14
+ private _events: string[] = [];
15
+ private _cleanup: (() => void)[] = [];
16
+ private _forwardElementRef: Ref<HTMLElement> = createRef();
17
+
18
+ constructor(host: EmbedHost, events: string[]) {
19
+ this.host = host;
20
+ this._events = events;
21
+ host.addController(this);
22
+ }
23
+
24
+ register() {
25
+ return ref(this._forwardElementRef);
26
+ }
27
+
28
+ hostConnected() {
29
+ // Guaranteed to have the element ref in the RAF callback because of how the lit lifecycle works
30
+ requestAnimationFrame(() => {
31
+ this._events.forEach((event) => {
32
+ if (this._forwardElementRef.value) {
33
+ this._cleanup.push(forwardEvent(event, this._forwardElementRef.value, this.host));
34
+ }
35
+ });
36
+ });
37
+ }
38
+
39
+ hostDisconnected() {
40
+ this._cleanup.forEach((fn) => {
41
+ fn();
42
+ });
43
+ this._cleanup = [];
44
+ }
45
+ }
46
+
47
+ export { EventForwardController };
@@ -0,0 +1,133 @@
1
+ import { Task } from '@lit/task';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+
4
+ import type { Embed, EmbedProps } from '..';
5
+ import type { ReactiveControllerHost } from 'lit';
6
+ import type { Environment } from '~/utils/env';
7
+
8
+ import { getEnvUrls } from '~/utils/env';
9
+
10
+ type EmbedHost = ReactiveControllerHost & Embed;
11
+
12
+ type TaskDeps = [
13
+ EmbedProps['env'],
14
+ EmbedProps['apiKey'],
15
+ EmbedProps['data'],
16
+ EmbedProps['vouchId'],
17
+ EmbedProps['templateId']
18
+ ];
19
+
20
+ class FetcherController {
21
+ host: EmbedHost;
22
+
23
+ private _fetching = false;
24
+
25
+ set fetching(value) {
26
+ if (this._fetching !== value) {
27
+ this._fetching = value;
28
+ this.host.requestUpdate();
29
+ }
30
+ }
31
+ get fetching() {
32
+ return this._fetching;
33
+ }
34
+
35
+ private getVouch = async (env: Environment, apiKey: string, vouchId: string) => {
36
+ const { embedApiUrl } = getEnvUrls(env);
37
+
38
+ const cacheCheck = uuidv4();
39
+ const res = await fetch(`${embedApiUrl}/vouches/${vouchId}`, {
40
+ method: 'GET',
41
+ headers: [
42
+ ['X-Api-Key', apiKey],
43
+ ['X-Cache-Check', cacheCheck]
44
+ ]
45
+ });
46
+
47
+ const vouch = await res.json();
48
+ this.host.dispatchEvent(new CustomEvent('vouch:loaded', { detail: vouchId }));
49
+
50
+ // HACK: we're currently using API Gateway caching on the embed API without any invalidation logic,
51
+ // so to ensure that the cache stays up to date, whenever we detect a cache hit we trigger another
52
+ // API call with the `Cache-Control` header which will re-fill the cache
53
+ const resCacheCheck = res?.headers?.get('X-Cache-Check');
54
+ if (resCacheCheck && resCacheCheck !== cacheCheck) {
55
+ fetch(`${embedApiUrl}/vouches/${vouchId}`, {
56
+ method: 'GET',
57
+ headers: [
58
+ ['X-Api-Key', apiKey],
59
+ ['Cache-Control', 'max-age=0']
60
+ ]
61
+ });
62
+ }
63
+
64
+ return vouch;
65
+ };
66
+
67
+ private getTemplate = async (env: Environment, apiKey: string, templateId: string) => {
68
+ const { embedApiUrl } = getEnvUrls(env);
69
+
70
+ const cacheCheck = uuidv4();
71
+ const res = await fetch(`${embedApiUrl}/templates/${templateId}`, {
72
+ method: 'GET',
73
+ headers: [
74
+ ['X-Api-Key', apiKey],
75
+ ['X-Cache-Check', cacheCheck]
76
+ ]
77
+ });
78
+ const template = await res.json();
79
+
80
+ // HACK: we're currently using API Gateway caching on the embed API without any invalidation logic,
81
+ // so to ensure that the cache stays up to date, whenever we detect a cache hit we trigger another
82
+ // API call with the `Cache-Control` header which will re-fill the cache
83
+ const resCacheCheck = res?.headers?.get('X-Cache-Check');
84
+ if (resCacheCheck && resCacheCheck !== cacheCheck) {
85
+ fetch(`${embedApiUrl}/templates/${templateId}`, {
86
+ method: 'GET',
87
+ headers: [
88
+ ['X-Api-Key', apiKey],
89
+ ['Cache-Control', 'max-age=0']
90
+ ]
91
+ });
92
+ }
93
+
94
+ return template;
95
+ };
96
+
97
+ constructor(host: EmbedHost) {
98
+ this.host = host;
99
+ new Task<TaskDeps, void>(
100
+ this.host,
101
+ async ([env, apiKey, data, vouchId, templateId]: TaskDeps) => {
102
+ try {
103
+ host.vouch = undefined;
104
+ host.template = undefined;
105
+
106
+ if (data) {
107
+ let template;
108
+ if (templateId) {
109
+ this.fetching = true;
110
+ template = await this.getTemplate(env, apiKey, templateId);
111
+ }
112
+ host.vouch = data;
113
+ host.template = template ?? data?.settings?.template?.instance;
114
+ } else if (vouchId) {
115
+ this.fetching = true;
116
+
117
+ const [vouch, template] = await Promise.all([
118
+ this.getVouch(env, apiKey, vouchId),
119
+ templateId ? this.getTemplate(env, apiKey, templateId) : null
120
+ ]);
121
+ host.vouch = vouch;
122
+ host.template = template ?? vouch?.settings?.template?.instance;
123
+ }
124
+ } finally {
125
+ this.fetching = false;
126
+ }
127
+ },
128
+ () => [host.env, host.apiKey, host.data, host.vouchId, host.templateId]
129
+ );
130
+ }
131
+ }
132
+
133
+ export { FetcherController };
@@ -0,0 +1,306 @@
1
+ /* eslint-disable max-lines */
2
+ import { v4 as uuidv4 } from 'uuid';
3
+
4
+ import type { Embed } from '..';
5
+ import type { VideoEventDetail } from '@vouchfor/media-player';
6
+ import type { ReactiveController, ReactiveControllerHost } from 'lit';
7
+
8
+ import { getEnvUrls } from '~/utils/env';
9
+
10
+ const STREAMED_THROTTLE = 10000;
11
+
12
+ type EmbedHost = ReactiveControllerHost & Embed;
13
+
14
+ type TrackingEvent = 'VOUCH_LOADED' | 'VOUCH_RESPONSE_VIEWED' | 'VIDEO_PLAYED' | 'VIDEO_STREAMED';
15
+ type TrackingPayload = {
16
+ vouchId: string;
17
+ answerId?: string;
18
+ streamStart?: number;
19
+ streamEnd?: number;
20
+ };
21
+
22
+ type TimeMap = {
23
+ [key: string]: number;
24
+ };
25
+
26
+ type BooleanMap = {
27
+ [key: string]: boolean;
28
+ };
29
+
30
+ class TrackingController implements ReactiveController {
31
+ host: EmbedHost;
32
+
33
+ private _tabId: string | undefined = undefined;
34
+ private _clientId: string | undefined = undefined;
35
+ private _visitorId: string | undefined = undefined;
36
+
37
+ private _hasPlayed = false;
38
+ private _hasLoaded: BooleanMap = {};
39
+ private _answersViewed: BooleanMap = {};
40
+ private _streamedTime: TimeMap = {};
41
+ private _streamLatestTime: TimeMap = {};
42
+ private _streamedPrevTimestamp: TimeMap = {};
43
+
44
+ constructor(host: EmbedHost) {
45
+ this.host = host;
46
+ host.addController(this);
47
+ }
48
+
49
+ private _findVouchId() {
50
+ if (this.host.vouch) {
51
+ if ('uuid' in this.host.vouch) {
52
+ return this.host.vouch.uuid;
53
+ }
54
+ return this.host.vouch.id;
55
+ }
56
+ }
57
+
58
+ private _createVisitor = (visitorId: string) => {
59
+ const { publicApiUrl } = getEnvUrls(this.host.env);
60
+ window.localStorage?.setItem?.('vouch-uid-visitor', visitorId);
61
+ navigator.sendBeacon(`${publicApiUrl}/api/visitor`, JSON.stringify({ visitorId }));
62
+ return visitorId;
63
+ };
64
+
65
+ private _getUids() {
66
+ if (typeof window === 'undefined') {
67
+ return {
68
+ client: null,
69
+ tab: null,
70
+ request: uuidv4()
71
+ };
72
+ }
73
+
74
+ // Persisted for a user for the same device + browser, so we can e.g. search for all logs related to that browser
75
+ const visitorId =
76
+ this._visitorId || window.localStorage?.getItem?.('vouch-uid-visitor') || this._createVisitor(uuidv4());
77
+ // Persisted for a user for the same device + browser, so we can e.g. search for all logs related to that browser
78
+ const clientId = this._clientId || window.localStorage?.getItem?.('vouch-uid-client') || uuidv4();
79
+ // Persisted in session storage, so we can search for everything the user has done in a specific tab
80
+ const tabId = this._tabId || window.sessionStorage?.getItem?.('vouch-uid-tab') || uuidv4();
81
+ // Not persisted, allows us to search for any logs related to a single FE request
82
+ // E.g. BE should pass this request ID through all other services to be able to group logs
83
+ const requestId = uuidv4();
84
+
85
+ // Cache and persist uids
86
+ if (visitorId !== this._visitorId) {
87
+ this._visitorId = visitorId;
88
+ window.localStorage?.setItem?.('vouch-uid-visitor', visitorId);
89
+ }
90
+
91
+ if (clientId !== this._clientId) {
92
+ this._clientId = clientId;
93
+ window.localStorage?.setItem?.('vouch-uid-client', clientId);
94
+ }
95
+
96
+ if (tabId !== this._tabId) {
97
+ this._tabId = tabId;
98
+ window.sessionStorage?.setItem?.('vouch-uid-tab', tabId);
99
+ }
100
+
101
+ return {
102
+ client: clientId,
103
+ tab: tabId,
104
+ request: requestId,
105
+ visitor: visitorId
106
+ };
107
+ }
108
+
109
+ private _getReportingMetadata = () => {
110
+ const [country, region] = Intl.DateTimeFormat().resolvedOptions().timeZone?.split?.('/') ?? [];
111
+
112
+ const utmParams: any = {};
113
+ [...new URLSearchParams(location.search).entries()].forEach(([key, value]) => {
114
+ if (/utm/.test(key)) {
115
+ const param = key.toLowerCase().replace(/[-_][a-z0-9]/g, (group) => group.slice(-1).toUpperCase());
116
+ utmParams[param] = value;
117
+ }
118
+ });
119
+
120
+ return {
121
+ source: this.host.trackingSource,
122
+ time: new Date(),
123
+ region,
124
+ country,
125
+ screenHeight: window.screen.height,
126
+ screenWidth: window.screen.width,
127
+ referrer: document.referrer,
128
+ currentUrl: location.href,
129
+ ...utmParams
130
+ };
131
+ };
132
+
133
+ private _sendTrackingEvent = (event: TrackingEvent, payload: TrackingPayload) => {
134
+ const { publicApiUrl } = getEnvUrls(this.host.env);
135
+ const { client, tab, request, visitor } = this._getUids();
136
+
137
+ if (this.host.disableTracking) {
138
+ return;
139
+ }
140
+
141
+ navigator.sendBeacon(
142
+ `${publicApiUrl}/api/events`,
143
+ JSON.stringify({
144
+ event,
145
+ payload,
146
+ context: {
147
+ 'x-uid-client': client,
148
+ 'x-uid-tab': tab,
149
+ 'x-uid-request': request,
150
+ 'x-uid-visitor': visitor,
151
+ 'x-reporting-metadata': this._getReportingMetadata()
152
+ }
153
+ })
154
+ );
155
+ };
156
+
157
+ private _handleVouchLoaded = ({ detail: vouchId }: CustomEvent<string>) => {
158
+ if (!vouchId) {
159
+ return;
160
+ }
161
+
162
+ // Only send loaded event once per session
163
+ if (!this._hasLoaded[vouchId]) {
164
+ this._sendTrackingEvent('VOUCH_LOADED', {
165
+ vouchId
166
+ });
167
+ this._hasLoaded[vouchId] = true;
168
+ }
169
+ };
170
+
171
+ private _handlePlay = () => {
172
+ const vouchId = this._findVouchId();
173
+
174
+ if (!vouchId) {
175
+ return;
176
+ }
177
+
178
+ // Only send the video played event once per session
179
+ if (!this._hasPlayed) {
180
+ this._sendTrackingEvent('VIDEO_PLAYED', {
181
+ vouchId,
182
+ streamStart: this.host.currentTime
183
+ });
184
+ this._hasPlayed = true;
185
+ }
186
+ };
187
+
188
+ private _handleVideoPlay = ({ detail: { id, node } }: CustomEvent<VideoEventDetail>) => {
189
+ const vouchId = this._findVouchId();
190
+
191
+ if (!vouchId) {
192
+ return;
193
+ }
194
+
195
+ // Only increment play count once per session
196
+ if (!this._answersViewed[id]) {
197
+ this._sendTrackingEvent('VOUCH_RESPONSE_VIEWED', {
198
+ vouchId,
199
+ answerId: id
200
+ });
201
+ this._answersViewed[id] = true;
202
+ }
203
+
204
+ this._streamedTime[id] = node.currentTime;
205
+ this._streamLatestTime[id] = node.currentTime;
206
+ this._streamedPrevTimestamp[id] = Date.now();
207
+ };
208
+
209
+ private _handleVideoSeeking = ({ detail: { id } }: CustomEvent<VideoEventDetail>) => {
210
+ const vouchId = this._findVouchId();
211
+
212
+ if (!vouchId) {
213
+ return;
214
+ }
215
+
216
+ if (this._streamLatestTime[id]) {
217
+ this._sendTrackingEvent('VIDEO_STREAMED', {
218
+ vouchId,
219
+ answerId: id,
220
+ streamStart: this._streamedTime[id],
221
+ streamEnd: this._streamLatestTime[id]
222
+ });
223
+ }
224
+
225
+ delete this._streamedTime[id];
226
+ delete this._streamLatestTime[id];
227
+ delete this._streamedPrevTimestamp[id];
228
+ };
229
+
230
+ private _handleVideoTimeUpdate = ({ detail: { id, node } }: CustomEvent<VideoEventDetail>) => {
231
+ const vouchId = this._findVouchId();
232
+
233
+ if (!vouchId) {
234
+ return;
235
+ }
236
+
237
+ const currentTimestamp = Date.now();
238
+ if (
239
+ node.currentTime &&
240
+ !node.paused &&
241
+ !this.host.paused &&
242
+ // Only fire the video seeked event when this video is the active one
243
+ id === this.host.scene?.video?.id &&
244
+ // Throttle the frequency that we send streamed events while playing
245
+ currentTimestamp - this._streamedPrevTimestamp[id] > STREAMED_THROTTLE
246
+ ) {
247
+ this._sendTrackingEvent('VIDEO_STREAMED', {
248
+ vouchId,
249
+ answerId: id,
250
+ streamStart: this._streamedTime[id],
251
+ streamEnd: node.currentTime
252
+ });
253
+ this._streamedTime[id] = node.currentTime;
254
+ this._streamedPrevTimestamp[id] = currentTimestamp;
255
+ }
256
+
257
+ this._streamLatestTime[id] = node.currentTime;
258
+ };
259
+
260
+ private _handleVideoPause = ({ detail: { id, node } }: CustomEvent<VideoEventDetail>) => {
261
+ const vouchId = this._findVouchId();
262
+
263
+ if (!vouchId) {
264
+ return;
265
+ }
266
+
267
+ // Don't send a tracking event if the video pauses when seeking backwards
268
+ if (node.currentTime > this._streamedTime[id]) {
269
+ // Send a video streamed event any time the video pauses then reset the streamed state
270
+ // We do this to capture the last bit of time that the video was played between the previous
271
+ // stream event and the video being paused manually or stopping because it ended
272
+ this._sendTrackingEvent('VIDEO_STREAMED', {
273
+ vouchId,
274
+ answerId: id,
275
+ streamStart: this._streamedTime[id],
276
+ streamEnd: node.currentTime
277
+ });
278
+ }
279
+
280
+ delete this._streamedTime[id];
281
+ delete this._streamLatestTime[id];
282
+ delete this._streamedPrevTimestamp[id];
283
+ };
284
+
285
+ hostConnected() {
286
+ requestAnimationFrame(() => {
287
+ this.host.addEventListener('vouch:loaded', this._handleVouchLoaded);
288
+ this.host.mediaPlayer?.addEventListener('play', this._handlePlay);
289
+ this.host.mediaPlayer?.addEventListener('video:play', this._handleVideoPlay);
290
+ this.host.mediaPlayer?.addEventListener('video:seeking', this._handleVideoSeeking);
291
+ this.host.mediaPlayer?.addEventListener('video:pause', this._handleVideoPause);
292
+ this.host.mediaPlayer?.addEventListener('video:timeupdate', this._handleVideoTimeUpdate);
293
+ });
294
+ }
295
+
296
+ hostDisconnected() {
297
+ this.host.removeEventListener('vouch:loaded', this._handleVouchLoaded);
298
+ this.host.mediaPlayer?.removeEventListener('play', this._handlePlay);
299
+ this.host.mediaPlayer?.removeEventListener('video:play', this._handleVideoPlay);
300
+ this.host.mediaPlayer?.removeEventListener('video:seeking', this._handleVideoSeeking);
301
+ this.host.mediaPlayer?.removeEventListener('video:pause', this._handleVideoPause);
302
+ this.host.mediaPlayer?.removeEventListener('video:timeupdate', this._handleVideoTimeUpdate);
303
+ }
304
+ }
305
+
306
+ export { TrackingController };