@windstream/react-shared-components 0.2.72 → 0.2.74

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.72",
3
+ "version": "0.2.74",
4
4
  "type": "module",
5
5
  "description": "Shared React components for Kinetic applications",
6
6
  "main": "dist/index.js",
@@ -87,7 +87,8 @@ export const RichTextBasic: FC<RichTextBasicProps> = props => {
87
87
  }
88
88
  className={cx(
89
89
  "last:[&_p]:mb-0",
90
- "text-body1 leading-normal",
90
+ // mobile-first: body3/leading-6 below md, body1/leading-normal from md up
91
+ "text-body3 leading-6 md:text-body1 md:leading-normal",
91
92
  "[&_b]:font-bold [&_strong]:font-bold [&_i]:italic [&_em]:italic",
92
93
  className,
93
94
  !enableMaxWidth && "max-w-none"
@@ -106,7 +106,7 @@ describe("AnchoredBottomBanner", () => {
106
106
  <AnchoredBottomBanner backgroundColor="blue" />
107
107
  );
108
108
  expect(
109
- container.querySelector(".bg-bg-fill-brand-supporting")
109
+ container.querySelector(".bg-bg-fill-brand-info")
110
110
  ).toBeInTheDocument();
111
111
  });
112
112
 
@@ -306,6 +306,7 @@ describe("AnchoredBottomBanner scheduledMessages", () => {
306
306
  scheduledStart: "2026-05-18",
307
307
  scheduleEnd: "2026-05-20",
308
308
  daysToShow: ["ALL"],
309
+ enableCountdownTimer: true,
309
310
  countdownStartDateTime: "2026-05-19T11:00:00Z",
310
311
  countdownEndDateTime: "2026-05-19T13:00:00Z",
311
312
  ctaButtonLabel: "Flash sale",
@@ -327,6 +328,7 @@ describe("AnchoredBottomBanner scheduledMessages", () => {
327
328
  {
328
329
  scheduledStart: "2026-05-17",
329
330
  scheduleEnd: "2026-05-19",
331
+ enableCountdownTimer: true,
330
332
  countdownEndDateTime: "2026-05-19T13:00:00Z",
331
333
  ctaButtonLabel: "Last day",
332
334
  },
@@ -383,6 +385,7 @@ describe("AnchoredBottomBanner scheduledMessages", () => {
383
385
  scheduledStart: "2026-05-18",
384
386
  scheduleEnd: "2026-05-20",
385
387
  daysToShow: ["TUE"],
388
+ enableCountdownTimer: true,
386
389
  countdownEndDateTime: "2026-05-19T13:00:00Z",
387
390
  ctaButtonLabel: "Tuesday deal",
388
391
  },
@@ -403,6 +406,7 @@ describe("AnchoredBottomBanner scheduledMessages", () => {
403
406
  {
404
407
  scheduledStart: "2026-05-18",
405
408
  scheduleEnd: "2026-05-20",
409
+ enableCountdownTimer: true,
406
410
  countdownEndDateTime: "2026-05-19T12:00:10Z",
407
411
  ctaButtonLabel: "Active message",
408
412
  },
@@ -414,6 +418,87 @@ describe("AnchoredBottomBanner scheduledMessages", () => {
414
418
  expect(screen.getByText(/00H : 00M : 10/)).toBeInTheDocument();
415
419
  });
416
420
 
421
+ it("does not show the countdown when the active message disables the timer", () => {
422
+ render(
423
+ <AnchoredBottomBanner
424
+ ctaButtonLabel="Default label"
425
+ scheduledMessages={[
426
+ {
427
+ scheduledStart: "2026-05-18",
428
+ scheduleEnd: "2026-05-20",
429
+ enableCountdownTimer: false,
430
+ countdownEndDateTime: "2026-05-19T13:00:00Z",
431
+ ctaButtonLabel: "No timer message",
432
+ },
433
+ ]}
434
+ />
435
+ );
436
+ expect(screen.getByText("No timer message")).toBeInTheDocument();
437
+ expect(screen.queryByText(/H :/)).not.toBeInTheDocument();
438
+ });
439
+
440
+ it("does not show the active message's countdown when the flag is unset", () => {
441
+ render(
442
+ <AnchoredBottomBanner
443
+ ctaButtonLabel="Default label"
444
+ scheduledMessages={[
445
+ {
446
+ scheduledStart: "2026-05-18",
447
+ scheduleEnd: "2026-05-20",
448
+ countdownEndDateTime: "2026-05-19T13:00:00Z",
449
+ ctaButtonLabel: "Unset timer message",
450
+ },
451
+ ]}
452
+ />
453
+ );
454
+ expect(screen.getByText("Unset timer message")).toBeInTheDocument();
455
+ expect(screen.queryByText(/H :/)).not.toBeInTheDocument();
456
+ });
457
+
458
+ it("does not inherit the parent's enableCountdownTimer for an active message", () => {
459
+ render(
460
+ <AnchoredBottomBanner
461
+ ctaButtonLabel="Default label"
462
+ enableCountdownTimer={true}
463
+ countdownEndDateTime="2026-05-19T13:00:00Z"
464
+ scheduledMessages={[
465
+ {
466
+ scheduledStart: "2026-05-18",
467
+ scheduleEnd: "2026-05-20",
468
+ enableCountdownTimer: false,
469
+ countdownEndDateTime: "2026-05-19T13:00:00Z",
470
+ ctaButtonLabel: "Active message",
471
+ },
472
+ ]}
473
+ />
474
+ );
475
+ // The parent timer is enabled, but the active message disables it -> no countdown.
476
+ expect(screen.getByText("Active message")).toBeInTheDocument();
477
+ expect(screen.queryByText(/H :/)).not.toBeInTheDocument();
478
+ });
479
+
480
+ it("keeps the message active after its countdown ends, hiding only the countdown", () => {
481
+ render(
482
+ <AnchoredBottomBanner
483
+ ctaButtonLabel="Default label"
484
+ scheduledMessages={[
485
+ {
486
+ scheduledStart: "2026-05-18",
487
+ scheduleEnd: "2026-05-20",
488
+ enableCountdownTimer: true,
489
+ // Countdown already ended (11:00Z) while now is 12:00Z, but the
490
+ // display window (05-18..05-20) is still open.
491
+ countdownEndDateTime: "2026-05-19T11:00:00Z",
492
+ ctaButtonLabel: "Still showing message",
493
+ },
494
+ ]}
495
+ />
496
+ );
497
+ expect(screen.getByText("Still showing message")).toBeInTheDocument();
498
+ expect(screen.queryByText("Default label")).not.toBeInTheDocument();
499
+ expect(screen.queryByText(/H :/)).not.toBeInTheDocument();
500
+ });
501
+
417
502
  it("selects the first message whose window contains today", () => {
418
503
  render(
419
504
  <AnchoredBottomBanner
@@ -441,6 +526,48 @@ describe("AnchoredBottomBanner scheduledMessages", () => {
441
526
  expect(screen.queryByText("Past message")).not.toBeInTheDocument();
442
527
  expect(screen.queryByText("Future message")).not.toBeInTheDocument();
443
528
  });
529
+
530
+ it("uses the active message's background, not the parent's", () => {
531
+ const { container } = render(
532
+ <AnchoredBottomBanner
533
+ backgroundColor="navy"
534
+ scheduledMessages={[
535
+ {
536
+ scheduledStart: "2026-05-18",
537
+ scheduleEnd: "2026-05-20",
538
+ backgroundColor: "green",
539
+ ctaButtonLabel: "Active message",
540
+ },
541
+ ]}
542
+ />
543
+ );
544
+ expect(container.querySelector(".bg-bg-fill-success")).toBeInTheDocument();
545
+ expect(
546
+ container.querySelector(".bg-bg-fill-inverse")
547
+ ).not.toBeInTheDocument();
548
+ });
549
+
550
+ it("uses the default background when the active message has none", () => {
551
+ const { container } = render(
552
+ <AnchoredBottomBanner
553
+ backgroundColor="navy"
554
+ scheduledMessages={[
555
+ {
556
+ scheduledStart: "2026-05-18",
557
+ scheduleEnd: "2026-05-20",
558
+ ctaButtonLabel: "Active message",
559
+ },
560
+ ]}
561
+ />
562
+ );
563
+ // No override on the message -> same default as a parent with no background.
564
+ expect(
565
+ container.querySelector(".bg-bg-fill-inverse")
566
+ ).not.toBeInTheDocument();
567
+ expect(
568
+ container.querySelector(".bg-bg-fill-brand-accent")
569
+ ).toBeInTheDocument();
570
+ });
444
571
  });
445
572
 
446
573
  describe("AnchoredBottomBanner scheduling timezone (America/New_York)", () => {
@@ -113,23 +113,16 @@ function dayMatches(daysToShow: string[] | undefined, nowMs: number): boolean {
113
113
  return normalized.includes(full) || normalized.includes(full.slice(0, 3));
114
114
  }
115
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.
116
+ // Active = today is within [scheduledStart, scheduleEnd] and allowed by
117
+ // daysToShow. The countdown is independent: it hides once its own end passes
118
+ // (see the `countdown` memo), but the message itself stays active across its
119
+ // whole display window.
119
120
  function isMessageActive(message: ScheduledMessage, nowMs: number): boolean {
120
121
  const startMs = parseDisplayStart(message.scheduledStart);
121
122
  const endMs = parseDisplayEnd(message.scheduleEnd);
122
123
  const afterStart = startMs === undefined || nowMs >= startMs;
123
124
  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
- );
125
+ return afterStart && beforeEnd && dayMatches(message.daysToShow, nowMs);
133
126
  }
134
127
 
135
128
  function formatCountdown(totalSeconds: number) {
@@ -159,22 +152,12 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
159
152
  const backGroundColorClasses = {
160
153
  navy: "bg-bg-fill-inverse",
161
154
  green: "bg-bg-fill-success",
162
- blue: "bg-bg-fill-brand-supporting",
155
+ blue: "bg-bg-fill-brand-info",
163
156
  purple: "bg-bg-fill-brand-tertiary",
164
157
  yellow: "bg-bg-fill-brand-accent",
165
158
  white: "bg-white",
166
159
  };
167
160
 
168
- const bgClass = backgroundColor
169
- ? backGroundColorClasses[backgroundColor]
170
- : "bg-bg-fill-brand-accent";
171
-
172
- const isLightBackground =
173
- backgroundColor === "yellow" ||
174
- backgroundColor === "white" ||
175
- !backgroundColor;
176
- const textColorClass = isLightBackground ? "text-text-primary" : "text-white";
177
-
178
161
  const [nowMs, setNowMs] = useState(() => Date.now());
179
162
  const intervalRef = useRef<number | null>(null);
180
163
 
@@ -198,7 +181,14 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
198
181
  // records the pre-mount hasActiveMessage:false fallback.
199
182
  if (!hasMounted) return;
200
183
  onResolved?.({ hasActiveMessage, activeMessage, applicableWeekday });
201
- }, [hasMounted, hasActiveMessage, activeMessage, applicableWeekday, onResolved]);
184
+ }, [
185
+ hasMounted,
186
+ hasActiveMessage,
187
+ activeMessage,
188
+ applicableWeekday,
189
+ scheduledMessages,
190
+ onResolved,
191
+ ]);
202
192
 
203
193
  // Latest display-window end, so we keep ticking to let messages activate/expire.
204
194
  const scheduleMaxEndMs = useMemo(() => {
@@ -217,6 +207,13 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
217
207
  ? activeMessage?.countdownEndDateTime
218
208
  : countdownEndDateTime;
219
209
 
210
+ // The timer flag is never inherited: an active message uses its own
211
+ // enableCountdownTimer (undefined = off, same as the banner level); otherwise
212
+ // the banner's top-level flag applies.
213
+ const effectiveEnableCountdownTimer = hasActiveMessage
214
+ ? activeMessage?.enableCountdownTimer
215
+ : enableCountdownTimer;
216
+
220
217
  const endMs = useMemo(
221
218
  () => parseCountdownDateTime(effectiveCountdownEndDateTime),
222
219
  [effectiveCountdownEndDateTime]
@@ -227,8 +224,9 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
227
224
  );
228
225
 
229
226
  const isTimerValid = useMemo(() => {
230
- // Active messages always enable the timer; default uses enableCountdownTimer.
231
- if (!hasActiveMessage && !enableCountdownTimer) return false;
227
+ // Timer shows only when explicitly enabled for the resolved source
228
+ // (active message's own flag, or the banner's flag when none is active).
229
+ if (!effectiveEnableCountdownTimer) return false;
232
230
  if (endMs === undefined) return false;
233
231
  if (effectiveCountdownStartDateTime && startMs === undefined) return false;
234
232
  if (startMs !== undefined && startMs >= endMs) {
@@ -240,8 +238,7 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
240
238
  }
241
239
  return true;
242
240
  }, [
243
- hasActiveMessage,
244
- enableCountdownTimer,
241
+ effectiveEnableCountdownTimer,
245
242
  endMs,
246
243
  startMs,
247
244
  effectiveCountdownStartDateTime,
@@ -309,6 +306,20 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
309
306
  ? activeMessage?.ctaButtonTarget
310
307
  : ctaButtonTarget;
311
308
 
309
+ // Active message owns its background; when unset it uses the same default as
310
+ // the parent-with-no-background (bg-fill-brand-accent), not the parent's color.
311
+ const effectiveBackgroundColor = hasActiveMessage
312
+ ? activeMessage?.backgroundColor
313
+ : backgroundColor;
314
+ const bgClass = effectiveBackgroundColor
315
+ ? backGroundColorClasses[effectiveBackgroundColor]
316
+ : "bg-bg-fill-brand-accent";
317
+ const isLightBackground =
318
+ effectiveBackgroundColor === "yellow" ||
319
+ effectiveBackgroundColor === "white" ||
320
+ !effectiveBackgroundColor;
321
+ const textColorClass = isLightBackground ? "text-text-primary" : "text-white";
322
+
312
323
  return (
313
324
  <section id={anchorId}>
314
325
  <div
@@ -25,10 +25,14 @@ export interface ScheduledMessage {
25
25
  scheduleEnd?: string;
26
26
  // Weekdays the message may show on within the window. "ALL" (or empty) = every day.
27
27
  daysToShow?: ScheduledMessageDay[];
28
+ // Per-message timer toggle. The message never inherits the banner's
29
+ // enableCountdownTimer; the countdown shows only when this is explicitly true.
30
+ enableCountdownTimer?: boolean;
28
31
  // Countdown timer window (full timestamps).
29
32
  countdownStartDateTime?: string;
30
33
  countdownEndDateTime?: string;
31
34
  iconName?: string;
35
+ backgroundColor?: AnchoredBottomBannerProps["backgroundColor"];
32
36
  ctaButtonLabel?: string;
33
37
  ctaSuffixText?: string;
34
38
  ctaButtonLink?: string;
@@ -1120,6 +1120,61 @@ describe("renderContentfulRichTextTable", () => {
1120
1120
  expect(container).toHaveTextContent("Other Title");
1121
1121
  });
1122
1122
 
1123
+ it("renders componentVideoLink inline entry (REST shape, no links prop)", () => {
1124
+ // Regression test: a doc containing both a table and an inline
1125
+ // componentVideoLink entry previously rendered nothing for the video,
1126
+ // since this function's INLINES.EMBEDDED_ENTRY override only resolved
1127
+ // entries via the GraphQL `links` lookup.
1128
+ const doc = makeDoc({
1129
+ nodeType: BLOCKS.PARAGRAPH,
1130
+ data: {},
1131
+ content: [
1132
+ makeInlineEntry("componentVideoLink", {
1133
+ link: "https://youtube.com/watch?v=123",
1134
+ }),
1135
+ ],
1136
+ });
1137
+ const { container } = render(<>{renderContentfulRichTextTable(doc)}</>);
1138
+ const el = container.querySelector('[data-testid="video-link"]')!;
1139
+ expect(el).toBeInTheDocument();
1140
+ const videoLink = JSON.parse(el.getAttribute("data-video-link")!);
1141
+ expect(videoLink).toMatchObject({
1142
+ link: "https://youtube.com/watch?v=123",
1143
+ });
1144
+ });
1145
+
1146
+ it("renders ComponentVideoLink (GraphQL __typename) inline entry resolved from links in table path", () => {
1147
+ const entryId = "video-inline-1";
1148
+ const doc = makeDoc({
1149
+ nodeType: BLOCKS.PARAGRAPH,
1150
+ data: {},
1151
+ content: [
1152
+ {
1153
+ nodeType: INLINES.EMBEDDED_ENTRY,
1154
+ data: { target: { sys: { id: entryId } } },
1155
+ content: [],
1156
+ },
1157
+ ],
1158
+ });
1159
+ const links = {
1160
+ entries: {
1161
+ inline: [
1162
+ {
1163
+ sys: { id: entryId },
1164
+ __typename: "ComponentVideoLink",
1165
+ link: "https://youtube.com/watch?v=456",
1166
+ },
1167
+ ],
1168
+ },
1169
+ };
1170
+ const { container } = render(
1171
+ <>{renderContentfulRichTextTable(doc, links)}</>
1172
+ );
1173
+ expect(
1174
+ container.querySelector('[data-testid="video-link"]')
1175
+ ).toBeInTheDocument();
1176
+ });
1177
+
1123
1178
  it("returns null for inline entry not found in links", () => {
1124
1179
  const doc = makeDoc({
1125
1180
  nodeType: BLOCKS.PARAGRAPH,
@@ -471,18 +471,21 @@ export function renderContentfulRichTextTable(
471
471
  );
472
472
  },
473
473
  [INLINES.EMBEDDED_ENTRY]: node => {
474
- const entryId = node.data.target.sys.id;
475
-
476
- const entry = links?.entries?.inline?.find(
477
- (e: any) => e.sys.id === entryId
478
- );
474
+ // resolveEmbeddedEntry/getEntryTypeId/getEntryFields handle both REST
475
+ // (already-resolved target) and GraphQL (`links` lookup) shapes - a
476
+ // plain `links?.entries?.inline` lookup here always returns undefined
477
+ // for REST responses (no `links` prop passed), silently dropping any
478
+ // inline embedded entry (e.g. componentVideoLink) in a doc with a table.
479
+ const entry = resolveEmbeddedEntry(node, links);
479
480
  if (!entry) return null;
481
+ const ctid = getEntryTypeId(entry);
482
+ const fields = getEntryFields(entry);
480
483
 
481
- if (entry.__typename === "ComponentCheckList") {
484
+ if (ctid === "componentCheckList" || ctid === "ComponentCheckList") {
482
485
  // FIX: Don't use a Hook here.
483
486
  // Map the items manually or use a non-hook helper function.
484
487
  const items =
485
- entry.list?.items?.map((item: any) =>
488
+ (fields.list?.items ?? entry.list?.items)?.map((item: any) =>
486
489
  renderContentfulRichText(
487
490
  toDocument(item?.checkListTitle ?? ""),
488
491
  true,
@@ -498,7 +501,11 @@ export function renderContentfulRichTextTable(
498
501
  );
499
502
  }
500
503
 
501
- return <span>{entry.title || ""}</span>;
504
+ if (ctid === "componentVideoLink" || ctid === "ComponentVideoLink") {
505
+ return <VideoLink videoLink={toVideoLinkProps(fields)} />;
506
+ }
507
+
508
+ return <span>{fields?.title ?? entry.title ?? ""}</span>;
502
509
  },
503
510
  },
504
511
  };
@@ -104,6 +104,7 @@
104
104
  --color-bg-fill-brand-hover: #45c187;
105
105
  --color-bg-fill-brand-selected: #24a76a;
106
106
  --color-bg-fill-brand-supporting: #07b2e2;
107
+ --color-bg-fill-brand-info: #07b2e2;
107
108
  --color-bg-fill-brand-tertiary: #931d69;
108
109
  --color-bg-fill-translucent: rgba(255, 255, 255, 0.5);
109
110
  --color-bg-fill-critical: #d40000;
@@ -243,6 +244,7 @@
243
244
  --color-bg-fill-brand-hover: #30aec5;
244
245
  --color-bg-fill-brand-selected: #007c93;
245
246
  --color-bg-fill-brand-supporting: #40afc9;
247
+ --color-bg-fill-brand-info: #40afc9;
246
248
  --color-bg-fill-brand-tertiary: #a33895;
247
249
  --color-bg-fill-inverse: #231f20;
248
250
  --color-bg-fill-inverse-active: #231f20;
@@ -300,6 +302,7 @@
300
302
  --color-bg-fill-brand-selected: #0393ba;
301
303
 
302
304
  --color-bg-fill-brand-supporting: #26b170;
305
+ --color-bg-fill-brand-info: #0393ba;
303
306
 
304
307
  /* Text colors */
305
308
  --color-text-brand: #0393ba;
@@ -158,6 +158,7 @@ module.exports = {
158
158
  hover: "var(--color-bg-fill-brand-hover)",
159
159
  selected: "var(--color-bg-fill-brand-selected)",
160
160
  supporting: "var(--color-bg-fill-brand-supporting)",
161
+ info: "var(--color-bg-fill-brand-info)",
161
162
  tertiary: "var(--color-bg-fill-brand-tertiary)",
162
163
  },
163
164
  critical: {