@windstream/react-shared-components 0.2.61 → 0.2.63

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windstream/react-shared-components",
3
- "version": "0.2.61",
3
+ "version": "0.2.63",
4
4
  "type": "module",
5
5
  "description": "Shared React components for Kinetic applications",
6
6
  "main": "dist/index.js",
@@ -117,9 +117,14 @@ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
117
117
  onFullscreenToggle: (isFullscreen: boolean) =>
118
118
  videoAnalytics.handleFullscreenToggle(analyticsMeta, isFullscreen),
119
119
  };
120
+ // The embed is now mounted up-front (before click) for SEO; remount on play
121
+ // so click-to-play still starts playback (YT.Player reads autoplay only at
122
+ // creation, and a native <video>'s autoplay attr only applies on load).
123
+ const playbackKey = isPlaying ? "playing" : "idle";
120
124
  if (videoSourceType === "contentful") {
121
125
  return (
122
126
  <ContentfulVideoEmbed
127
+ key={playbackKey}
123
128
  videoAssetUrl={videoLink?.videoAssetUrl}
124
129
  videoAssetType={videoLink?.videoAssetType}
125
130
  image={posterImage}
@@ -132,11 +137,21 @@ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
132
137
  }
133
138
  if (videoHref.includes("vimeo")) {
134
139
  return (
135
- <VimeoEmbed link={videoHref} autoplay={isPlaying} {...analyticsProps} />
140
+ <VimeoEmbed
141
+ key={playbackKey}
142
+ link={videoHref}
143
+ autoplay={isPlaying}
144
+ {...analyticsProps}
145
+ />
136
146
  );
137
147
  }
138
148
  return (
139
- <YoutubeEmbed link={videoHref} autoplay={isPlaying} {...analyticsProps} />
149
+ <YoutubeEmbed
150
+ key={playbackKey}
151
+ link={videoHref}
152
+ autoplay={isPlaying}
153
+ {...analyticsProps}
154
+ />
140
155
  );
141
156
  };
142
157
  const contentVideo = embedSelector();
@@ -175,12 +190,12 @@ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
175
190
  </div>
176
191
  </div>
177
192
 
