@windstream/react-shared-components 0.2.50 → 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.
@@ -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: ({ link, autoplay }: any) => (
24
- <div
25
- data-testid="youtube-embed"
26
- data-link={link}
27
- data-autoplay={autoplay}
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(container.querySelector(".max-w-6xl")!);
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
- const usingContentfulAsset =
24
- videoSourceType === "contentful" && !!videoLink?.videoAssetUrl;
25
- const videoHref =
26
- (usingContentfulAsset ? videoLink?.videoAssetUrl : videoLink?.link) ?? "";
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
- if (usingContentfulAsset) {
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 <VimeoEmbed link={videoHref} autoplay={isPlaying} />;
134
+ return (
135
+ <VimeoEmbed link={videoHref} autoplay={isPlaying} {...analyticsProps} />
136
+ );
86
137
  }
87
- return <YoutubeEmbed link={videoHref} autoplay={isPlaying} />;
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 mx-auto w-full cursor-pointer rounded-image transition-all duration-300 lg:w-[486px]",
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 top-20 z-[100] flex items-center justify-center bg-black/80 p-4 transition-all duration-300"
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
- className="max-w-6xl aspect-video w-full overflow-hidden rounded-3xl bg-black shadow-2xl md:w-4/6"
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 { link, containerClassName, autoplay = false, debug = false } = props;
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"