@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
@@ -5,11 +5,15 @@ import React, {
5
5
  useRef,
6
6
  useState,
7
7
  } from "react";
8
- import { AnchoredBottomBannerProps } from "./types";
8
+ import { AnchoredBottomBannerProps, ScheduledMessage } from "./types";
9
9
  import Link from "next/link";
10
10
 
11
11
  import { MaterialIcon } from "@shared/components/material-icon";
12
12
 
13
+ const DAY_MS = 24 * 60 * 60 * 1000;
14
+ // Schedule windows and recurring weekdays are evaluated in this zone (DST-aware).
15
+ const SCHEDULING_TIME_ZONE = "America/New_York";
16
+
13
17
  function parseCountdownDateTime(value?: string): number | undefined {
14
18
  if (!value) return undefined;
15
19
  const parsed = Date.parse(value);
@@ -20,6 +24,114 @@ function parseCountdownDateTime(value?: string): number | undefined {
20
24
  return parsed;
21
25
  }
22
26
 
27
+ // Offset of the scheduling zone at an instant: (wall-clock read as UTC) - instant.
28
+ function schedulingZoneOffsetMs(instantMs: number): number {
29
+ const parts = new Intl.DateTimeFormat("en-US", {
30
+ timeZone: SCHEDULING_TIME_ZONE,
31
+ hour12: false,
32
+ year: "numeric",
33
+ month: "2-digit",
34
+ day: "2-digit",
35
+ hour: "2-digit",
36
+ minute: "2-digit",
37
+ second: "2-digit",
38
+ }).formatToParts(new Date(instantMs));
39
+ const map: Record<string, string> = {};
40
+ for (const part of parts) map[part.type] = part.value;
41
+ const hour = map.hour === "24" ? "00" : map.hour;
42
+ const wallAsUtc = Date.UTC(
43
+ Number(map.year),
44
+ Number(map.month) - 1,
45
+ Number(map.day),
46
+ Number(hour),
47
+ Number(map.minute),
48
+ Number(map.second)
49
+ );
50
+ return wallAsUtc - instantMs;
51
+ }
52
+
53
+ // Absolute epoch ms for midnight of a calendar day in the scheduling zone.
54
+ function schedulingZoneMidnightMs(
55
+ year: number,
56
+ month: number,
57
+ day: number
58
+ ): number {
59
+ const guess = Date.UTC(year, month - 1, day);
60
+ return guess - schedulingZoneOffsetMs(guess);
61
+ }
62
+
63
+ // Start of the scheduledStart calendar day in the scheduling zone.
64
+ function parseDisplayStart(value?: string): number | undefined {
65
+ if (!value) return undefined;
66
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
67
+ if (!match) {
68
+ const parsed = Date.parse(value);
69
+ return Number.isFinite(parsed) ? parsed : undefined;
70
+ }
71
+ return schedulingZoneMidnightMs(
72
+ Number(match[1]),
73
+ Number(match[2]),
74
+ Number(match[3])
75
+ );
76
+ }
77
+
78
+ // Exclusive end = start of the day AFTER scheduleEnd in the scheduling zone, so
79
+ // scheduleEnd stays active through the whole America/New_York calendar day.
80
+ function parseDisplayEnd(value?: string): number | undefined {
81
+ if (!value) return undefined;
82
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
83
+ if (!match) {
84
+ const parsed = Date.parse(value);
85
+ return Number.isFinite(parsed) ? parsed + DAY_MS : undefined;
86
+ }
87
+ const next = new Date(
88
+ Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
89
+ );
90
+ next.setUTCDate(next.getUTCDate() + 1);
91
+ return schedulingZoneMidnightMs(
92
+ next.getUTCFullYear(),
93
+ next.getUTCMonth() + 1,
94
+ next.getUTCDate()
95
+ );
96
+ }
97
+
98
+ // Weekday name (e.g. "TUESDAY") in the scheduling zone, DST-aware.
99
+ function schedulingZoneWeekday(nowMs: number): string {
100
+ return new Intl.DateTimeFormat("en-US", {
101
+ timeZone: SCHEDULING_TIME_ZONE,
102
+ weekday: "long",
103
+ })
104
+ .format(new Date(nowMs))
105
+ .toUpperCase();
106
+ }
107
+
108
+ function dayMatches(daysToShow: string[] | undefined, nowMs: number): boolean {
109
+ if (!daysToShow || daysToShow.length === 0) return true;
110
+ const normalized = daysToShow.map(day => day.trim().toUpperCase());
111
+ if (normalized.includes("ALL")) return true;
112
+ const full = schedulingZoneWeekday(nowMs);
113
+ return normalized.includes(full) || normalized.includes(full.slice(0, 3));
114
+ }
115
+
116
+ // Active = today is within [scheduledStart, scheduleEnd], allowed by daysToShow,
117
+ // and the message's countdown (when set) has not already ended. An expired
118
+ // countdown makes the message inactive so it can't block another running one.
119
+ function isMessageActive(message: ScheduledMessage, nowMs: number): boolean {
120
+ const startMs = parseDisplayStart(message.scheduledStart);
121
+ const endMs = parseDisplayEnd(message.scheduleEnd);
122
+ const afterStart = startMs === undefined || nowMs >= startMs;
123
+ const beforeEnd = endMs === undefined || nowMs < endMs;
124
+ const countdownEndMs = parseCountdownDateTime(message.countdownEndDateTime);
125
+ const countdownNotExpired =
126
+ countdownEndMs === undefined || nowMs < countdownEndMs;
127
+ return (
128
+ afterStart &&
129
+ beforeEnd &&
130
+ countdownNotExpired &&
131
+ dayMatches(message.daysToShow, nowMs)
132
+ );
133
+ }
134
+
23
135
  function formatCountdown(totalSeconds: number) {
24
136
  const safeSeconds = Math.max(0, Math.floor(totalSeconds));
25
137
  const hours = Math.floor(safeSeconds / 3600);
@@ -41,6 +153,8 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
41
153
  enableCountdownTimer,
42
154
  countdownStartDateTime,
43
155
  countdownEndDateTime,
156
+ scheduledMessages,
157
+ onResolved,
44
158
  }) => {
45
159
  const backGroundColorClasses = {
46
160
  navy: "bg-bg-fill-inverse",
@@ -61,38 +175,70 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
61
175
  !backgroundColor;
62
176
  const textColorClass = isLightBackground ? "text-text-primary" : "text-white";
63
177
 
64
- // Memoize parsed timestamps so they aren't re-parsed every second
178
+ const [nowMs, setNowMs] = useState(() => Date.now());
179
+ const intervalRef = useRef<number | null>(null);
180
+
181
+ // The message active for "now" (display window + daysToShow) wins.
182
+ const activeMessage = useMemo(
183
+ () => scheduledMessages?.find(message => isMessageActive(message, nowMs)),
184
+ [scheduledMessages, nowMs]
185
+ );
186
+ const hasActiveMessage = activeMessage !== undefined;
187
+
188
+ // Expose the resolved result so consumers (analytics) can read what showed.
189
+ const applicableWeekday = schedulingZoneWeekday(nowMs);
190
+ useEffect(() => {
191
+ onResolved?.({ hasActiveMessage, activeMessage, applicableWeekday });
192
+ }, [hasActiveMessage, activeMessage, applicableWeekday, onResolved]);
193
+
194
+ // Latest display-window end, so we keep ticking to let messages activate/expire.
195
+ const scheduleMaxEndMs = useMemo(() => {
196
+ if (!scheduledMessages?.length) return undefined;
197
+ const ends = scheduledMessages
198
+ .map(message => parseDisplayEnd(message.scheduleEnd))
199
+ .filter((value): value is number => value !== undefined);
200
+ return ends.length ? Math.max(...ends) : undefined;
201
+ }, [scheduledMessages]);
202
+
203
+ // Active message drives the countdown; dates are never inherited either way.
204
+ const effectiveCountdownStartDateTime = hasActiveMessage
205
+ ? activeMessage?.countdownStartDateTime
206
+ : countdownStartDateTime;
207
+ const effectiveCountdownEndDateTime = hasActiveMessage
208
+ ? activeMessage?.countdownEndDateTime
209
+ : countdownEndDateTime;
210
+
65
211
  const endMs = useMemo(
66
- () => parseCountdownDateTime(countdownEndDateTime),
67
- [countdownEndDateTime]
212
+ () => parseCountdownDateTime(effectiveCountdownEndDateTime),
213
+ [effectiveCountdownEndDateTime]
68
214
  );
69
215
  const startMs = useMemo(
70
- () => parseCountdownDateTime(countdownStartDateTime),
71
- [countdownStartDateTime]
216
+ () => parseCountdownDateTime(effectiveCountdownStartDateTime),
217
+ [effectiveCountdownStartDateTime]
72
218
  );
73
219
 
74
220
  const isTimerValid = useMemo(() => {
75
- if (!enableCountdownTimer || endMs === undefined) return false;
76
- if (countdownStartDateTime && startMs === undefined) return false;
221
+ // Active messages always enable the timer; default uses enableCountdownTimer.
222
+ if (!hasActiveMessage && !enableCountdownTimer) return false;
223
+ if (endMs === undefined) return false;
224
+ if (effectiveCountdownStartDateTime && startMs === undefined) return false;
77
225
  if (startMs !== undefined && startMs >= endMs) {
78
226
  console.error("Invalid countdown range: start must be before end", {
79
- countdownStartDateTime,
80
- countdownEndDateTime,
227
+ countdownStartDateTime: effectiveCountdownStartDateTime,
228
+ countdownEndDateTime: effectiveCountdownEndDateTime,
81
229
  });
82
230
  return false;
83
231
  }
84
232
  return true;
85
233
  }, [
234
+ hasActiveMessage,
86
235
  enableCountdownTimer,
87
236
  endMs,
88
237
  startMs,
89
- countdownStartDateTime,
90
- countdownEndDateTime,
238
+ effectiveCountdownStartDateTime,
239
+ effectiveCountdownEndDateTime,
91
240
  ]);
92
241
 
93
- const [nowMs, setNowMs] = useState(() => Date.now());
94
- const intervalRef = useRef<number | null>(null);
95
-
96
242
  const clearTimer = useCallback(() => {
97
243
  if (intervalRef.current !== null) {
98
244
  window.clearInterval(intervalRef.current);
@@ -101,22 +247,24 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
101
247
  }, []);
102
248
 
103
249
  useEffect(() => {
104
- if (!isTimerValid) {
250
+ // Tick while the countdown runs, or while a schedule still has upcoming/active
251
+ // windows so the active message can change over time.
252
+ const countdownRunning =
253
+ isTimerValid && endMs !== undefined && nowMs < endMs;
254
+ const scheduleHasFuture =
255
+ scheduleMaxEndMs !== undefined && nowMs < scheduleMaxEndMs;
256
+
257
+ if (!countdownRunning && !scheduleHasFuture) {
105
258
  clearTimer();
106
259
  return;
107
260
  }
108
261
 
109
262
  intervalRef.current = window.setInterval(() => {
110
- const now = Date.now();
111
- // Auto-clear interval once countdown expires
112
- if (endMs !== undefined && now >= endMs) {
113
- clearTimer();
114
- }
115
- setNowMs(now);
263
+ setNowMs(Date.now());
116
264
  }, 1000);
117
265
 
118
266
  return clearTimer;
119
- }, [isTimerValid, endMs, clearTimer]);
267
+ }, [isTimerValid, endMs, scheduleMaxEndMs, nowMs, clearTimer]);
120
268
 
121
269
  const countdown = useMemo(() => {
122
270
  if (!isTimerValid || endMs === undefined) {
@@ -134,6 +282,24 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
134
282
  };
135
283
  }, [isTimerValid, endMs, startMs, nowMs]);
136
284
 
285
+ // While a message is active it fully drives the content; each field is
286
+ // inherited from the message (even when unset) rather than the banner default.
287
+ const effectiveIconName = hasActiveMessage
288
+ ? activeMessage?.iconName
289
+ : iconName;
290
+ const effectiveCtaButtonLabel = hasActiveMessage
291
+ ? activeMessage?.ctaButtonLabel
292
+ : ctaButtonLabel;
293
+ const effectiveCtaSuffixText = hasActiveMessage
294
+ ? activeMessage?.ctaSuffixText
295
+ : ctaSuffixText;
296
+ const effectiveCtaButtonLink = hasActiveMessage
297
+ ? activeMessage?.ctaButtonLink
298
+ : ctaButtonLink;
299
+ const effectiveCtaButtonTarget = hasActiveMessage
300
+ ? activeMessage?.ctaButtonTarget
301
+ : ctaButtonTarget;
302
+
137
303
  return (
138
304
  <section id={anchorId}>
139
305
  <div
@@ -142,18 +308,18 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
142
308
  }`}
143
309
  >
144
310
  <Link
145
- href={ctaButtonLink || "#"}
146
- target={ctaButtonTarget || "_self"}
311
+ href={effectiveCtaButtonLink || "#"}
312
+ target={effectiveCtaButtonTarget || "_self"}
147
313
  className="max-w-screen-xl w-full transition-all"
148
314
  >
149
315
  <div className="flex flex-col items-center justify-center gap-1 text-center font-black">
150
316
  <div
151
317
  className={`${textColorClass} break-words text-body1 font-black leading-snug md:text-[18px]`}
152
318
  >
153
- {iconName && (
319
+ {effectiveIconName && (
154
320
  <MaterialIcon
155
321
  name={
156
- iconName as React.ComponentProps<
322
+ effectiveIconName as React.ComponentProps<
157
323
  typeof MaterialIcon
158
324
  >["name"]
159
325
  }
@@ -168,8 +334,10 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
168
334
  </span>
169
335
  )}
170
336
  {countdown.shouldShow ? " " : null}
171
- {ctaButtonLabel && ctaButtonLabel}{" "}
172
- {ctaSuffixText && <span className="ml-0.5">{ctaSuffixText}</span>}
337
+ {effectiveCtaButtonLabel && effectiveCtaButtonLabel}{" "}
338
+ {effectiveCtaSuffixText && (
339
+ <span className="ml-0.5">{effectiveCtaSuffixText}</span>
340
+ )}
173
341
  </div>
174
342
  </div>
175
343
  </Link>
@@ -1,3 +1,40 @@
1
+ // Allowed values for a scheduled message's daysToShow.
2
+ // "ALL" (or empty) = every day; full names or 3-letter abbreviations, uppercase.
3
+ export type ScheduledMessageDay =
4
+ | "ALL"
5
+ | "SUNDAY"
6
+ | "MONDAY"
7
+ | "TUESDAY"
8
+ | "WEDNESDAY"
9
+ | "THURSDAY"
10
+ | "FRIDAY"
11
+ | "SATURDAY"
12
+ | "SUN"
13
+ | "MON"
14
+ | "TUE"
15
+ | "WED"
16
+ | "THU"
17
+ | "FRI"
18
+ | "SAT";
19
+
20
+ export interface ScheduledMessage {
21
+ // Stable id for analytics (Contentful entry id or title).
22
+ id?: string;
23
+ // Date-only display window (scheduleEnd is inclusive of the whole end day).
24
+ scheduledStart?: string;
25
+ scheduleEnd?: string;
26
+ // Weekdays the message may show on within the window. "ALL" (or empty) = every day.
27
+ daysToShow?: ScheduledMessageDay[];
28
+ // Countdown timer window (full timestamps).
29
+ countdownStartDateTime?: string;
30
+ countdownEndDateTime?: string;
31
+ iconName?: string;
32
+ ctaButtonLabel?: string;
33
+ ctaSuffixText?: string;
34
+ ctaButtonLink?: string;
35
+ ctaButtonTarget?: string;
36
+ }
37
+
1
38
  export interface AnchoredBottomBannerProps {
2
39
  ctaSuffixText?: string;
3
40
  backgroundColor?: "navy" | "yellow" | "green" | "purple" | "blue" | "white";
@@ -10,4 +47,13 @@ export interface AnchoredBottomBannerProps {
10
47
  enableCountdownTimer?: boolean;
11
48
  countdownStartDateTime?: string;
12
49
  countdownEndDateTime?: string;
50
+ // When a message is active for today, it drives the countdown + CTA content;
51
+ // otherwise the banner falls back to its own top-level values above.
52
+ scheduledMessages?: ScheduledMessage[];
53
+ // Fires with the resolved result so consumers (analytics) know what showed.
54
+ onResolved?: (result: {
55
+ hasActiveMessage: boolean;
56
+ activeMessage?: ScheduledMessage;
57
+ applicableWeekday: string;
58
+ }) => void;
13
59
  }
@@ -72,7 +72,6 @@ describe("ProductCard", () => {
72
72
  description: "Fast internet for your home",
73
73
  features: ["Wi-Fi included", "No data caps"],
74
74
  onCtaClick: jest.fn(),
75
- hostType: "residential" as const,
76
75
  };
77
76
 
78
77
  beforeEach(() => {
@@ -118,6 +117,20 @@ describe("ProductCard", () => {
118
117
  });
119
118
  });
120
119
 
120
+ describe("Price Suffix", () => {
121
+ it("renders priceSuffixBottom when provided", () => {
122
+ render(
123
+ <ProductCard {...defaultProps} priceSuffixBottom="for 12 months" />
124
+ );
125
+ expect(screen.getByText("for 12 months")).toBeInTheDocument();
126
+ });
127
+
128
+ it("does not render priceSuffixBottom when not provided", () => {
129
+ render(<ProductCard {...defaultProps} />);
130
+ expect(screen.queryByText("for 12 months")).not.toBeInTheDocument();
131
+ });
132
+ });
133
+
121
134
  describe("Best Value", () => {
122
135
  it("renders best value banner when bestValue is true", () => {
123
136
  render(<ProductCard {...defaultProps} bestValue={true} />);