178
- {/* Inline Player (if not popup) */}
179
- {!videoPopup && isPlaying && (
193
+ {/* Inline Player (if not popup) - mounted up-front, hidden until play */}
194
+ {!videoPopup && (
180
195
  <div
181
196
  className={cx(
182
197
  "aspect-[16/9] w-full overflow-hidden rounded-image transition-opacity duration-300",
183
- isPlaying ? "opacity-100" : "opacity-0",
198
+ isPlaying ? "opacity-100" : "hidden",
184
199
  !useOriginalImageAspectRatio &&
185
200
  "xl:aspect-square xl:max-h-[486px] xl:w-[486px]"
186
201
  )}
@@ -190,10 +205,15 @@ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
190
205
  )}
191
206
  </div>
192
207
 
193
- {/* Video Popup Overlay */}
194
- {videoPopup && isPlaying && (
208
+ {/* Video Popup Overlay - embed mounted up-front, overlay shown on play */}
209
+ {videoPopup && (
195
210
  <div
196
- className="fixed inset-0 z-[100] flex items-center justify-center p-4 transition-all duration-300"
211
+ className={cx(
212
+ "z-[100] transition-all duration-300",
213
+ isPlaying
214
+ ? "fixed inset-0 flex items-center justify-center p-4"
215
+ : "hidden"
216
+ )}
197
217
  onClick={handleCloseVideo}
198
218
  >
199
219
  <div
@@ -5,18 +5,27 @@ import Player from "@vimeo/player";
5
5
  import { cx } from "@shared/utils/cx";
6
6
 
7
7
  const extractVimeoIdandHash = (href: string) => {
8
- // Strip query parameters and fragments
9
- const cleanHref = href.split(/[?#]/)[0].replace(/\/+$/, "");
10
- const match =
11
- cleanHref.match(/vimeo\.com\/(?:.*\/)?(\d{6,})/) ||
12
- cleanHref.match(/player\.vimeo\.com\/video\/(\d{6,})/);
8
+ // Strip query parameters and fragments without a backtracking-prone regex
9
+ let cleanHref = href.split(/[?#]/)[0];
10
+ while (cleanHref.endsWith("/")) cleanHref = cleanHref.slice(0, -1);
13
11
 
14
- const parts = cleanHref.split("/");
15
- const hash = parts[parts.length - 1];
12
+ const parts = cleanHref.split("/").filter(Boolean);
13
+
14
+ // Locate the vimeo domain segment (vimeo.com or player.vimeo.com) manually
15
+ // instead of a backtracking regex like `(?:.*\/)?(\d{6,})`
16
+ const domainIndex = parts.findIndex(
17
+ part => part === "vimeo.com" || part === "player.vimeo.com"
18
+ );
19
+ if (domainIndex === -1) return "";
16
20
 
17
- if (!match?.[1]) return "";
18
- if (match[1] === hash) return match[1];
19
- return `${match[1]}/${hash}`;
21
+ const id = parts.find(
22
+ (part, i) => i > domainIndex && /^\d{6,}$/.test(part)
23
+ );
24
+ if (!id) return "";
25
+
26
+ const hash = parts[parts.length - 1];
27
+ if (id === hash) return id;
28
+ return `${id}/${hash}`;
20
29
  };
21
30
 
22
31
  export const getVimeoEmbedId = (link?: string): string => {
@@ -71,22 +71,26 @@ describe("YoutubeEmbed", () => {
71
71
  expect(screen.getByTitle("Embedded youtube")).toBeInTheDocument();
72
72
  });
73
73
 
74
- it("constructs the player with the extracted videoId and enablejsapi, autoplay/mute off by default", async () => {
74
+ it("renders a real iframe whose src carries the videoId, enablejsapi and autoplay/mute off by default", () => {
75
75
  render(<YoutubeEmbed link="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />);
76
- await waitFor(() => expect(capturedVideoId).toBeDefined());
77
- expect(capturedVideoId).toBe("dQw4w9WgXcQ");
78
- expect(capturedPlayerVars).toEqual({ enablejsapi: 1, autoplay: 0, mute: 0 });
76
+ const iframe = screen.getByTitle("Embedded youtube") as HTMLIFrameElement;
77
+ expect(iframe.tagName).toBe("IFRAME");
78
+ expect(iframe.src).toContain("/embed/dQw4w9WgXcQ");
79
+ expect(iframe.src).toContain("enablejsapi=1");
80
+ expect(iframe.src).toContain("autoplay=0");
81
+ expect(iframe.src).toContain("mute=0");
79
82
  });
80
83
 
81
- it("passes autoplay and mute declaratively via playerVars when autoplay is true", async () => {
84
+ it("drives autoplay and mute via the iframe src when autoplay is true", () => {
82
85
  render(
83
86
  <YoutubeEmbed
84
87
  link="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
85
88
  autoplay={true}
86
89
  />
87
90
  );
88
- await waitFor(() => expect(capturedPlayerVars).toBeDefined());
89
- expect(capturedPlayerVars).toEqual({ enablejsapi: 1, autoplay: 1, mute: 1 });
91
+ const iframe = screen.getByTitle("Embedded youtube") as HTMLIFrameElement;
92
+ expect(iframe.src).toContain("autoplay=1");
93
+ expect(iframe.src).toContain("mute=1");
90
94
  });
91
95
 
92
96
  it("reports play/pause/ended via onStateChange", async () => {
@@ -36,80 +36,76 @@ export const YoutubeEmbed: React.FC<Props> = props => {
36
36
  } = props;
37
37
 
38
38
  const embedId = useMemo(() => getYoutubeEmbedId(link), [link]);
39
- const containerRef = useRef<HTMLDivElement>(null);
40
- const iframeRef = useRef<HTMLIFrameElement | null>(null);
39
+ const iframeRef = useRef<HTMLIFrameElement>(null);
41
40
  const progressInterval = useRef<ReturnType<typeof setInterval>>();
42
41
 
43
42
  useFullscreenToggle(iframeRef, onFullscreenToggle);
44
43
 
45
44
  useEffect(() => {
46
- if (!embedId || !containerRef.current) return undefined;
45
+ if (!embedId || !iframeRef.current) return undefined;
47
46
  let player: any;
48
47
  let cancelled = false;
49
48
 
50
- // The YT.Player-managed iframe is mounted into a plain DOM node created
51
- // here, outside of React's own render tree/reconciliation - React only
52
- // ever renders an empty container div for this component, so it never
53
- // diffs/touches whatever the API puts inside it. Letting React manage
54
- // the same node the API mutates is a well-known source of onReady/
55
- // onStateChange silently never firing (a reconciliation pass can revert
56
- // the API's DOM changes without going through this component's own
57
- // effect cleanup).
58
- const mountNode = document.createElement("div");
59
- mountNode.style.width = "100%";
60
- mountNode.style.height = "100%";
61
- containerRef.current.appendChild(mountNode);
62
-
63
- loadYoutubeIframeApi().then(YT => {
64
- if (cancelled) return;
65
- player = new YT.Player(mountNode, {
66
- videoId: embedId,
67
- playerVars: {
68
- enablejsapi: 1,
69
- autoplay: autoplay ? 1 : 0,
70
- mute: autoplay ? 1 : 0,
71
- },
72
- events: {
73
- onReady: () => {
74
- const iframe = player.getIframe?.();
75
- if (iframe) {
76
- iframe.className = "absolute left-0 top-0 h-full w-full";
77
- iframeRef.current = iframe;
78
- }
79
- },
80
- onStateChange: (event: any) => {
81
- const State = YT.PlayerState;
82
- if (event.data === State.PLAYING) {
83
- onPlay?.();
84
- clearInterval(progressInterval.current);
85
- progressInterval.current = setInterval(() => {
86
- const duration = player?.getDuration?.() ?? 0;
87
- const position = player?.getCurrentTime?.() ?? 0;
88
- onTimeUpdate?.(position, duration);
89
- }, 1000);
90
- } else if (event.data === State.PAUSED) {
91
- onPause?.();
92
- clearInterval(progressInterval.current);
93
- } else if (event.data === State.ENDED) {
94
- onEnded?.();
95
- clearInterval(progressInterval.current);
96
- }
49
+ // Attach the JS API to the real <iframe> React already rendered (kept in
50
+ // the markup so crawlers see a video element) instead of letting the API
51
+ // inject its own iframe client-side into an empty div.
52
+ loadYoutubeIframeApi()
53
+ .then(YT => {
54
+ if (cancelled || !iframeRef.current) return;
55
+ player = new YT.Player(iframeRef.current, {
56
+ events: {
57
+ onStateChange: (event: any) => {
58
+ const State = YT.PlayerState;
59
+ if (event.data === State.PLAYING) {
60
+ onPlay?.();
61
+ clearInterval(progressInterval.current);
62
+ progressInterval.current = setInterval(() => {
63
+ const duration = player?.getDuration?.() ?? 0;
64
+ const position = player?.getCurrentTime?.() ?? 0;
65
+ onTimeUpdate?.(position, duration);
66
+ }, 1000);
67
+ } else if (event.data === State.PAUSED) {
68
+ onPause?.();
69
+ clearInterval(progressInterval.current);
70
+ } else if (event.data === State.ENDED) {
71
+ onEnded?.();
72
+ clearInterval(progressInterval.current);
73
+ }
74
+ },
97
75
  },
98
- },
76
+ });
77
+ })
78
+ .catch(() => {
79
+ // ignore API load failures; the iframe still plays on its own
99
80
  });
100
- }).catch(() => {
101
- // ignore YouTube API load failures; component will remain empty
102
- });
103
81
 
104
82
  return () => {
105
83
  cancelled = true;
106
84
  clearInterval(progressInterval.current);
107
- player?.destroy?.();
108
- mountNode.remove();
85
+ try {
86
+ player?.destroy?.();
87
+ } catch {
88
+ // iframe may already be detached by React on unmount
89
+ }
109
90
  };
110
91
  // eslint-disable-next-line react-hooks/exhaustive-deps
111
92
  }, [embedId]);
112
93
 
94
+ if (!embedId) {
95
+ return null;
96
+ }
97
+
98
+ // enablejsapi=1 lets YT.Player attach for analytics; autoplay/mute are driven
99
+ // via the URL so a fresh mount (on play) starts playback.
100
+ const params = new URLSearchParams({
101
+ enablejsapi: "1",
102
+ autoplay: autoplay ? "1" : "0",
103
+ mute: autoplay ? "1" : "0",
104
+ playsinline: "1",
105
+ rel: "0",
106
+ });
107
+ const src = `https://www.youtube.com/embed/${embedId}?${params.toString()}`;
108
+
113
109
  return (
114
110
  <div
115
111
  className={cx(
@@ -117,10 +113,14 @@ export const YoutubeEmbed: React.FC<Props> = props => {
117
113
  containerClassName
118
114
  )}
119
115
  >
120
- <div
121
- ref={containerRef}
116
+ <iframe
117
+ ref={iframeRef}
122
118
  className="absolute left-0 top-0 h-full w-full"
119
+ src={src}
123
120
  title="Embedded youtube"
121
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
122
+ allowFullScreen={true}
123
+ referrerPolicy="strict-origin-when-cross-origin"
124
124
  />
125
125
  </div>
126
126
  );
@@ -285,3 +285,251 @@ describe("AnchoredBottomBanner", () => {
285
285
  consoleSpy.mockRestore();
286
286
  });
287
287
  });
288
+
289
+ describe("AnchoredBottomBanner scheduledMessages", () => {
290
+ // 2026-05-19T12:00:00Z is a Tuesday.
291
+ beforeEach(() => {
292
+ jest.useFakeTimers();
293
+ jest.setSystemTime(new Date("2026-05-19T12:00:00Z"));
294
+ });
295
+
296
+ afterEach(() => {
297
+ jest.useRealTimers();
298
+ });
299
+
300
+ it("shows the active message's CTA, icon and countdown", () => {
301
+ render(
302
+ <AnchoredBottomBanner
303
+ ctaButtonLabel="Default label"
304
+ scheduledMessages={[
305
+ {
306
+ scheduledStart: "2026-05-18",
307
+ scheduleEnd: "2026-05-20",
308
+ daysToShow: ["ALL"],
309
+ countdownStartDateTime: "2026-05-19T11:00:00Z",
310
+ countdownEndDateTime: "2026-05-19T13:00:00Z",
311
+ ctaButtonLabel: "Flash sale",
312
+ iconName: "bolt",
313
+ },
314
+ ]}
315
+ />
316
+ );
317
+ expect(screen.getByText("Flash sale")).toBeInTheDocument();
318
+ expect(screen.queryByText("Default label")).not.toBeInTheDocument();
319
+ expect(screen.getByText("bolt")).toBeInTheDocument();
320
+ expect(screen.getByText(/01H : 00M : 00/)).toBeInTheDocument();
321
+ });
322
+
323
+ it("treats scheduleEnd as inclusive of the whole end day", () => {
324
+ render(
325
+ <AnchoredBottomBanner
326
+ scheduledMessages={[
327
+ {
328
+ scheduledStart: "2026-05-17",
329
+ scheduleEnd: "2026-05-19",
330
+ countdownEndDateTime: "2026-05-19T13:00:00Z",
331
+ ctaButtonLabel: "Last day",
332
+ },
333
+ ]}
334
+ />
335
+ );
336
+ expect(screen.getByText("Last day")).toBeInTheDocument();
337
+ expect(screen.getByText(/01H : 00M : 00/)).toBeInTheDocument();
338
+ });
339
+
340
+ it("falls back to default when today is outside the display window", () => {
341
+ render(
342
+ <AnchoredBottomBanner
343
+ ctaButtonLabel="Default label"
344
+ scheduledMessages={[
345
+ {
346
+ scheduledStart: "2026-05-25",
347
+ scheduleEnd: "2026-05-26",
348
+ countdownEndDateTime: "2026-05-25T13:00:00Z",
349
+ ctaButtonLabel: "Future message",
350
+ },
351
+ ]}
352
+ />
353
+ );
354
+ expect(screen.getByText("Default label")).toBeInTheDocument();
355
+ expect(screen.queryByText("Future message")).not.toBeInTheDocument();
356
+ expect(screen.queryByText(/H :/)).not.toBeInTheDocument();
357
+ });
358
+
359
+ it("hides the message when daysToShow excludes today's weekday", () => {
360
+ render(
361
+ <AnchoredBottomBanner
362
+ ctaButtonLabel="Default label"
363
+ scheduledMessages={[
364
+ {
365
+ scheduledStart: "2026-05-18",
366
+ scheduleEnd: "2026-05-20",
367
+ daysToShow: ["MONDAY"],
368
+ countdownEndDateTime: "2026-05-19T13:00:00Z",
369
+ ctaButtonLabel: "Monday only",
370
+ },
371
+ ]}
372
+ />
373
+ );
374
+ expect(screen.getByText("Default label")).toBeInTheDocument();
375
+ expect(screen.queryByText("Monday only")).not.toBeInTheDocument();
376
+ });
377
+
378
+ it("shows the message when daysToShow includes today's weekday (abbrev)", () => {
379
+ render(
380
+ <AnchoredBottomBanner
381
+ scheduledMessages={[
382
+ {
383
+ scheduledStart: "2026-05-18",
384
+ scheduleEnd: "2026-05-20",
385
+ daysToShow: ["TUE"],
386
+ countdownEndDateTime: "2026-05-19T13:00:00Z",
387
+ ctaButtonLabel: "Tuesday deal",
388
+ },
389
+ ]}
390
+ />
391
+ );
392
+ expect(screen.getByText("Tuesday deal")).toBeInTheDocument();
393
+ expect(screen.getByText(/01H : 00M : 00/)).toBeInTheDocument();
394
+ });
395
+
396
+ it("uses the active message's countdown, not the parent's", () => {
397
+ render(
398
+ <AnchoredBottomBanner
399
+ ctaButtonLabel="Default label"
400
+ enableCountdownTimer={true}
401
+ countdownEndDateTime="2026-05-19T12:00:30Z"
402
+ scheduledMessages={[
403
+ {
404
+ scheduledStart: "2026-05-18",
405
+ scheduleEnd: "2026-05-20",
406
+ countdownEndDateTime: "2026-05-19T12:00:10Z",
407
+ ctaButtonLabel: "Active message",
408
+ },
409
+ ]}
410
+ />
411
+ );
412
+ expect(screen.getByText("Active message")).toBeInTheDocument();
413
+ expect(screen.queryByText("Default label")).not.toBeInTheDocument();
414
+ expect(screen.getByText(/00H : 00M : 10/)).toBeInTheDocument();
415
+ });
416
+
417
+ it("selects the first message whose window contains today", () => {
418
+ render(
419
+ <AnchoredBottomBanner
420
+ scheduledMessages={[
421
+ {
422
+ scheduledStart: "2026-05-01",
423
+ scheduleEnd: "2026-05-05",
424
+ ctaButtonLabel: "Past message",
425
+ },
426
+ {
427
+ scheduledStart: "2026-05-18",
428
+ scheduleEnd: "2026-05-20",
429
+ countdownEndDateTime: "2026-05-19T13:00:00Z",
430
+ ctaButtonLabel: "Current message",
431
+ },
432
+ {
433
+ scheduledStart: "2026-05-25",
434
+ scheduleEnd: "2026-05-26",
435
+ ctaButtonLabel: "Future message",
436
+ },
437
+ ]}
438
+ />
439
+ );
440
+ expect(screen.getByText("Current message")).toBeInTheDocument();
441
+ expect(screen.queryByText("Past message")).not.toBeInTheDocument();
442
+ expect(screen.queryByText("Future message")).not.toBeInTheDocument();
443
+ });
444
+ });
445
+
446
+ describe("AnchoredBottomBanner scheduling timezone (America/New_York)", () => {
447
+ // 2026-05-19T02:00:00Z is Mon 2026-05-18 22:00 EDT — a different ET calendar
448
+ // day AND weekday than UTC, so it exercises the EST/EDT scheduling rules.
449
+ beforeEach(() => {
450
+ jest.useFakeTimers();
451
+ jest.setSystemTime(new Date("2026-05-19T02:00:00Z"));
452
+ });
453
+
454
+ afterEach(() => {
455
+ jest.useRealTimers();
456
+ });
457
+
458
+ it("uses the ET calendar day for the display window, not UTC", () => {
459
+ render(
460
+ <AnchoredBottomBanner
461
+ ctaButtonLabel="Default label"
462
+ scheduledMessages={[
463
+ {
464
+ scheduledStart: "2026-05-18",
465
+ scheduleEnd: "2026-05-18",
466
+ countdownEndDateTime: "2026-05-25T00:00:00Z",
467
+ ctaButtonLabel: "ET Monday message",
468
+ },
469
+ ]}
470
+ />
471
+ );
472
+ // In ET it is still 2026-05-18, so the 05-18 window is active. Under the old
473
+ // UTC logic it would be 05-19 and this window would already be expired.
474
+ expect(screen.getByText("ET Monday message")).toBeInTheDocument();
475
+ expect(screen.queryByText("Default label")).not.toBeInTheDocument();
476
+ });
477
+
478
+ it("does not activate a window before its ET start day", () => {
479
+ render(
480
+ <AnchoredBottomBanner
481
+ ctaButtonLabel="Default label"
482
+ scheduledMessages={[
483
+ {
484
+ scheduledStart: "2026-05-19",
485
+ scheduleEnd: "2026-05-19",
486
+ countdownEndDateTime: "2026-05-25T00:00:00Z",
487
+ ctaButtonLabel: "ET Tuesday message",
488
+ },
489
+ ]}
490
+ />
491
+ );
492
+ // ET is still 05-18 (22:00), so the 05-19 window has not started yet.
493
+ expect(screen.getByText("Default label")).toBeInTheDocument();
494
+ expect(screen.queryByText("ET Tuesday message")).not.toBeInTheDocument();
495
+ });
496
+
497
+ it("matches daysToShow against the ET weekday, not the UTC weekday", () => {
498
+ render(
499
+ <AnchoredBottomBanner
500
+ ctaButtonLabel="Default label"
501
+ scheduledMessages={[
502
+ {
503
+ scheduledStart: "2026-05-18",
504
+ scheduleEnd: "2026-05-20",
505
+ daysToShow: ["MONDAY"],
506
+ countdownEndDateTime: "2026-05-25T00:00:00Z",
507
+ ctaButtonLabel: "Monday only",
508
+ },
509
+ ]}
510
+ />
511
+ );
512
+ // ET weekday is Monday (the UTC weekday would be Tuesday).
513
+ expect(screen.getByText("Monday only")).toBeInTheDocument();
514
+ });
515
+
516
+ it("does not match a UTC weekday that differs from the ET weekday", () => {
517
+ render(
518
+ <AnchoredBottomBanner
519
+ ctaButtonLabel="Default label"
520
+ scheduledMessages={[
521
+ {
522
+ scheduledStart: "2026-05-18",
523
+ scheduleEnd: "2026-05-20",
524
+ daysToShow: ["TUESDAY"],
525
+ countdownEndDateTime: "2026-05-25T00:00:00Z",
526
+ ctaButtonLabel: "Tuesday only",
527
+ },
528
+ ]}
529
+ />
530
+ );
531
+ // UTC says Tuesday, but ET says Monday -> the message must not show.
532
+ expect(screen.getByText("Default label")).toBeInTheDocument();
533
+ expect(screen.queryByText("Tuesday only")).not.toBeInTheDocument();
534
+ });
535
+ });