@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/dist/contentful/index.d.ts +22 -2
- package/dist/contentful/index.esm.js +5 -5
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +4 -4
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.esm.js +1 -1
- package/dist/utils/index.esm.js.map +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/video-link/index.tsx +28 -8
- package/src/components/video-link/vimeo-embed.tsx +19 -10
- package/src/components/video-link/youtube-embed.test.tsx +11 -7
- package/src/components/video-link/youtube-embed.tsx +58 -58
- package/src/contentful/blocks/anchored-bottom-banner/index.test.tsx +248 -0
- package/src/contentful/blocks/anchored-bottom-banner/index.tsx +197 -29
- package/src/contentful/blocks/anchored-bottom-banner/types.ts +46 -0
- package/src/contentful/blocks/cards/product-card/index.tsx +7 -2
- package/src/contentful/blocks/image-promo-bar/index.tsx +2 -2
- package/src/contentful/index.ts +5 -1
- package/src/hooks/contentful/use-contentful-rich-text.test.tsx +12 -0
- package/src/hooks/contentful/use-contentful-rich-text.tsx +17 -2
package/package.json
CHANGED
|
@@ -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
|
|
140
|
+
<VimeoEmbed
|
|
141
|
+
key={playbackKey}
|
|
142
|
+
link={videoHref}
|
|
143
|
+
autoplay={isPlaying}
|
|
144
|
+
{...analyticsProps}
|
|
145
|
+
/>
|
|
136
146
|
);
|
|
137
147
|
}
|
|
138
148
|
return (
|
|
139
|
-
<YoutubeEmbed
|
|
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 &&
|
|
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" : "
|
|
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 &&
|
|
208
|
+
{/* Video Popup Overlay - embed mounted up-front, overlay shown on play */}
|
|
209
|
+
{videoPopup && (
|
|
195
210
|
<div
|
|
196
|
-
className=
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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("
|
|
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
|
-
|
|
77
|
-
expect(
|
|
78
|
-
expect(
|
|
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("
|
|
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
|
-
|
|
89
|
-
expect(
|
|
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
|
|
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 || !
|
|
45
|
+
if (!embedId || !iframeRef.current) return undefined;
|
|
47
46
|
let player: any;
|
|
48
47
|
let cancelled = false;
|
|
49
48
|
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
<
|
|
121
|
-
ref={
|
|
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
|
+
});
|