@hubspot/cms-component-library 0.3.11 → 0.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/componentLibrary/Accordion/AccordionContent/index.module.scss +3 -5
- package/components/componentLibrary/Accordion/AccordionItem/StyleFields.tsx +6 -14
- package/components/componentLibrary/Accordion/AccordionItem/index.module.scss +7 -5
- package/components/componentLibrary/Accordion/AccordionItem/index.tsx +6 -15
- package/components/componentLibrary/Accordion/AccordionItem/types.ts +5 -5
- package/components/componentLibrary/Accordion/AccordionTitle/StyleFields.tsx +1 -1
- package/components/componentLibrary/Accordion/AccordionTitle/index.module.scss +6 -6
- package/components/componentLibrary/Accordion/llm.txt +15 -15
- package/components/componentLibrary/Accordion/stories/Accordion.stories.tsx +3 -3
- package/components/componentLibrary/Accordion/stories/AccordionDecorator.module.scss +38 -0
- package/components/componentLibrary/Accordion/stories/AccordionDecorator.tsx +7 -35
- package/components/componentLibrary/Button/index.module.scss +12 -4
- package/components/componentLibrary/Button/llm.txt +12 -4
- package/components/componentLibrary/Button/stories/ButtonDecorator.module.scss +12 -4
- package/components/componentLibrary/Card/index.module.scss +5 -5
- package/components/componentLibrary/Card/llm.txt +3 -1
- package/components/componentLibrary/Card/stories/CardDecorator.module.scss +12 -4
- package/components/componentLibrary/Form/PlaceholderForm.module.css +74 -0
- package/components/componentLibrary/Form/PlaceholderForm.tsx +31 -0
- package/components/componentLibrary/Form/StyleFields.tsx +2 -1
- package/components/componentLibrary/Form/index.tsx +24 -2
- package/components/componentLibrary/Form/islands/FormIsland.tsx +3 -1
- package/components/componentLibrary/Form/islands/LegacyFormIsland.tsx +10 -3
- package/components/componentLibrary/Form/islands/legacyForm.module.scss +270 -0
- package/components/componentLibrary/Form/islands/v4Form.module.css +59 -57
- package/components/componentLibrary/Form/types.ts +7 -2
- package/components/componentLibrary/Image/index.module.scss +3 -1
- package/components/componentLibrary/Image/llm.txt +3 -1
- package/components/componentLibrary/Image/stories/ImageDecorator.module.scss +3 -1
- package/components/componentLibrary/Text/ContentFields.tsx +1 -0
- package/components/componentLibrary/Text/index.module.scss +40 -1
- package/components/componentLibrary/Text/llm.txt +4 -2
- package/components/componentLibrary/Video/ContentFields.tsx +1 -0
- package/components/componentLibrary/Video/StyleFields.tsx +42 -0
- package/components/componentLibrary/Video/TrackHSVideoAnalytics.tsx +445 -0
- package/components/componentLibrary/Video/hooks/usePageMeta.tsx +25 -0
- package/components/componentLibrary/Video/hooks/useQueryParam.tsx +12 -0
- package/components/componentLibrary/Video/hooks/useUserToken.tsx +56 -0
- package/components/componentLibrary/Video/index.tsx +11 -2
- package/components/componentLibrary/Video/islands/EmbedVideoIsland.tsx +239 -0
- package/components/componentLibrary/Video/islands/HSVideoIsland.tsx +4 -0
- package/components/componentLibrary/Video/islands/index.module.scss +94 -0
- package/components/componentLibrary/Video/types.ts +22 -0
- package/components/componentLibrary/Video/utils/videoAnalytics.ts +146 -0
- package/package.json +1 -1
- package/components/componentLibrary/Form/islands/legacyForm.module.css +0 -251
|
@@ -12,6 +12,48 @@ const StyleFields = ({
|
|
|
12
12
|
name={playButtonColorName}
|
|
13
13
|
default={playButtonColorDefault}
|
|
14
14
|
showOpacity={false}
|
|
15
|
+
visibilityRules="ADVANCED"
|
|
16
|
+
advancedVisibility={{
|
|
17
|
+
boolean_operator: 'OR',
|
|
18
|
+
criteria: [],
|
|
19
|
+
children: [
|
|
20
|
+
{
|
|
21
|
+
boolean_operator: 'AND',
|
|
22
|
+
criteria: [
|
|
23
|
+
{
|
|
24
|
+
controlling_field: 'videoType',
|
|
25
|
+
operator: 'EQUAL',
|
|
26
|
+
controlling_value_regex: 'hubspot_video',
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
boolean_operator: 'AND',
|
|
32
|
+
criteria: [
|
|
33
|
+
{
|
|
34
|
+
controlling_field: 'videoType',
|
|
35
|
+
operator: 'EQUAL',
|
|
36
|
+
controlling_value_regex: 'embed',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
controlling_field: 'embedVideo',
|
|
40
|
+
operator: 'MATCHES_REGEX',
|
|
41
|
+
controlling_value_regex: '(?=.*"source_type":"oembed")',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
controlling_field: 'embedVideo',
|
|
45
|
+
operator: 'MATCHES_REGEX',
|
|
46
|
+
controlling_value_regex: '(?=.*"oembed_url":"(?!")+)',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
controlling_field: 'oembedThumbnail',
|
|
50
|
+
operator: 'MATCHES_REGEX',
|
|
51
|
+
controlling_value_regex: '(?=.*"src":"(?!")+)',
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
}}
|
|
15
57
|
/>
|
|
16
58
|
);
|
|
17
59
|
};
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { getHSEnv, HSEnvironment } from '@hubspot/cms-components';
|
|
2
|
+
import {
|
|
3
|
+
// @ts-expect-error -- types not flowing through properly
|
|
4
|
+
useVideoPlayer,
|
|
5
|
+
// @ts-expect-error -- types not flowing through properly
|
|
6
|
+
PlayerState,
|
|
7
|
+
// @ts-expect-error -- types not flowing through properly
|
|
8
|
+
VideoCrmObject,
|
|
9
|
+
} from '@hubspot/video-player-core';
|
|
10
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
11
|
+
|
|
12
|
+
import { useUserToken } from './hooks/useUserToken.js';
|
|
13
|
+
import { usePageMeta } from './hooks/usePageMeta.js';
|
|
14
|
+
import {
|
|
15
|
+
trackCompletedPlay,
|
|
16
|
+
trackPlayEvent,
|
|
17
|
+
trackSecondsViewed,
|
|
18
|
+
} from './utils/videoAnalytics.js';
|
|
19
|
+
import { PageMeta } from './types.js';
|
|
20
|
+
|
|
21
|
+
type TrackingProps = {
|
|
22
|
+
env: HSEnvironment;
|
|
23
|
+
portalId: number;
|
|
24
|
+
eventProps: {
|
|
25
|
+
crmObjectId: number;
|
|
26
|
+
sessionId: string;
|
|
27
|
+
contactUtk: string | null;
|
|
28
|
+
pageMeta: PageMeta | null;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const CHUNK_DURATION_SECONDS = 1;
|
|
33
|
+
const RETENTION_TRACKING_INTERVAL_SECONDS = 10;
|
|
34
|
+
const RETENTION_MAX_SESSION_DURATION_SECONDS = 60 * 60; // 1 hour
|
|
35
|
+
|
|
36
|
+
const getTimeRangesDuration = (timeRanges?: TimeRanges) => {
|
|
37
|
+
if (!timeRanges) {
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const durations = [];
|
|
42
|
+
let i = 0;
|
|
43
|
+
while (i < timeRanges.length) {
|
|
44
|
+
durations.push(timeRanges.end(i) - timeRanges.start(i));
|
|
45
|
+
i++;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return durations.reduce((sum, d) => sum + d, 0);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const useTrackVisibilityChange = ({
|
|
52
|
+
isTrackingEnabled,
|
|
53
|
+
trackingStartedAt,
|
|
54
|
+
onVisibilityHidden,
|
|
55
|
+
}: {
|
|
56
|
+
isTrackingEnabled: boolean;
|
|
57
|
+
trackingStartedAt?: number;
|
|
58
|
+
onVisibilityHidden: () => void;
|
|
59
|
+
}) => {
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const handleVisibilityChange = () => {
|
|
62
|
+
if (
|
|
63
|
+
!isTrackingEnabled ||
|
|
64
|
+
!trackingStartedAt ||
|
|
65
|
+
document.visibilityState !== 'hidden'
|
|
66
|
+
) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sessionDuration = (Date.now() - trackingStartedAt) / 1000;
|
|
71
|
+
if (sessionDuration >= RETENTION_MAX_SESSION_DURATION_SECONDS) {
|
|
72
|
+
// Detach visibilitychange listener after 1 hour max session duration
|
|
73
|
+
document.removeEventListener(
|
|
74
|
+
'visibilitychange',
|
|
75
|
+
handleVisibilityChange
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
onVisibilityHidden();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
83
|
+
return () => {
|
|
84
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
85
|
+
};
|
|
86
|
+
}, [isTrackingEnabled, trackingStartedAt, onVisibilityHidden]);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const useInitialPlayTracking = ({
|
|
90
|
+
hasStarted,
|
|
91
|
+
env,
|
|
92
|
+
portalId,
|
|
93
|
+
eventProps,
|
|
94
|
+
}: TrackingProps & {
|
|
95
|
+
hasStarted: boolean;
|
|
96
|
+
}) => {
|
|
97
|
+
const { contactUtk, pageMeta } = eventProps;
|
|
98
|
+
const hasTrackedInitialPlay = useRef(false);
|
|
99
|
+
|
|
100
|
+
const trackInitialPlay = async () => {
|
|
101
|
+
hasTrackedInitialPlay.current = true;
|
|
102
|
+
await trackPlayEvent(env, portalId, {
|
|
103
|
+
...eventProps,
|
|
104
|
+
contactUtk: contactUtk!,
|
|
105
|
+
pageMeta: pageMeta!,
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (!contactUtk || !pageMeta) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!hasTrackedInitialPlay.current && hasStarted) {
|
|
115
|
+
trackInitialPlay();
|
|
116
|
+
}
|
|
117
|
+
}, [hasStarted, contactUtk, pageMeta]);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const useRetentionTracking = ({
|
|
121
|
+
chunksViewed,
|
|
122
|
+
currentTime,
|
|
123
|
+
hasEnded,
|
|
124
|
+
element,
|
|
125
|
+
trackingStartedAt,
|
|
126
|
+
videoDurationSeconds,
|
|
127
|
+
env,
|
|
128
|
+
portalId,
|
|
129
|
+
eventProps,
|
|
130
|
+
}: TrackingProps &
|
|
131
|
+
Pick<PlayerState, 'currentTime' | 'hasEnded' | 'element'> & {
|
|
132
|
+
chunksViewed: Map<number, number>;
|
|
133
|
+
trackingStartedAt?: number;
|
|
134
|
+
videoDurationSeconds: number;
|
|
135
|
+
}) => {
|
|
136
|
+
const { contactUtk, pageMeta } = eventProps;
|
|
137
|
+
const { loop, played } = element || {};
|
|
138
|
+
const lastRetentionReportedAt = useRef(0);
|
|
139
|
+
const chunksViewedSinceReportRef = useRef(new Map<number, number>());
|
|
140
|
+
const hasStoppedTracking = useRef(false);
|
|
141
|
+
const currentChunk = Math.floor(currentTime / CHUNK_DURATION_SECONDS);
|
|
142
|
+
|
|
143
|
+
const trackNewChunksViewed = useCallback(
|
|
144
|
+
async (endState = false) => {
|
|
145
|
+
if (endState === true) {
|
|
146
|
+
hasStoppedTracking.current = true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!chunksViewedSinceReportRef.current.size) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const chunksToReport = Object.fromEntries(
|
|
154
|
+
chunksViewedSinceReportRef.current
|
|
155
|
+
);
|
|
156
|
+
chunksViewedSinceReportRef.current.clear();
|
|
157
|
+
lastRetentionReportedAt.current = Date.now();
|
|
158
|
+
|
|
159
|
+
const retentionParams = {
|
|
160
|
+
secondsToViews: chunksToReport,
|
|
161
|
+
endState,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
await trackSecondsViewed(
|
|
165
|
+
env,
|
|
166
|
+
portalId,
|
|
167
|
+
{ ...eventProps, contactUtk: contactUtk!, pageMeta: pageMeta! },
|
|
168
|
+
retentionParams
|
|
169
|
+
);
|
|
170
|
+
},
|
|
171
|
+
[env, portalId, eventProps]
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const fillRemainingSecondsViewed = () => {
|
|
175
|
+
const totalChunks = Math.ceil(
|
|
176
|
+
videoDurationSeconds / CHUNK_DURATION_SECONDS
|
|
177
|
+
);
|
|
178
|
+
if (chunksViewed.size < totalChunks) {
|
|
179
|
+
// Fill remaining chunks viewed to track for looped video
|
|
180
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
181
|
+
if (
|
|
182
|
+
!chunksViewed.has(i) &&
|
|
183
|
+
!chunksViewedSinceReportRef.current.has(i)
|
|
184
|
+
) {
|
|
185
|
+
chunksViewedSinceReportRef.current.set(i, 1);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Since looped videos don't fire an 'ended' event, determine completion based
|
|
192
|
+
// on played duration or total viewed chunks and track remaining chunks viewed
|
|
193
|
+
const handleLoopedVideo = () => {
|
|
194
|
+
const playedDuration = getTimeRangesDuration(played);
|
|
195
|
+
if (playedDuration >= videoDurationSeconds) {
|
|
196
|
+
// End retention tracking for looped video based on played duration
|
|
197
|
+
fillRemainingSecondsViewed();
|
|
198
|
+
trackNewChunksViewed(true);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const totalChunks = Math.floor(
|
|
203
|
+
videoDurationSeconds / CHUNK_DURATION_SECONDS
|
|
204
|
+
);
|
|
205
|
+
if (chunksViewed.size >= totalChunks) {
|
|
206
|
+
// End retention tracking for looped video watched completely
|
|
207
|
+
trackNewChunksViewed(true);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (
|
|
213
|
+
!contactUtk ||
|
|
214
|
+
!pageMeta ||
|
|
215
|
+
!trackingStartedAt ||
|
|
216
|
+
hasStoppedTracking.current
|
|
217
|
+
) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Check if max session duration exceeded
|
|
222
|
+
const sessionDuration = (Date.now() - trackingStartedAt) / 1000;
|
|
223
|
+
if (sessionDuration >= RETENTION_MAX_SESSION_DURATION_SECONDS) {
|
|
224
|
+
// End retention tracking due to session duration reaching max
|
|
225
|
+
trackNewChunksViewed(false);
|
|
226
|
+
hasStoppedTracking.current = true;
|
|
227
|
+
if (element?.loop) {
|
|
228
|
+
element.loop = false;
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (hasEnded && chunksViewedSinceReportRef.current.size > 0) {
|
|
234
|
+
trackNewChunksViewed(true);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (loop) {
|
|
239
|
+
handleLoopedVideo();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (chunksViewed.has(currentChunk)) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
chunksViewed.set(currentChunk, (chunksViewed.get(currentChunk) || 0) + 1);
|
|
247
|
+
chunksViewedSinceReportRef.current.set(
|
|
248
|
+
currentChunk,
|
|
249
|
+
(chunksViewedSinceReportRef.current.get(currentChunk) || 0) + 1
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
if (!lastRetentionReportedAt.current) {
|
|
253
|
+
lastRetentionReportedAt.current = Date.now();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const secondsSinceLastReport =
|
|
257
|
+
(Date.now() - lastRetentionReportedAt.current) / 1000;
|
|
258
|
+
|
|
259
|
+
if (secondsSinceLastReport >= RETENTION_TRACKING_INTERVAL_SECONDS) {
|
|
260
|
+
trackNewChunksViewed(false);
|
|
261
|
+
}
|
|
262
|
+
}, [
|
|
263
|
+
trackNewChunksViewed,
|
|
264
|
+
trackingStartedAt,
|
|
265
|
+
currentChunk,
|
|
266
|
+
hasEnded,
|
|
267
|
+
contactUtk,
|
|
268
|
+
pageMeta,
|
|
269
|
+
loop,
|
|
270
|
+
played,
|
|
271
|
+
videoDurationSeconds,
|
|
272
|
+
]);
|
|
273
|
+
|
|
274
|
+
const onVisibilityHidden = useCallback(() => {
|
|
275
|
+
if (!hasStoppedTracking.current) {
|
|
276
|
+
// End retention tracking on visibility change
|
|
277
|
+
trackNewChunksViewed(true);
|
|
278
|
+
}
|
|
279
|
+
}, [trackNewChunksViewed]);
|
|
280
|
+
|
|
281
|
+
useTrackVisibilityChange({
|
|
282
|
+
isTrackingEnabled: Boolean(contactUtk && pageMeta),
|
|
283
|
+
trackingStartedAt,
|
|
284
|
+
onVisibilityHidden,
|
|
285
|
+
});
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const useCompletedPlayTracking = ({
|
|
289
|
+
chunksViewed,
|
|
290
|
+
hasEnded,
|
|
291
|
+
element,
|
|
292
|
+
trackingStartedAt,
|
|
293
|
+
videoDurationSeconds,
|
|
294
|
+
env,
|
|
295
|
+
portalId,
|
|
296
|
+
eventProps,
|
|
297
|
+
}: TrackingProps &
|
|
298
|
+
Pick<PlayerState, 'hasEnded' | 'element'> & {
|
|
299
|
+
chunksViewed: Map<number, number>;
|
|
300
|
+
trackingStartedAt?: number;
|
|
301
|
+
videoDurationSeconds: number;
|
|
302
|
+
}) => {
|
|
303
|
+
const { contactUtk, pageMeta } = eventProps;
|
|
304
|
+
const { loop, played } = element || {};
|
|
305
|
+
const hasTrackedCompletion = useRef(false);
|
|
306
|
+
|
|
307
|
+
const trackCompletion = useCallback(async () => {
|
|
308
|
+
const secondsViewed = chunksViewed.size * CHUNK_DURATION_SECONDS;
|
|
309
|
+
if (
|
|
310
|
+
!videoDurationSeconds ||
|
|
311
|
+
!secondsViewed ||
|
|
312
|
+
hasTrackedCompletion.current
|
|
313
|
+
) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
hasTrackedCompletion.current = true;
|
|
318
|
+
const completedPlayParams = {
|
|
319
|
+
secondsViewed,
|
|
320
|
+
percentageViewed: Math.round(
|
|
321
|
+
(secondsViewed / videoDurationSeconds) * 100
|
|
322
|
+
),
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
await trackCompletedPlay(
|
|
326
|
+
env,
|
|
327
|
+
portalId,
|
|
328
|
+
{ ...eventProps, contactUtk: contactUtk!, pageMeta: pageMeta! },
|
|
329
|
+
completedPlayParams
|
|
330
|
+
);
|
|
331
|
+
}, [env, portalId, eventProps, videoDurationSeconds]);
|
|
332
|
+
|
|
333
|
+
// Since looped videos don't fire an 'ended' event, determine completion based
|
|
334
|
+
// on played duration or total viewed chunks and track completed-play
|
|
335
|
+
const handleLoopedVideo = () => {
|
|
336
|
+
const playedDuration = getTimeRangesDuration(played);
|
|
337
|
+
if (playedDuration >= videoDurationSeconds) {
|
|
338
|
+
// Track completed play for looped video based on played duration
|
|
339
|
+
trackCompletion();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const totalChunks = Math.floor(
|
|
344
|
+
videoDurationSeconds / CHUNK_DURATION_SECONDS
|
|
345
|
+
);
|
|
346
|
+
if (chunksViewed.size >= totalChunks) {
|
|
347
|
+
// Track completed play for looped video watched completely
|
|
348
|
+
trackCompletion();
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
if (!contactUtk || !pageMeta || hasTrackedCompletion.current) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (hasEnded) {
|
|
358
|
+
trackCompletion();
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (loop) {
|
|
363
|
+
handleLoopedVideo();
|
|
364
|
+
}
|
|
365
|
+
}, [
|
|
366
|
+
trackCompletion,
|
|
367
|
+
hasEnded,
|
|
368
|
+
contactUtk,
|
|
369
|
+
pageMeta,
|
|
370
|
+
loop,
|
|
371
|
+
played,
|
|
372
|
+
videoDurationSeconds,
|
|
373
|
+
]);
|
|
374
|
+
|
|
375
|
+
const onVisibilityHidden = useCallback(() => {
|
|
376
|
+
if (!hasTrackedCompletion.current) {
|
|
377
|
+
// End retention tracking on visibility change
|
|
378
|
+
trackCompletion();
|
|
379
|
+
}
|
|
380
|
+
}, [trackCompletion]);
|
|
381
|
+
|
|
382
|
+
useTrackVisibilityChange({
|
|
383
|
+
isTrackingEnabled: Boolean(contactUtk && pageMeta),
|
|
384
|
+
trackingStartedAt,
|
|
385
|
+
onVisibilityHidden,
|
|
386
|
+
});
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const TrackHSVideoAnalytics = ({
|
|
390
|
+
video,
|
|
391
|
+
portalId,
|
|
392
|
+
}: {
|
|
393
|
+
video: VideoCrmObject;
|
|
394
|
+
portalId: number;
|
|
395
|
+
}) => {
|
|
396
|
+
const hsEnv = getHSEnv();
|
|
397
|
+
const { utk: contactUtk } = useUserToken();
|
|
398
|
+
const { state } = useVideoPlayer();
|
|
399
|
+
const { currentTime, hasStarted, hasEnded, element, playbackStartedAt } =
|
|
400
|
+
state;
|
|
401
|
+
const chunksViewedRef = useRef(new Map<number, number>());
|
|
402
|
+
const pageMeta = usePageMeta();
|
|
403
|
+
// session ID used to match events across initial/secondsViewed/completed events
|
|
404
|
+
const sessionId = useRef(String(Date.now()));
|
|
405
|
+
|
|
406
|
+
const eventProps = useMemo(
|
|
407
|
+
() => ({
|
|
408
|
+
pageMeta,
|
|
409
|
+
crmObjectId: video.crmObjectId,
|
|
410
|
+
sessionId: sessionId.current,
|
|
411
|
+
contactUtk,
|
|
412
|
+
}),
|
|
413
|
+
[pageMeta, video, contactUtk]
|
|
414
|
+
);
|
|
415
|
+
const videoDurationSeconds = video.duration / 1000;
|
|
416
|
+
const sharedProps = { env: hsEnv, portalId, eventProps };
|
|
417
|
+
|
|
418
|
+
useInitialPlayTracking({
|
|
419
|
+
...sharedProps,
|
|
420
|
+
hasStarted,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
useRetentionTracking({
|
|
424
|
+
...sharedProps,
|
|
425
|
+
chunksViewed: chunksViewedRef.current,
|
|
426
|
+
currentTime,
|
|
427
|
+
hasEnded,
|
|
428
|
+
element,
|
|
429
|
+
trackingStartedAt: playbackStartedAt,
|
|
430
|
+
videoDurationSeconds,
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
useCompletedPlayTracking({
|
|
434
|
+
...sharedProps,
|
|
435
|
+
chunksViewed: chunksViewedRef.current,
|
|
436
|
+
hasEnded,
|
|
437
|
+
element,
|
|
438
|
+
trackingStartedAt: playbackStartedAt,
|
|
439
|
+
videoDurationSeconds,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
return null;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
export default TrackHSVideoAnalytics;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { PageMeta } from '../types.js';
|
|
3
|
+
import { useQueryParam } from './useQueryParam.js';
|
|
4
|
+
|
|
5
|
+
export const usePageMeta = () => {
|
|
6
|
+
const [pageMeta, setPageMeta] = useState<PageMeta | null>(null);
|
|
7
|
+
const hsencParam = useQueryParam('_hsenc');
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const pageId = window.hsVars && window.hsVars.page_id;
|
|
11
|
+
const data: PageMeta = {
|
|
12
|
+
pageId: pageId ?? 0,
|
|
13
|
+
pageUrl: window.location.href,
|
|
14
|
+
pageTitle: document.title,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
if (hsencParam) {
|
|
18
|
+
data._hsenc = hsencParam;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
setPageMeta(data);
|
|
22
|
+
}, [hsencParam]);
|
|
23
|
+
|
|
24
|
+
return pageMeta;
|
|
25
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export const useQueryParam = (param: string) => {
|
|
4
|
+
const [value, setValue] = useState<string | null>(null);
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
// Setting value in useEffect since `window` won't be available during SSR
|
|
7
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
8
|
+
setValue(urlParams.get(param));
|
|
9
|
+
}, [param]);
|
|
10
|
+
|
|
11
|
+
return value;
|
|
12
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { PrivacyConsent } from '../types.js';
|
|
3
|
+
|
|
4
|
+
export const useUserToken = () => {
|
|
5
|
+
const [utk, setUtk] = useState<string | null>(null);
|
|
6
|
+
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
let privacyConsent: PrivacyConsent;
|
|
9
|
+
let isMounted = true;
|
|
10
|
+
|
|
11
|
+
const areAnalyticsAllowed = () => {
|
|
12
|
+
if (!privacyConsent) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return privacyConsent.categories
|
|
16
|
+
? privacyConsent.categories.analytics
|
|
17
|
+
: privacyConsent.allowed;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const onTrackingCodeLoaded = (_utk: string | null) => {
|
|
21
|
+
if (!privacyConsent || !_utk || !isMounted) {
|
|
22
|
+
// Exit if utk and privacy consent cannot be obtained, or component unmounted
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (areAnalyticsAllowed()) {
|
|
27
|
+
setUtk(_utk);
|
|
28
|
+
} else {
|
|
29
|
+
setUtk(null);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
if (window._hsq?.push) {
|
|
34
|
+
window._hsq.push([
|
|
35
|
+
'addPrivacyConsentListener',
|
|
36
|
+
(_consent: PrivacyConsent) => {
|
|
37
|
+
privacyConsent = _consent;
|
|
38
|
+
|
|
39
|
+
if (window.__hsUserToken && areAnalyticsAllowed()) {
|
|
40
|
+
if (isMounted) {
|
|
41
|
+
setUtk(window.__hsUserToken);
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
window._hsq?.push(['addUserTokenListener', onTrackingCodeLoaded]);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return () => {
|
|
51
|
+
isMounted = false;
|
|
52
|
+
};
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
return { utk };
|
|
56
|
+
};
|
|
@@ -2,6 +2,8 @@ import { Island } from '@hubspot/cms-components';
|
|
|
2
2
|
import ContentFields from './ContentFields.js';
|
|
3
3
|
import StyleFields from './StyleFields.js';
|
|
4
4
|
// @ts-expect-error -- ?island not typed
|
|
5
|
+
import EmbedVideoIsland from './islands/EmbedVideoIsland.js?island';
|
|
6
|
+
// @ts-expect-error -- ?island not typed
|
|
5
7
|
import HSVideoIsland from './islands/HSVideoIsland.js?island';
|
|
6
8
|
import { VideoProps } from './types.js';
|
|
7
9
|
|
|
@@ -9,6 +11,7 @@ const VideoComponent = ({
|
|
|
9
11
|
videoType,
|
|
10
12
|
hubspotVideoParams,
|
|
11
13
|
embedVideoParams,
|
|
14
|
+
oembedThumbnail,
|
|
12
15
|
playButtonColor,
|
|
13
16
|
video,
|
|
14
17
|
}: VideoProps) => {
|
|
@@ -28,8 +31,14 @@ const VideoComponent = ({
|
|
|
28
31
|
videoType === 'embed' &&
|
|
29
32
|
Boolean(embedVideoParams?.oembed_url || embedVideoParams?.embed_html)
|
|
30
33
|
) {
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
return (
|
|
35
|
+
<Island
|
|
36
|
+
module={EmbedVideoIsland}
|
|
37
|
+
embedVideoParams={embedVideoParams}
|
|
38
|
+
oembedThumbnail={oembedThumbnail}
|
|
39
|
+
playButtonColor={playButtonColor}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
return null;
|