@windstream/react-shared-components 0.2.60 → 0.2.62

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.
Files changed (39) hide show
  1. package/dist/contentful/index.d.ts +48 -2
  2. package/dist/contentful/index.esm.js +5 -5
  3. package/dist/contentful/index.esm.js.map +1 -1
  4. package/dist/contentful/index.js +4 -4
  5. package/dist/contentful/index.js.map +1 -1
  6. package/dist/core.d.ts +1 -1
  7. package/dist/index.d.ts +3 -3
  8. package/dist/index.esm.js +2 -2
  9. package/dist/index.esm.js.map +1 -1
  10. package/dist/index.js +2 -2
  11. package/dist/index.js.map +1 -1
  12. package/dist/styles.css +1 -1
  13. package/package.json +1 -1
  14. package/src/components/brand-button/index.tsx +1 -1
  15. package/src/components/video-link/index.tsx +28 -8
  16. package/src/components/video-link/youtube-embed.test.tsx +11 -7
  17. package/src/components/video-link/youtube-embed.tsx +58 -58
  18. package/src/contentful/blocks/anchored-bottom-banner/index.test.tsx +248 -0
  19. package/src/contentful/blocks/anchored-bottom-banner/index.tsx +197 -29
  20. package/src/contentful/blocks/anchored-bottom-banner/types.ts +46 -0
  21. package/src/contentful/blocks/cards/product-card/index.test.tsx +14 -1
  22. package/src/contentful/blocks/cards/product-card/index.tsx +193 -135
  23. package/src/contentful/blocks/cards/product-card/types.ts +1 -1
  24. package/src/contentful/blocks/carousel/Carousel.stories.tsx +59 -0
  25. package/src/contentful/blocks/carousel/helper.test.tsx +23 -0
  26. package/src/contentful/blocks/carousel/helper.tsx +18 -5
  27. package/src/contentful/blocks/carousel/types.ts +3 -0
  28. package/src/contentful/blocks/dynamic-form/conditional-fields.test.tsx +3 -0
  29. package/src/contentful/blocks/dynamic-form/conditional-fields.tsx +30 -4
  30. package/src/contentful/blocks/dynamic-form/context.ts +2 -0
  31. package/src/contentful/blocks/dynamic-form/field-map.test.tsx +21 -0
  32. package/src/contentful/blocks/dynamic-form/field-map.tsx +15 -0
  33. package/src/contentful/blocks/dynamic-form/form-field.tsx +2 -0
  34. package/src/contentful/blocks/dynamic-form/index.tsx +48 -16
  35. package/src/contentful/blocks/dynamic-form/render.tsx +1 -0
  36. package/src/contentful/blocks/dynamic-form/types.ts +26 -0
  37. package/src/contentful/blocks/dynamic-form/utils.test.ts +86 -0
  38. package/src/contentful/blocks/dynamic-form/utils.ts +57 -0
  39. package/src/contentful/index.ts +5 -1
@@ -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
+ });