@vouchfor/embeds 0.0.0-experiment.e828084 → 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.e828084",
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,10 +35,12 @@
35
35
  "**/*.{md,json,yml}": "prettier --write"
36
36
  },
37
37
  "dependencies": {
38
- "@vouchfor/media-player": "0.0.0-experiment.e828084"
38
+ "@lit/task": "^1.0.0",
39
+ "@vouchfor/media-player": "0.0.0-experiment.f112aaa",
40
+ "uuid": "^9.0.1"
39
41
  },
40
42
  "peerDependencies": {
41
- "lit": "^2.7.5"
43
+ "lit": "^3.1.0"
42
44
  },
43
45
  "devDependencies": {
44
46
  "@esm-bundle/chai": "^4.3.4-fix.0",
@@ -60,7 +62,7 @@
60
62
  "eslint": "^8.50.0",
61
63
  "eslint-plugin-import": "^2.28.1",
62
64
  "lint-staged": "^14.0.1",
63
- "lit": "^2.8.0",
65
+ "lit": "^3.1.0",
64
66
  "prettier": "^3.0.3",
65
67
  "react": "^18.2.0",
66
68
  "react-dom": "^18.2.0",
@@ -0,0 +1,79 @@
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 = ({
14
+ vouchId,
15
+ templateId,
16
+ questions,
17
+ preload,
18
+ autoplay,
19
+ env,
20
+ apiKey,
21
+ controls,
22
+ aspectRatio
23
+ }: EmbedArgs) => {
24
+ return html`
25
+ <div style="height: 100vh">
26
+ <vouch-embed
27
+ env=${ifDefined(env)}
28
+ apiKey=${ifDefined(apiKey)}
29
+ vouchId=${ifDefined(vouchId)}
30
+ templateId=${ifDefined(templateId)}
31
+ .questions=${questions}
32
+ .controls=${controls}
33
+ ?autoplay=${autoplay}
34
+ preload=${ifDefined(preload)}
35
+ aspectRatio=${ifDefined(aspectRatio)}
36
+ @error=${console.log}
37
+ ></vouch-embed>
38
+ </div>
39
+ `;
40
+ };
41
+
42
+ // More on how to set up stories at: https://storybook.js.org/docs/web-components/writing-stories/introduction
43
+ const meta = {
44
+ title: 'Embed',
45
+ tags: ['autodocs'],
46
+ render: (args) => _Embed(args),
47
+ component: 'vouch-embed'
48
+ } satisfies Meta<EmbedProps>;
49
+
50
+ type Story = StoryObj<EmbedArgs>;
51
+
52
+ const Embed: Story = {
53
+ args: {
54
+ env: 'local',
55
+ apiKey: 'TVik9uTMgE-PD25UTHIS6gyl0hMBWC7AT4dkpdlLBT4VIfDWZJrQiCk6Ak7m1',
56
+ vouchId: '6JQEIPeStt',
57
+ templateId: '357fc118-e179-4171-9446-ff2b8e9d1b29',
58
+ questions: [],
59
+ aspectRatio: 0,
60
+ preload: 'none',
61
+ autoplay: false
62
+ },
63
+ argTypes: {
64
+ env: {
65
+ control: 'radio',
66
+ options: ['local', 'dev', 'staging', 'prod']
67
+ },
68
+ preload: {
69
+ control: 'radio',
70
+ options: ['auto', 'none']
71
+ }
72
+ },
73
+ parameters: {
74
+ layout: 'fullscreen'
75
+ }
76
+ };
77
+
78
+ export default meta;
79
+ 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,152 @@
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 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']];
21
+
22
+ class FetcherController {
23
+ host: EmbedHost;
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
+
100
+ constructor(host: EmbedHost) {
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,294 @@
1
+ import { TEMPLATE_VERSION } from '@vouchfor/canvas-video';
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 packageJson from '../../../../package.json';
9
+ import { getEnvUrls } from '~/utils/env';
10
+
11
+ const MINIMUM_SEND_THRESHOLD = 1;
12
+
13
+ type EmbedHost = ReactiveControllerHost & Embed;
14
+
15
+ type TrackingEvent = 'VOUCH_LOADED' | 'VOUCH_RESPONSE_VIEWED' | 'VIDEO_PLAYED' | 'VIDEO_STREAMED';
16
+ type TrackingPayload = {
17
+ vouchId?: string;
18
+ answerId?: string;
19
+ streamStart?: number;
20
+ streamEnd?: number;
21
+ };
22
+
23
+ type TimeMap = {
24
+ [key: string]: number;
25
+ };
26
+
27
+ type BooleanMap = {
28
+ [key: string]: boolean;
29
+ };
30
+
31
+ class TrackingController implements ReactiveController {
32
+ host: EmbedHost;
33
+
34
+ private _tabId: string | undefined = undefined;
35
+ private _clientId: string | undefined = undefined;
36
+ private _visitorId: string | undefined = undefined;
37
+
38
+ private _hasPlayed = false;
39
+ private _hasLoaded: BooleanMap = {};
40
+ private _answersViewed: BooleanMap = {};
41
+ private _streamStartTime: TimeMap = {};
42
+ private _streamLatestTime: TimeMap = {};
43
+ private _currentlyPlayingVideo: VideoEventDetail | null = null;
44
+
45
+ constructor(host: EmbedHost) {
46
+ this.host = host;
47
+ host.addController(this);
48
+ }
49
+
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;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ private _createVisitor = (visitorId: string) => {
61
+ const { publicApiUrl } = getEnvUrls(this.host.env);
62
+ window.localStorage?.setItem?.('vouch-uid-visitor', visitorId);
63
+ navigator.sendBeacon(`${publicApiUrl}/api/visitor`, JSON.stringify({ visitorId }));
64
+ return visitorId;
65
+ };
66
+
67
+ private _getUids() {
68
+ if (typeof window === 'undefined') {
69
+ return {
70
+ client: null,
71
+ tab: null,
72
+ request: uuidv4()
73
+ };
74
+ }
75
+
76
+ // Persisted for a user for the same device + browser, so we can e.g. search for all logs related to that browser
77
+ const visitorId =
78
+ this._visitorId || window.localStorage?.getItem?.('vouch-uid-visitor') || this._createVisitor(uuidv4());
79
+ // Persisted for a user for the same device + browser, so we can e.g. search for all logs related to that browser
80
+ const clientId = this._clientId || window.localStorage?.getItem?.('vouch-uid-client') || uuidv4();
81
+ // Persisted in session storage, so we can search for everything the user has done in a specific tab
82
+ const tabId = this._tabId || window.sessionStorage?.getItem?.('vouch-uid-tab') || uuidv4();
83
+ // Not persisted, allows us to search for any logs related to a single FE request
84
+ // E.g. BE should pass this request ID through all other services to be able to group logs
85
+ const requestId = uuidv4();
86
+
87
+ // Cache and persist uids
88
+ if (visitorId !== this._visitorId) {
89
+ this._visitorId = visitorId;
90
+ window.localStorage?.setItem?.('vouch-uid-visitor', visitorId);
91
+ }
92
+
93
+ if (clientId !== this._clientId) {
94
+ this._clientId = clientId;
95
+ window.localStorage?.setItem?.('vouch-uid-client', clientId);
96
+ }
97
+
98
+ if (tabId !== this._tabId) {
99
+ this._tabId = tabId;
100
+ window.sessionStorage?.setItem?.('vouch-uid-tab', tabId);
101
+ }
102
+
103
+ return {
104
+ client: clientId,
105
+ tab: tabId,
106
+ request: requestId,
107
+ visitor: visitorId
108
+ };
109
+ }
110
+
111
+ private _getReportingMetadata = () => {
112
+ const [country, region] = Intl.DateTimeFormat().resolvedOptions().timeZone?.split?.('/') ?? [];
113
+
114
+ const utmParams: any = {};
115
+ [...new URLSearchParams(location.search).entries()].forEach(([key, value]) => {
116
+ if (/utm/.test(key)) {
117
+ const param = key.toLowerCase().replace(/[-_][a-z0-9]/g, (group) => group.slice(-1).toUpperCase());
118
+ utmParams[param] = value;
119
+ }
120
+ });
121
+
122
+ return {
123
+ source: this.host.trackingSource,
124
+ time: new Date(),
125
+ region,
126
+ country,
127
+ screenHeight: window.screen.height,
128
+ screenWidth: window.screen.width,
129
+ referrer: document.referrer,
130
+ currentUrl: location.href,
131
+ embedVersion: packageJson.version,
132
+ templateVersion: TEMPLATE_VERSION,
133
+ ...utmParams
134
+ };
135
+ };
136
+
137
+ private _sendTrackingEvent = (event: TrackingEvent, payload?: TrackingPayload) => {
138
+ const vouchId = this._findVouchId(payload);
139
+
140
+ if (!vouchId || this.host.disableTracking) {
141
+ return;
142
+ }
143
+
144
+ const { publicApiUrl } = getEnvUrls(this.host.env);
145
+ const { client, tab, request, visitor } = this._getUids();
146
+
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];
183
+ }
184
+ };
185
+
186
+ private _handleVouchLoaded = ({ detail: vouchId }: CustomEvent<string>) => {
187
+ if (!vouchId) {
188
+ return;
189
+ }
190
+
191
+ // Only send loaded event once per session
192
+ if (!this._hasLoaded[vouchId]) {
193
+ this._sendTrackingEvent('VOUCH_LOADED', { vouchId });
194
+ this._hasLoaded[vouchId] = true;
195
+ }
196
+ };
197
+
198
+ private _handlePlay = () => {
199
+ // Only send the video played event once per session
200
+ if (!this._hasPlayed) {
201
+ this._sendTrackingEvent('VIDEO_PLAYED', {
202
+ streamStart: this.host.currentTime
203
+ });
204
+ this._hasPlayed = true;
205
+ }
206
+ };
207
+
208
+ private _handleVideoPlay = ({ detail: { id, key, node } }: CustomEvent<VideoEventDetail>) => {
209
+ // Only increment play count once per session
210
+ if (!this._answersViewed[key]) {
211
+ this._sendTrackingEvent('VOUCH_RESPONSE_VIEWED', {
212
+ answerId: id
213
+ });
214
+ this._answersViewed[key] = true;
215
+ }
216
+
217
+ if (!this._streamStartTime[key]) {
218
+ this._streamStartTime[key] = node.currentTime;
219
+ this._streamLatestTime[key] = node.currentTime;
220
+ }
221
+ };
222
+
223
+ private _handleVideoTimeUpdate = ({ detail: { id, key, node } }: CustomEvent<VideoEventDetail>) => {
224
+ if (
225
+ // We only want to count any time that the video is actually playing
226
+ !this.host.paused &&
227
+ // Only update the latest time if this event fires for the currently active video
228
+ id === this.host.scene?.video?.id
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) {
237
+ this._sendTrackingEvent('VIDEO_STREAMED', {
238
+ answerId: id,
239
+ streamStart: this._streamStartTime[key],
240
+ streamEnd: this._streamLatestTime[key]
241
+ });
242
+ }
243
+ delete this._streamStartTime[key];
244
+ delete this._streamLatestTime[key];
245
+ };
246
+
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();
257
+ }
258
+ };
259
+
260
+ private _handlePageHide = () => {
261
+ this._pageUnloading();
262
+ };
263
+
264
+ hostConnected() {
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
+ });
277
+ }
278
+
279
+ hostDisconnected() {
280
+ this._streamEnded();
281
+ if ('onvisibilitychange' in document) {
282
+ document.removeEventListener('visibilitychange', this._handleVisibilityChange);
283
+ } else {
284
+ window.removeEventListener('pagehide', this._handlePageHide);
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);
291
+ }
292
+ }
293
+
294
+ export { TrackingController };