@windstream/react-shared-components 0.2.49 → 0.2.51
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/dist/contentful/index.d.ts +12 -1
- package/dist/contentful/index.esm.js +3 -3
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +3 -3
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +3 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.esm.js +6 -6
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/dist/next/index.esm.js +2 -2
- package/dist/next/index.esm.js.map +1 -1
- package/dist/next/index.js +2 -2
- package/dist/next/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -1
- package/src/components/next-image/index.tsx +10 -1
- package/src/components/video-link/contentful-video-embed.tsx +17 -1
- package/src/components/video-link/index.test.tsx +196 -24
- package/src/components/video-link/index.tsx +84 -15
- package/src/components/video-link/types.ts +15 -1
- package/src/components/video-link/vimeo-embed.tsx +51 -2
- package/src/components/video-link/youtube-embed.test.tsx +81 -54
- package/src/components/video-link/youtube-embed.tsx +97 -16
- package/src/components/video-link/youtube-iframe-api-loader.ts +45 -0
- package/src/contentful/blocks/callout/index.test.tsx +2 -2
- package/src/contentful/blocks/callout/index.tsx +1 -0
- package/src/contentful/blocks/cards/blog-card/index.tsx +9 -2
- package/src/contentful/blocks/cards/blog-card/types.ts +4 -0
- package/src/contentful/blocks/cards/floating-image-card/index.tsx +20 -6
- package/src/contentful/blocks/cards/floating-image-card/types.ts +4 -0
- package/src/contentful/blocks/cards/full-image-card/index.tsx +2 -1
- package/src/contentful/blocks/cards/full-image-card/types.ts +4 -0
- package/src/contentful/blocks/cards/simple-card/index.tsx +2 -2
- package/src/contentful/blocks/cards/simple-card/types.ts +4 -0
- package/src/contentful/blocks/find-kinetic/index.tsx +20 -7
- package/src/contentful/blocks/find-kinetic/types.ts +1 -0
- package/src/contentful/blocks/image-promo-bar/index.test.tsx +2 -2
- package/src/contentful/blocks/image-promo-bar/index.tsx +4 -1
- package/src/contentful/blocks/image-promo-bar/types.ts +6 -1
- package/src/contentful/blocks/primary-hero/index.tsx +6 -6
- package/src/hooks/contentful/use-contentful-rich-text.test.tsx +6 -6
- package/src/hooks/use-fullscreen-toggle.ts +23 -0
- package/src/hooks/use-video-analytics.ts +126 -0
- package/src/types/vimeo-player.d.ts +11 -0
|
@@ -17,16 +17,50 @@ jest.mock("./vimeo-embed", () => ({
|
|
|
17
17
|
VimeoEmbed: ({ link, autoplay }: any) => (
|
|
18
18
|
<div data-testid="vimeo-embed" data-link={link} data-autoplay={autoplay} />
|
|
19
19
|
),
|
|
20
|
+
getVimeoEmbedId: (link?: string) => (link?.includes("vimeo") ? "123456" : ""),
|
|
20
21
|
}));
|
|
21
22
|
|
|
22
23
|
jest.mock("./youtube-embed", () => ({
|
|
23
|
-
YoutubeEmbed: ({
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
YoutubeEmbed: ({
|
|
25
|
+
link,
|
|
26
|
+
autoplay,
|
|
27
|
+
onPlay,
|
|
28
|
+
onPause,
|
|
29
|
+
onTimeUpdate,
|
|
30
|
+
onEnded,
|
|
31
|
+
onFullscreenToggle,
|
|
32
|
+
}: any) => (
|
|
33
|
+
<div data-testid="youtube-embed" data-link={link} data-autoplay={autoplay}>
|
|
34
|
+
<button data-testid="yt-play" onClick={() => onPlay?.()} />
|
|
35
|
+
<button data-testid="yt-pause" onClick={() => onPause?.()} />
|
|
36
|
+
<button data-testid="yt-ended" onClick={() => onEnded?.()} />
|
|
37
|
+
<button
|
|
38
|
+
data-testid="yt-fullscreen-on"
|
|
39
|
+
onClick={() => onFullscreenToggle?.(true)}
|
|
40
|
+
/>
|
|
41
|
+
<button
|
|
42
|
+
data-testid="yt-fullscreen-off"
|
|
43
|
+
onClick={() => onFullscreenToggle?.(false)}
|
|
44
|
+
/>
|
|
45
|
+
<button
|
|
46
|
+
data-testid="yt-progress-25"
|
|
47
|
+
onClick={() => onTimeUpdate?.(25, 100)}
|
|
48
|
+
/>
|
|
49
|
+
<button
|
|
50
|
+
data-testid="yt-progress-50"
|
|
51
|
+
onClick={() => onTimeUpdate?.(50, 100)}
|
|
52
|
+
/>
|
|
53
|
+
<button
|
|
54
|
+
data-testid="yt-progress-75"
|
|
55
|
+
onClick={() => onTimeUpdate?.(75, 100)}
|
|
56
|
+
/>
|
|
57
|
+
<button
|
|
58
|
+
data-testid="yt-progress-100"
|
|
59
|
+
onClick={() => onTimeUpdate?.(100, 100)}
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
29
62
|
),
|
|
63
|
+
getYoutubeEmbedId: (link?: string) => (link ? "dQw4w9WgXcQ" : ""),
|
|
30
64
|
}));
|
|
31
65
|
|
|
32
66
|
jest.mock("./helper", () => ({
|
|
@@ -82,22 +116,6 @@ describe("VideoLink", () => {
|
|
|
82
116
|
expect(container.innerHTML).toBe("");
|
|
83
117
|
});
|
|
84
118
|
|
|
85
|
-
it("falls back to videoLink.link when videoSourceType is 'contentful' but videoAssetUrl is missing", () => {
|
|
86
|
-
const { container } = render(
|
|
87
|
-
<VideoLink
|
|
88
|
-
videoLink={{
|
|
89
|
-
videoSourceType: "contentful",
|
|
90
|
-
link: "https://youtube.com/watch?v=123",
|
|
91
|
-
}}
|
|
92
|
-
/>
|
|
93
|
-
);
|
|
94
|
-
clickVideoSection(container);
|
|
95
|
-
expect(screen.getByTestId("youtube-embed")).toHaveAttribute(
|
|
96
|
-
"data-link",
|
|
97
|
-
"https://youtube.com/watch?v=123"
|
|
98
|
-
);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
119
|
it("falls back to the poster when videoSourceType is 'contentful' but videoAssetUrl is missing", () => {
|
|
102
120
|
render(
|
|
103
121
|
<VideoLink
|
|
@@ -238,10 +256,26 @@ describe("VideoLink", () => {
|
|
|
238
256
|
/>
|
|
239
257
|
);
|
|
240
258
|
clickVideoSection(container);
|
|
241
|
-
fireEvent.click(
|
|
259
|
+
fireEvent.click(screen.getByTestId("video-popup-content"));
|
|
242
260
|
expect(container.querySelector(".fixed")).toBeInTheDocument();
|
|
243
261
|
});
|
|
244
262
|
|
|
263
|
+
it("closes popup when clicking the visible close button", () => {
|
|
264
|
+
const { container } = render(
|
|
265
|
+
<VideoLink
|
|
266
|
+
videoLink={{
|
|
267
|
+
link: "https://youtube.com/watch?v=123",
|
|
268
|
+
image: "thumb.jpg",
|
|
269
|
+
videoPopup: true,
|
|
270
|
+
}}
|
|
271
|
+
/>
|
|
272
|
+
);
|
|
273
|
+
clickVideoSection(container);
|
|
274
|
+
expect(screen.getByTestId("video-popup-close")).toBeInTheDocument();
|
|
275
|
+
fireEvent.click(screen.getByTestId("video-popup-close"));
|
|
276
|
+
expect(container.querySelector(".fixed")).not.toBeInTheDocument();
|
|
277
|
+
});
|
|
278
|
+
|
|
245
279
|
it("handles hover state on the video section", () => {
|
|
246
280
|
const { container } = render(
|
|
247
281
|
<VideoLink
|
|
@@ -263,4 +297,142 @@ describe("VideoLink", () => {
|
|
|
263
297
|
"false"
|
|
264
298
|
);
|
|
265
299
|
});
|
|
266
|
-
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe("VideoLink analytics", () => {
|
|
303
|
+
const videoLink = {
|
|
304
|
+
link: "https://youtube.com/watch?v=dQw4w9WgXcQ",
|
|
305
|
+
image: "thumb.jpg",
|
|
306
|
+
title: "My Video",
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
it("fires 'played' with unified schema properties when the embed reports play", () => {
|
|
310
|
+
const onVideoEvent = jest.fn();
|
|
311
|
+
const { container } = render(
|
|
312
|
+
<VideoLink videoLink={videoLink} onVideoEvent={onVideoEvent} />
|
|
313
|
+
);
|
|
314
|
+
clickVideoSection(container);
|
|
315
|
+
fireEvent.click(screen.getByTestId("yt-play"));
|
|
316
|
+
|
|
317
|
+
expect(onVideoEvent).toHaveBeenCalledWith("played", {
|
|
318
|
+
videoId: "dQw4w9WgXcQ",
|
|
319
|
+
videoTitle: "My Video",
|
|
320
|
+
provider: "youtube",
|
|
321
|
+
position: 0,
|
|
322
|
+
duration: 0,
|
|
323
|
+
percentComplete: 0,
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("fires 'paused' when the embed reports pause", () => {
|
|
328
|
+
const onVideoEvent = jest.fn();
|
|
329
|
+
const { container } = render(
|
|
330
|
+
<VideoLink videoLink={videoLink} onVideoEvent={onVideoEvent} />
|
|
331
|
+
);
|
|
332
|
+
clickVideoSection(container);
|
|
333
|
+
fireEvent.click(screen.getByTestId("yt-pause"));
|
|
334
|
+
|
|
335
|
+
expect(onVideoEvent).toHaveBeenCalledWith(
|
|
336
|
+
"paused",
|
|
337
|
+
expect.objectContaining({ provider: "youtube" })
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("fires 'progress' exactly once per 25/50/75 milestone, not on every timeupdate", () => {
|
|
342
|
+
const onVideoEvent = jest.fn();
|
|
343
|
+
const { container } = render(
|
|
344
|
+
<VideoLink videoLink={videoLink} onVideoEvent={onVideoEvent} />
|
|
345
|
+
);
|
|
346
|
+
clickVideoSection(container);
|
|
347
|
+
|
|
348
|
+
// Simulate several timeupdate ticks crossing the same milestone.
|
|
349
|
+
fireEvent.click(screen.getByTestId("yt-progress-25"));
|
|
350
|
+
fireEvent.click(screen.getByTestId("yt-progress-25"));
|
|
351
|
+
fireEvent.click(screen.getByTestId("yt-progress-50"));
|
|
352
|
+
fireEvent.click(screen.getByTestId("yt-progress-75"));
|
|
353
|
+
|
|
354
|
+
const progressCalls = onVideoEvent.mock.calls.filter(
|
|
355
|
+
([event]) => event === "progress"
|
|
356
|
+
);
|
|
357
|
+
expect(progressCalls).toHaveLength(3);
|
|
358
|
+
expect(progressCalls[0][1]).toMatchObject({ percentComplete: 25 });
|
|
359
|
+
expect(progressCalls[1][1]).toMatchObject({ percentComplete: 50 });
|
|
360
|
+
expect(progressCalls[2][1]).toMatchObject({ percentComplete: 75 });
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("fires 'completed' exactly once, even if both timeupdate(100%) and ended fire", () => {
|
|
364
|
+
const onVideoEvent = jest.fn();
|
|
365
|
+
const { container } = render(
|
|
366
|
+
<VideoLink videoLink={videoLink} onVideoEvent={onVideoEvent} />
|
|
367
|
+
);
|
|
368
|
+
clickVideoSection(container);
|
|
369
|
+
|
|
370
|
+
fireEvent.click(screen.getByTestId("yt-progress-100"));
|
|
371
|
+
fireEvent.click(screen.getByTestId("yt-ended"));
|
|
372
|
+
|
|
373
|
+
const completedCalls = onVideoEvent.mock.calls.filter(
|
|
374
|
+
([event]) => event === "completed"
|
|
375
|
+
);
|
|
376
|
+
expect(completedCalls).toHaveLength(1);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("fires 'fullscreen_toggled' with isFullscreen for each toggle", () => {
|
|
380
|
+
const onVideoEvent = jest.fn();
|
|
381
|
+
const { container } = render(
|
|
382
|
+
<VideoLink videoLink={videoLink} onVideoEvent={onVideoEvent} />
|
|
383
|
+
);
|
|
384
|
+
clickVideoSection(container);
|
|
385
|
+
|
|
386
|
+
fireEvent.click(screen.getByTestId("yt-fullscreen-on"));
|
|
387
|
+
fireEvent.click(screen.getByTestId("yt-fullscreen-off"));
|
|
388
|
+
|
|
389
|
+
expect(onVideoEvent).toHaveBeenCalledWith(
|
|
390
|
+
"fullscreen_toggled",
|
|
391
|
+
expect.objectContaining({ isFullscreen: true })
|
|
392
|
+
);
|
|
393
|
+
expect(onVideoEvent).toHaveBeenCalledWith(
|
|
394
|
+
"fullscreen_toggled",
|
|
395
|
+
expect.objectContaining({ isFullscreen: false })
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("fires 'closed' with the last known position/duration when the popup is closed", () => {
|
|
400
|
+
const onVideoEvent = jest.fn();
|
|
401
|
+
const { container } = render(
|
|
402
|
+
<VideoLink
|
|
403
|
+
videoLink={{ ...videoLink, videoPopup: true }}
|
|
404
|
+
onVideoEvent={onVideoEvent}
|
|
405
|
+
/>
|
|
406
|
+
);
|
|
407
|
+
clickVideoSection(container);
|
|
408
|
+
fireEvent.click(screen.getByTestId("yt-progress-50"));
|
|
409
|
+
fireEvent.click(screen.getByTestId("video-popup-close"));
|
|
410
|
+
|
|
411
|
+
expect(onVideoEvent).toHaveBeenCalledWith(
|
|
412
|
+
"closed",
|
|
413
|
+
expect.objectContaining({ position: 50, duration: 100 })
|
|
414
|
+
);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("resets milestone tracking on a fresh play session after closing", () => {
|
|
418
|
+
const onVideoEvent = jest.fn();
|
|
419
|
+
const { container } = render(
|
|
420
|
+
<VideoLink
|
|
421
|
+
videoLink={{ ...videoLink, videoPopup: true }}
|
|
422
|
+
onVideoEvent={onVideoEvent}
|
|
423
|
+
/>
|
|
424
|
+
);
|
|
425
|
+
clickVideoSection(container);
|
|
426
|
+
fireEvent.click(screen.getByTestId("yt-progress-25"));
|
|
427
|
+
fireEvent.click(screen.getByTestId("video-popup-close"));
|
|
428
|
+
|
|
429
|
+
onVideoEvent.mockClear();
|
|
430
|
+
clickVideoSection(container);
|
|
431
|
+
fireEvent.click(screen.getByTestId("yt-progress-25"));
|
|
432
|
+
|
|
433
|
+
const progressCalls = onVideoEvent.mock.calls.filter(
|
|
434
|
+
([event]) => event === "progress"
|
|
435
|
+
);
|
|
436
|
+
expect(progressCalls).toHaveLength(1);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
@@ -2,12 +2,25 @@ import React, { useState } from "react";
|
|
|
2
2
|
import { ContentfulVideoEmbed } from "./contentful-video-embed";
|
|
3
3
|
import { PlayButton } from "./helper";
|
|
4
4
|
import { VideoLinkComponentProps } from "./types";
|
|
5
|
-
import { VimeoEmbed } from "./vimeo-embed";
|
|
6
|
-
import { YoutubeEmbed } from "./youtube-embed";
|
|
5
|
+
import { getVimeoEmbedId, VimeoEmbed } from "./vimeo-embed";
|
|
6
|
+
import { getYoutubeEmbedId, YoutubeEmbed } from "./youtube-embed";
|
|
7
7
|
|
|
8
|
+
import { MaterialIcon } from "@shared/components/material-icon";
|
|
8
9
|
import { NextImage } from "@shared/components/next-image";
|
|
10
|
+
import {
|
|
11
|
+
useVideoAnalytics,
|
|
12
|
+
VideoProvider,
|
|
13
|
+
} from "@shared/hooks/use-video-analytics";
|
|
9
14
|
import { cx } from "@shared/utils";
|
|
10
15
|
|
|
16
|
+
// Derives a stable id for the Contentful-hosted case, which has no natural
|
|
17
|
+
// provider id the way a YouTube/Vimeo URL does.
|
|
18
|
+
const getContentfulVideoId = (assetUrl?: unknown): string => {
|
|
19
|
+
if (typeof assetUrl !== "string" || !assetUrl) return "";
|
|
20
|
+
const [path] = assetUrl.split(/[?#]/);
|
|
21
|
+
return path.split("/").filter(Boolean).pop() ?? assetUrl;
|
|
22
|
+
};
|
|
23
|
+
|
|
11
24
|
// Owns all video state (active/hover) so ImagePromoBar stays state-free for video.
|
|
12
25
|
export const VideoLink: React.FC<VideoLinkComponentProps> = ({
|
|
13
26
|
videoLink,
|
|
@@ -15,15 +28,21 @@ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
|
|
|
15
28
|
imageHeight = 660,
|
|
16
29
|
useOriginalImageAspectRatio = false,
|
|
17
30
|
isAboveTheFold = false,
|
|
31
|
+
onVideoEvent,
|
|
18
32
|
}) => {
|
|
19
33
|
const [activeVideo, setActiveVideo] = useState("");
|
|
20
34
|
const [isHovered, setIsHovered] = useState(false);
|
|
35
|
+
const videoAnalytics = useVideoAnalytics(onVideoEvent);
|
|
21
36
|
|
|
22
37
|
const videoSourceType = videoLink?.videoSourceType;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
38
|
+
// CMS data crossing a boundary - guard against a malformed/unresolved
|
|
39
|
+
// reference coming through as a truthy non-string (e.g. an unresolved
|
|
40
|
+
// Contentful Link stub), not just null/undefined.
|
|
41
|
+
const rawVideoHref =
|
|
42
|
+
videoSourceType === "contentful"
|
|
43
|
+
? videoLink?.videoAssetUrl
|
|
44
|
+
: videoLink?.link;
|
|
45
|
+
const videoHref = typeof rawVideoHref === "string" ? rawVideoHref : "";
|
|
27
46
|
|
|
28
47
|
// The video's own poster - never the promo bar's plain image field.
|
|
29
48
|
const posterImage = videoLink?.image;
|
|
@@ -59,17 +78,46 @@ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
|
|
|
59
78
|
const videoPopup = videoLink?.videoPopup ?? false;
|
|
60
79
|
const isPlaying = Boolean(activeVideo && activeVideo === videoHref);
|
|
61
80
|
|
|
81
|
+
const provider: VideoProvider =
|
|
82
|
+
videoSourceType === "contentful"
|
|
83
|
+
? "contentful"
|
|
84
|
+
: videoHref.includes("vimeo")
|
|
85
|
+
? "vimeo"
|
|
86
|
+
: "youtube";
|
|
87
|
+
const videoId =
|
|
88
|
+
provider === "contentful"
|
|
89
|
+
? getContentfulVideoId(videoLink?.videoAssetUrl)
|
|
90
|
+
: provider === "vimeo"
|
|
91
|
+
? getVimeoEmbedId(videoHref)
|
|
92
|
+
: getYoutubeEmbedId(videoHref);
|
|
93
|
+
const analyticsMeta = { videoId, videoTitle: videoLink?.title, provider };
|
|
94
|
+
|
|
62
95
|
const handlePlayClick = () => {
|
|
96
|
+
// Guards against a click inside the already-playing area (e.g. on a
|
|
97
|
+
// native <video>'s own controls) bubbling up to this section's onClick
|
|
98
|
+
// and wiping analytics/session state - only a fresh play should reset.
|
|
99
|
+
if (isPlaying) return;
|
|
100
|
+
videoAnalytics.reset();
|
|
63
101
|
setActiveVideo(videoHref);
|
|
64
102
|
};
|
|
65
103
|
|
|
66
104
|
const handleCloseVideo = (e: React.MouseEvent) => {
|
|
67
105
|
e.stopPropagation();
|
|
106
|
+
videoAnalytics.handleClosed(analyticsMeta);
|
|
68
107
|
setActiveVideo("");
|
|
69
108
|
};
|
|
70
109
|
|
|
71
110
|
const embedSelector = () => {
|
|
72
|
-
|
|
111
|
+
const analyticsProps = {
|
|
112
|
+
onPlay: () => videoAnalytics.handlePlay(analyticsMeta),
|
|
113
|
+
onPause: () => videoAnalytics.handlePause(analyticsMeta),
|
|
114
|
+
onTimeUpdate: (position: number, duration: number) =>
|
|
115
|
+
videoAnalytics.handleTimeUpdate(analyticsMeta, position, duration),
|
|
116
|
+
onEnded: () => videoAnalytics.handleEnded(analyticsMeta),
|
|
117
|
+
onFullscreenToggle: (isFullscreen: boolean) =>
|
|
118
|
+
videoAnalytics.handleFullscreenToggle(analyticsMeta, isFullscreen),
|
|
119
|
+
};
|
|
120
|
+
if (videoSourceType === "contentful") {
|
|
73
121
|
return (
|
|
74
122
|
<ContentfulVideoEmbed
|
|
75
123
|
videoAssetUrl={videoLink?.videoAssetUrl}
|
|
@@ -78,13 +126,18 @@ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
|
|
|
78
126
|
title={videoLink?.title}
|
|
79
127
|
description={videoLink?.description}
|
|
80
128
|
autoplay={isPlaying}
|
|
129
|
+
{...analyticsProps}
|
|
81
130
|
/>
|
|
82
131
|
);
|
|
83
132
|
}
|
|
84
133
|
if (videoHref.includes("vimeo")) {
|
|
85
|
-
return
|
|
134
|
+
return (
|
|
135
|
+
<VimeoEmbed link={videoHref} autoplay={isPlaying} {...analyticsProps} />
|
|
136
|
+
);
|
|
86
137
|
}
|
|
87
|
-
return
|
|
138
|
+
return (
|
|
139
|
+
<YoutubeEmbed link={videoHref} autoplay={isPlaying} {...analyticsProps} />
|
|
140
|
+
);
|
|
88
141
|
};
|
|
89
142
|
const contentVideo = embedSelector();
|
|
90
143
|
|
|
@@ -92,7 +145,7 @@ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
|
|
|
92
145
|
<>
|
|
93
146
|
<div
|
|
94
147
|
className={cx(
|
|
95
|
-
"video-section relative
|
|
148
|
+
"video-section relative w-full cursor-pointer rounded-image transition-all duration-300 lg:w-[486px]",
|
|
96
149
|
!isPlaying && "hover:ring-2 hover:ring-border-focus"
|
|
97
150
|
)}
|
|
98
151
|
onClick={handlePlayClick}
|
|
@@ -140,13 +193,23 @@ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
|
|
|
140
193
|
{/* Video Popup Overlay */}
|
|
141
194
|
{videoPopup && isPlaying && (
|
|
142
195
|
<div
|
|
143
|
-
className="fixed inset-0
|
|
196
|
+
className="fixed inset-0 z-[100] flex items-center justify-center p-4 transition-all duration-300"
|
|
144
197
|
onClick={handleCloseVideo}
|
|
145
198
|
>
|
|
146
199
|
<div
|
|
147
|
-
|
|
200
|
+
data-testid="video-popup-content"
|
|
201
|
+
className="relative aspect-video w-[min(100%,calc(80vh*16/9))] max-w-[1280px] overflow-hidden rounded-2xl bg-black shadow-2xl md:rounded-3xl"
|
|
148
202
|
onClick={e => e.stopPropagation()}
|
|
149
203
|
>
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
onClick={handleCloseVideo}
|
|
207
|
+
aria-label="Close video"
|
|
208
|
+
data-testid="video-popup-close"
|
|
209
|
+
className="absolute right-3 top-3 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white transition-colors hover:bg-black/80 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white sm:right-4 sm:top-4 sm:h-10 sm:w-10"
|
|
210
|
+
>
|
|
211
|
+
<MaterialIcon name="close" className="text-white" />
|
|
212
|
+
</button>
|
|
150
213
|
{contentVideo}
|
|
151
214
|
</div>
|
|
152
215
|
</div>
|
|
@@ -159,12 +222,18 @@ export default VideoLink;
|
|
|
159
222
|
|
|
160
223
|
export { ContentfulVideoEmbed } from "./contentful-video-embed";
|
|
161
224
|
export { PlayButton } from "./helper";
|
|
162
|
-
export { VimeoEmbed } from "./vimeo-embed";
|
|
163
|
-
export { YoutubeEmbed } from "./youtube-embed";
|
|
225
|
+
export { getVimeoEmbedId, VimeoEmbed } from "./vimeo-embed";
|
|
226
|
+
export { getYoutubeEmbedId, YoutubeEmbed } from "./youtube-embed";
|
|
164
227
|
|
|
165
228
|
export type {
|
|
166
229
|
PlayButtonProps,
|
|
167
230
|
VideoEmbedProps,
|
|
168
231
|
VideoLinkComponentProps,
|
|
169
232
|
VideoLinkProps,
|
|
170
|
-
} from "./types";
|
|
233
|
+
} from "./types";
|
|
234
|
+
export type {
|
|
235
|
+
VideoAnalyticsEventName,
|
|
236
|
+
VideoAnalyticsHandler,
|
|
237
|
+
VideoAnalyticsPayload,
|
|
238
|
+
VideoProvider,
|
|
239
|
+
} from "@shared/hooks/use-video-analytics";
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { VideoAnalyticsHandler } from "@shared/hooks/use-video-analytics";
|
|
2
|
+
|
|
1
3
|
export type VideoLinkProps = {
|
|
2
4
|
image?: string;
|
|
3
5
|
videoPopup?: boolean;
|
|
@@ -9,11 +11,22 @@ export type VideoLinkProps = {
|
|
|
9
11
|
description?: string;
|
|
10
12
|
};
|
|
11
13
|
|
|
14
|
+
// Raw playback callbacks every provider embed reports; VideoLink is the only
|
|
15
|
+
// place that turns these into the unified analytics event schema.
|
|
16
|
+
export type VideoEmbedAnalyticsProps = {
|
|
17
|
+
onPlay?: () => void;
|
|
18
|
+
onPause?: () => void;
|
|
19
|
+
onTimeUpdate?: (position: number, duration: number) => void;
|
|
20
|
+
onEnded?: () => void;
|
|
21
|
+
onFullscreenToggle?: (isFullscreen: boolean) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
12
24
|
export type VideoEmbedProps = {
|
|
13
25
|
containerClassName?: string;
|
|
14
26
|
autoplay?: boolean;
|
|
15
27
|
debug?: boolean;
|
|
16
|
-
} & VideoLinkProps
|
|
28
|
+
} & VideoLinkProps &
|
|
29
|
+
VideoEmbedAnalyticsProps;
|
|
17
30
|
|
|
18
31
|
export type VideoLinkComponentProps = {
|
|
19
32
|
videoLink?: VideoLinkProps;
|
|
@@ -21,6 +34,7 @@ export type VideoLinkComponentProps = {
|
|
|
21
34
|
imageHeight?: number;
|
|
22
35
|
useOriginalImageAspectRatio?: boolean;
|
|
23
36
|
isAboveTheFold?: boolean;
|
|
37
|
+
onVideoEvent?: VideoAnalyticsHandler;
|
|
24
38
|
};
|
|
25
39
|
|
|
26
40
|
export type PlayButtonProps = {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import React, { useMemo } from "react";
|
|
1
|
+
import React, { useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import { VideoEmbedProps } from "./types";
|
|
3
3
|
|
|
4
4
|
import { cx } from "@shared/utils";
|
|
5
|
+
import Player from "@vimeo/player";
|
|
5
6
|
|
|
6
7
|
const extractVimeoIdandHash = (href: string) => {
|
|
7
8
|
// Strip query parameters and fragments
|
|
@@ -18,8 +19,24 @@ const extractVimeoIdandHash = (href: string) => {
|
|
|
18
19
|
return `${match[1]}/${hash}`;
|
|
19
20
|
};
|
|
20
21
|
|
|
22
|
+
export const getVimeoEmbedId = (link?: string): string => {
|
|
23
|
+
if (!link) return "";
|
|
24
|
+
return extractVimeoIdandHash(link.toString());
|
|
25
|
+
};
|
|
26
|
+
|
|
21
27
|
export const VimeoEmbed: React.FC<VideoEmbedProps> = props => {
|
|
22
|
-
const {
|
|
28
|
+
const {
|
|
29
|
+
link,
|
|
30
|
+
containerClassName,
|
|
31
|
+
autoplay = false,
|
|
32
|
+
debug = false,
|
|
33
|
+
onPlay,
|
|
34
|
+
onPause,
|
|
35
|
+
onTimeUpdate,
|
|
36
|
+
onEnded,
|
|
37
|
+
onFullscreenToggle,
|
|
38
|
+
} = props;
|
|
39
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
23
40
|
|
|
24
41
|
const { href, embedId } = useMemo(() => {
|
|
25
42
|
if (!link) return { href: "", embedId: "" };
|
|
@@ -33,6 +50,37 @@ export const VimeoEmbed: React.FC<VideoEmbedProps> = props => {
|
|
|
33
50
|
console.log("[VimeoEmbed] href:", href, "id:", embedId);
|
|
34
51
|
}
|
|
35
52
|
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!embedId || !iframeRef.current) return undefined;
|
|
55
|
+
const player = new Player(iframeRef.current);
|
|
56
|
+
|
|
57
|
+
const handlePlay = () => onPlay?.();
|
|
58
|
+
const handlePause = () => onPause?.();
|
|
59
|
+
const handleEnded = () => onEnded?.();
|
|
60
|
+
const handleTimeUpdate = (data: { seconds: number; duration: number }) =>
|
|
61
|
+
onTimeUpdate?.(data.seconds, data.duration);
|
|
62
|
+
const handleFullscreenChange = (data: { fullscreen: boolean }) =>
|
|
63
|
+
onFullscreenToggle?.(data.fullscreen);
|
|
64
|
+
|
|
65
|
+
player.on("play", handlePlay);
|
|
66
|
+
player.on("pause", handlePause);
|
|
67
|
+
player.on("ended", handleEnded);
|
|
68
|
+
player.on("timeupdate", handleTimeUpdate);
|
|
69
|
+
player.on("fullscreenchange", handleFullscreenChange);
|
|
70
|
+
|
|
71
|
+
return () => {
|
|
72
|
+
player.off("play", handlePlay);
|
|
73
|
+
player.off("pause", handlePause);
|
|
74
|
+
player.off("ended", handleEnded);
|
|
75
|
+
player.off("timeupdate", handleTimeUpdate);
|
|
76
|
+
player.off("fullscreenchange", handleFullscreenChange);
|
|
77
|
+
player.destroy().catch(() => {
|
|
78
|
+
// ignore teardown errors (e.g. iframe already removed from the DOM)
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
82
|
+
}, [embedId]);
|
|
83
|
+
|
|
36
84
|
if (!embedId) {
|
|
37
85
|
return null;
|
|
38
86
|
}
|
|
@@ -59,6 +107,7 @@ export const VimeoEmbed: React.FC<VideoEmbedProps> = props => {
|
|
|
59
107
|
)}
|
|
60
108
|
>
|
|
61
109
|
<iframe
|
|
110
|
+
ref={iframeRef}
|
|
62
111
|
className="absolute left-0 top-0 h-full w-full"
|
|
63
112
|
src={src}
|
|
64
113
|
allow="autoplay; fullscreen; picture-in-picture"
|