@vouchfor/embeds 0.0.0-experiment.bb1a5cf → 0.0.0-experiment.dcc3e68

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.bb1a5cf",
3
+ "version": "0.0.0-experiment.dcc3e68",
4
4
  "license": "MIT",
5
5
  "author": "Aaron Williams",
6
6
  "main": "dist/es/embeds.js",
@@ -35,10 +35,12 @@
35
35
  "**/*.{md,json,yml}": "prettier --write"
36
36
  },
37
37
  "dependencies": {
38
- "@vouchfor/media-player": "0.0.0-experiment.bb1a5cf"
38
+ "@lit/task": "1.0.0",
39
+ "@vouchfor/media-player": "0.0.0-experiment.dcc3e68",
40
+ "uuid": "^9.0.1"
39
41
  },
40
42
  "peerDependencies": {
41
- "lit": "^2.7.5"
43
+ "lit": "^3.0.2"
42
44
  },
43
45
  "devDependencies": {
44
46
  "@esm-bundle/chai": "^4.3.4-fix.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
+ preload,
17
+ autoplay,
18
+ env,
19
+ apiKey,
20
+ controls,
21
+
22
+ resolution,
23
+ aspectRatio
24
+ }: EmbedArgs) => {
25
+ return html`
26
+ <div style="height: 100vh">
27
+ <vouch-embed
28
+ env=${ifDefined(env)}
29
+ apiKey=${ifDefined(apiKey)}
30
+ ?autoplay=${autoplay}
31
+ vouchId=${ifDefined(vouchId)}
32
+ templateId=${ifDefined(templateId)}
33
+ resolution=${ifDefined(resolution)}
34
+ aspectRatio=${ifDefined(aspectRatio)}
35
+ preload=${ifDefined(preload)}
36
+ .controls=${controls}
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: 'dev',
55
+ apiKey: 'TVik9uTMgE-PD25UTHIS6gyl0hMBWC7AT4dkpdlLBT4VIfDWZJrQiCk6Ak7m1',
56
+ vouchId: '6JQEIPeStt',
57
+ templateId: '7d0113f7-3f9a-4bdd-97e3-07ee6eec5730',
58
+ resolution: 1080,
59
+ aspectRatio: 0,
60
+ preload: 'none',
61
+ autoplay: false
62
+ },
63
+ argTypes: {
64
+ env: {
65
+ control: 'radio',
66
+ options: ['prod', 'staging', 'dev']
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,115 @@
1
+ import { Task } from '@lit/task';
2
+
3
+ import type { Embed, EmbedProps } from '..';
4
+ import type { TemplateInstance } from '@vouchfor/canvas-video';
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
+ private _vouch: EmbedProps['data'] | null = null;
25
+ private _template: TemplateInstance | null = null;
26
+
27
+ set fetching(value) {
28
+ if (this._fetching !== value) {
29
+ this._fetching = value;
30
+ this.host.requestUpdate();
31
+ }
32
+ }
33
+ get fetching() {
34
+ return this._fetching;
35
+ }
36
+
37
+ set vouch(value) {
38
+ if (this._vouch !== value) {
39
+ this._vouch = value;
40
+ this.host.requestUpdate();
41
+ }
42
+ }
43
+ get vouch() {
44
+ return this._vouch;
45
+ }
46
+
47
+ set template(value) {
48
+ if (this._template !== value) {
49
+ this._template = value;
50
+ this.host.requestUpdate();
51
+ }
52
+ }
53
+ get template() {
54
+ return this._template;
55
+ }
56
+
57
+ private async getVouch(env: Environment, apiKey: string, vouchId?: string) {
58
+ const { embedApiUrl } = getEnvUrls(env);
59
+
60
+ if (vouchId) {
61
+ return fetch(`${embedApiUrl}/vouches/${vouchId}`, {
62
+ method: 'GET',
63
+ headers: [['X-Api-Key', apiKey]]
64
+ }).then((response) => response.json());
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ private async getTemplate(env: Environment, apiKey: string, templateId?: string) {
71
+ const { embedApiUrl } = getEnvUrls(env);
72
+
73
+ if (templateId) {
74
+ return fetch(`${embedApiUrl}/templates/${templateId}`, {
75
+ method: 'GET',
76
+ headers: [['X-Api-Key', apiKey]]
77
+ }).then((response) => response.json());
78
+ }
79
+
80
+ return null;
81
+ }
82
+
83
+ constructor(host: EmbedHost) {
84
+ this.host = host;
85
+ new Task<TaskDeps, void>(
86
+ this.host,
87
+ async ([env, apiKey, data, vouchId, templateId]: TaskDeps) => {
88
+ try {
89
+ this.vouch = null;
90
+ this.template = null;
91
+
92
+ if (data) {
93
+ const template = await this.getTemplate(env, apiKey, templateId);
94
+ this.vouch = data;
95
+ this.template = data?.settings?.template?.instance ?? template;
96
+ } else if (vouchId) {
97
+ this.fetching = true;
98
+
99
+ const [vouch, template] = await Promise.all([
100
+ this.getVouch(env, apiKey, vouchId),
101
+ this.getTemplate(env, apiKey, templateId)
102
+ ]);
103
+ this.vouch = vouch;
104
+ this.template = vouch?.settings?.template?.instance ?? template;
105
+ }
106
+ } finally {
107
+ this.fetching = false;
108
+ }
109
+ },
110
+ () => [host.env, host.apiKey, host.data, host.vouchId, host.templateId]
111
+ );
112
+ }
113
+ }
114
+
115
+ export { FetcherController };
@@ -0,0 +1,261 @@
1
+ import { v4 as uuidv4 } from 'uuid';
2
+
3
+ import type { Embed } from '..';
4
+ import type { VideoEventDetail } from '@vouchfor/media-player';
5
+ import type { ReactiveController, ReactiveControllerHost } from 'lit';
6
+
7
+ import { getEnvUrls } from '~/utils/env';
8
+
9
+ const STREAMED_THROTTLE = 2000;
10
+
11
+ type EmbedHost = ReactiveControllerHost & Embed;
12
+
13
+ type TrackingEvent = 'VOUCH_LOADED' | 'VOUCH_RESPONSE_VIEWED' | 'VIDEO_PLAYED' | 'VIDEO_STREAMED';
14
+ type TrackingPayload = {
15
+ vouchId: string;
16
+ answerId?: string;
17
+ streamStart?: number;
18
+ streamEnd?: number;
19
+ };
20
+
21
+ type TimeMap = {
22
+ [key: string]: number;
23
+ };
24
+
25
+ type BooleanMap = {
26
+ [key: string]: boolean;
27
+ };
28
+
29
+ class TrackingController implements ReactiveController {
30
+ host: EmbedHost;
31
+
32
+ private _tabId: string | undefined = undefined;
33
+ private _clientId: string | undefined = undefined;
34
+ private _visitorId: string | undefined = undefined;
35
+
36
+ private _hasPlayed = false;
37
+ private _hasLoaded: BooleanMap = {};
38
+ private _answersViewed: BooleanMap = {};
39
+ private _streamedTime: TimeMap = {};
40
+ private _streamedPrevTimestamp: TimeMap = {};
41
+
42
+ constructor(host: EmbedHost) {
43
+ this.host = host;
44
+ host.addController(this);
45
+ }
46
+
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;
53
+ }
54
+ }
55
+
56
+ private _createVisitor = (visitorId: string) => {
57
+ const { publicApiUrl } = getEnvUrls(this.host.env);
58
+ window.localStorage?.setItem?.('vouch-uid-visitor', visitorId);
59
+ navigator.sendBeacon(`${publicApiUrl}/api/visitor`, JSON.stringify({ visitorId }));
60
+ return visitorId;
61
+ };
62
+
63
+ private _getUids() {
64
+ if (typeof window === 'undefined') {
65
+ return {
66
+ client: null,
67
+ tab: null,
68
+ request: uuidv4()
69
+ };
70
+ }
71
+
72
+ // Persisted for a user for the same device + browser, so we can e.g. search for all logs related to that browser
73
+ const visitorId =
74
+ this._visitorId || window.localStorage?.getItem?.('vouch-uid-visitor') || this._createVisitor(uuidv4());
75
+ // Persisted for a user for the same device + browser, so we can e.g. search for all logs related to that browser
76
+ const clientId = this._clientId || window.localStorage?.getItem?.('vouch-uid-client') || uuidv4();
77
+ // Persisted in session storage, so we can search for everything the user has done in a specific tab
78
+ const tabId = this._tabId || window.sessionStorage?.getItem?.('vouch-uid-tab') || uuidv4();
79
+ // Not persisted, allows us to search for any logs related to a single FE request
80
+ // E.g. BE should pass this request ID through all other services to be able to group logs
81
+ const requestId = uuidv4();
82
+
83
+ // Cache and persist uids
84
+ if (visitorId !== this._visitorId) {
85
+ this._visitorId = visitorId;
86
+ window.localStorage?.setItem?.('vouch-uid-visitor', visitorId);
87
+ }
88
+
89
+ if (clientId !== this._clientId) {
90
+ this._clientId = clientId;
91
+ window.localStorage?.setItem?.('vouch-uid-client', clientId);
92
+ }
93
+
94
+ if (tabId !== this._tabId) {
95
+ this._tabId = tabId;
96
+ window.sessionStorage?.setItem?.('vouch-uid-tab', tabId);
97
+ }
98
+
99
+ return {
100
+ client: clientId,
101
+ tab: tabId,
102
+ request: requestId,
103
+ visitor: visitorId
104
+ };
105
+ }
106
+
107
+ private _getReportingMetadata = () => {
108
+ const [country, region] = Intl.DateTimeFormat().resolvedOptions().timeZone?.split?.('/') ?? [];
109
+
110
+ const utmParams: any = {};
111
+ [...new URLSearchParams(location.search).entries()].forEach(([key, value]) => {
112
+ if (/utm/.test(key)) {
113
+ const param = key.toLowerCase().replace(/[-_][a-z0-9]/g, (group) => group.slice(-1).toUpperCase());
114
+ utmParams[param] = value;
115
+ }
116
+ });
117
+
118
+ return {
119
+ source: 'media_player',
120
+ time: new Date(),
121
+ region,
122
+ country,
123
+ screenHeight: window.screen.height,
124
+ screenWidth: window.screen.width,
125
+ referrer: document.referrer,
126
+ currentUrl: location.href,
127
+ ...utmParams
128
+ };
129
+ };
130
+
131
+ private _sendTrackingEvent = (event: TrackingEvent, payload: TrackingPayload) => {
132
+ const { publicApiUrl } = getEnvUrls(this.host.env);
133
+ const { client, tab, request, visitor } = this._getUids();
134
+
135
+ // Don't send tracking if we don't have a source
136
+ navigator.sendBeacon(
137
+ `${publicApiUrl}/api/events`,
138
+ JSON.stringify({
139
+ event,
140
+ payload,
141
+ context: {
142
+ 'x-uid-client': client,
143
+ 'x-uid-tab': tab,
144
+ 'x-uid-request': request,
145
+ 'x-uid-visitor': visitor,
146
+ 'x-reporting-metadata': this._getReportingMetadata()
147
+ }
148
+ })
149
+ );
150
+ };
151
+
152
+ private _handleVouchLoaded = ({ detail: vouchId }: CustomEvent<string>) => {
153
+ if (!vouchId) {
154
+ return;
155
+ }
156
+
157
+ // Only send loaded event once per session
158
+ if (!this._hasLoaded[vouchId]) {
159
+ this._sendTrackingEvent('VOUCH_LOADED', {
160
+ vouchId
161
+ });
162
+ this._hasLoaded[vouchId] = true;
163
+ }
164
+ };
165
+
166
+ private _handlePlay = () => {
167
+ const vouchId = this._findVouchId();
168
+
169
+ if (!vouchId) {
170
+ return;
171
+ }
172
+
173
+ // Only send the video played event once per session
174
+ if (!this._hasPlayed) {
175
+ this._sendTrackingEvent('VIDEO_PLAYED', {
176
+ vouchId,
177
+ streamStart: this.host.currentTime
178
+ });
179
+ this._hasPlayed = true;
180
+ }
181
+ };
182
+
183
+ private _handleVideoPlay = ({ detail: { id, node } }: CustomEvent<VideoEventDetail>) => {
184
+ const vouchId = this._findVouchId();
185
+ if (!vouchId) {
186
+ return;
187
+ }
188
+ // Only increment play count once per session
189
+ if (!this._answersViewed[id]) {
190
+ this._sendTrackingEvent('VOUCH_RESPONSE_VIEWED', {
191
+ vouchId,
192
+ answerId: id
193
+ });
194
+ this._answersViewed[id] = true;
195
+ }
196
+ this._streamedTime[id] = node.currentTime;
197
+ this._streamedPrevTimestamp[id] = Date.now();
198
+ };
199
+
200
+ private _handleVideoTimeUpdate = ({ detail: { id, node } }: CustomEvent<VideoEventDetail>) => {
201
+ const vouchId = this._findVouchId();
202
+ if (!vouchId) {
203
+ return;
204
+ }
205
+ const currentTimestamp = Date.now();
206
+ if (
207
+ !this.host.paused &&
208
+ // Only fire the video seeked event when this video is the active one
209
+ id === this.host.scene?.video?.id &&
210
+ // Throttle the frequency that we send streamed events while playing
211
+ currentTimestamp - this._streamedPrevTimestamp[id] > STREAMED_THROTTLE
212
+ ) {
213
+ this._sendTrackingEvent('VIDEO_STREAMED', {
214
+ vouchId,
215
+ answerId: id,
216
+ streamStart: this._streamedTime[id],
217
+ streamEnd: node.currentTime
218
+ });
219
+ this._streamedTime[id] = node.currentTime;
220
+ this._streamedPrevTimestamp[id] = currentTimestamp;
221
+ }
222
+ };
223
+
224
+ private _handleVideoPause = ({ detail: { id, node } }: CustomEvent<VideoEventDetail>) => {
225
+ const vouchId = this._findVouchId();
226
+ if (!vouchId) {
227
+ return;
228
+ }
229
+ // Send a video streamed event any time the video pauses then reset the streamed state
230
+ // We do this to capture the last bit of time that the video was played between the previous
231
+ // stream event and the video being paused manually or stopping because it ended
232
+ this._sendTrackingEvent('VIDEO_STREAMED', {
233
+ vouchId,
234
+ answerId: id,
235
+ streamStart: this._streamedTime[id],
236
+ streamEnd: node.currentTime
237
+ });
238
+ delete this._streamedTime[id];
239
+ delete this._streamedPrevTimestamp[id];
240
+ };
241
+
242
+ hostConnected() {
243
+ requestAnimationFrame(() => {
244
+ this.host.addEventListener('vouch:loaded', this._handleVouchLoaded);
245
+ this.host.mediaPlayer?.addEventListener('play', this._handlePlay);
246
+ this.host.mediaPlayer?.addEventListener('video:play', this._handleVideoPlay);
247
+ this.host.mediaPlayer?.addEventListener('video:pause', this._handleVideoPause);
248
+ this.host.mediaPlayer?.addEventListener('video:timeupdate', this._handleVideoTimeUpdate);
249
+ });
250
+ }
251
+
252
+ hostDisconnected() {
253
+ this.host.removeEventListener('vouch:loaded', this._handleVouchLoaded);
254
+ this.host.mediaPlayer?.removeEventListener('play', this._handlePlay);
255
+ this.host.mediaPlayer?.removeEventListener('video:play', this._handleVideoPlay);
256
+ this.host.mediaPlayer?.removeEventListener('video:pause', this._handleVideoPause);
257
+ this.host.mediaPlayer?.removeEventListener('video:timeupdate', this._handleVideoTimeUpdate);
258
+ }
259
+ }
260
+
261
+ export { TrackingController };