@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.
@@ -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
  }
@@ -102,7 +102,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
102
102
  as="p"
103
103
  className={cx(
104
104
  "label3 text-wrap lowercase",
105
- isDark ? "text-text-accent-on-bg-inverse" : "text-text-brand"
105
+ isDark ? "text-text-inverse" : "text-text"
106
106
  )}
107
107
  >
108
108
  {planSubtext}
@@ -275,7 +275,12 @@ export const ProductCard: React.FC<ProductCardProps> = ({
275
275
  width={12}
276
276
  height={12}
277
277
  />
278
- <span className="footnote text-text">
278
+ <span
279
+ className={cx(
280
+ "footnote",
281
+ isDark ? "text-text-inverse" : "text-text"
282
+ )}
283
+ >
279
284
  {innerBadge.badgeText.includes("|") ? (
280
285
  <>
281
286
  <span className="font-bold">
@@ -164,7 +164,7 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
164
164
  </div>
165
165
  )}
166
166
  </div>
167
- <aside className="flex w-full flex-1 shrink-0 items-center justify-center lg:w-auto">
167
+ <div className="flex w-full flex-1 shrink-0 items-center justify-center lg:w-auto">
168
168
  {/* Media Section: only when no Video Link is attached at all */}
169
169
  {image && !hasVideoLink && (
170
170
  <div
@@ -203,7 +203,7 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
203
203
  isAboveTheFold={isAboveTheFold}
204
204
  onVideoEvent={onVideoEvent}
205
205
  />
206
- </aside>
206
+ </div>
207
207
  </div>
208
208
  </div>
209
209
 
@@ -88,7 +88,11 @@ export { DynamicTabs } from "./blocks/dynamic-tabs";
88
88
  export type { DynamicTabsT } from "./blocks/dynamic-tabs/types";
89
89
 
90
90
  export { AnchoredBottomBanner } from "./blocks/anchored-bottom-banner";
91
- export type { AnchoredBottomBannerProps } from "./blocks/anchored-bottom-banner/types";
91
+ export type {
92
+ AnchoredBottomBannerProps,
93
+ ScheduledMessage,
94
+ ScheduledMessageDay,
95
+ } from "./blocks/anchored-bottom-banner/types";
92
96
 
93
97
  export { default as CookieBanner } from "./blocks/cookiebanner";
94
98
  export type { TypeComponentRichTextFields as CookieBannerProps } from "./blocks/cookiebanner/type";
@@ -276,6 +276,18 @@ describe("renderContentfulRichText", () => {
276
276
  const a = container.querySelector("a")!;
277
277
  expect(a).toHaveClass("custom-link");
278
278
  });
279
+
280
+ it("uses a separate class for links inside list items", () => {
281
+ const list = makeList(false, ["List link"]);
282
+ list.content[0].content = [makeHyperlink("/list-link", "List link")];
283
+ const doc = makeDoc(makeHyperlink("/paragraph-link", "Paragraph link"), list);
284
+
285
+ const { container } = render(<>{renderContentfulRichText(doc)}</>);
286
+ const links = container.querySelectorAll("a");
287
+
288
+ expect(links[0]).toHaveClass("body1", "font-bold");
289
+ expect(links[1]).toHaveClass("body2");
290
+ });
279
291
  });
280
292
 
281
293
  describe("Custom options merge", () => {
@@ -267,10 +267,23 @@ export function renderContentfulRichText(
267
267
  className: string = "body1",
268
268
  linkClassName: string = "body1 font-bold",
269
269
  options?: Options,
270
- links?: any
270
+ links?: any,
271
+ listLinkClassName: string = "body2"
271
272
  ): ReactNode | null {
272
273
  if (!doc || !Array.isArray(doc.content)) return null;
273
274
 
275
+ const listItemHyperlinks = new WeakSet<object>();
276
+ const collectListItemHyperlinks = (node: any, isInListItem = false) => {
277
+ const isListItem = isInListItem || node?.nodeType === BLOCKS.LIST_ITEM;
278
+ if (isListItem && node?.nodeType === INLINES.HYPERLINK) {
279
+ listItemHyperlinks.add(node);
280
+ }
281
+ node?.content?.forEach((child: any) =>
282
+ collectListItemHyperlinks(child, isListItem)
283
+ );
284
+ };
285
+ collectListItemHyperlinks(doc);
286
+
274
287
  const defaultOptions = getDefaultOptions(links);
275
288
  const merged: Options = {
276
289
  ...defaultOptions,
@@ -301,7 +314,9 @@ export function renderContentfulRichText(
301
314
  target={shouldOpenBlank ? "_blank" : undefined}
302
315
  rel={shouldOpenBlank ? "noopener" : undefined}
303
316
  variant="default"
304
- className={linkClassName}
317
+ className={
318
+ listItemHyperlinks.has(node) ? listLinkClassName : linkClassName
319
+ }
305
320
  >
306
321
  {children}
307
322
  </Link>