@windstream/react-shared-components 0.2.71 → 0.2.73

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.
@@ -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;
@@ -86,16 +86,16 @@ describe("ProductCard", () => {
86
86
 
87
87
  it("renders plan name", () => {
88
88
  render(<ProductCard {...defaultProps} />);
89
- expect(
90
- screen.getByRole("heading", { level: 3 })
91
- ).toHaveTextContent("Kinetic 500");
89
+ expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent(
90
+ "Kinetic 500"
91
+ );
92
92
  });
93
93
 
94
94
  it("renders plan subtext", () => {
95
95
  render(<ProductCard {...defaultProps} />);
96
- expect(
97
- screen.getByRole("heading", { level: 3 })
98
- ).toHaveTextContent("Up to 500 Mbps");
96
+ expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent(
97
+ "Up to 500 Mbps"
98
+ );
99
99
  });
100
100
 
101
101
  it("renders price dollar sign", () => {
@@ -126,6 +126,45 @@ describe("ProductCard", () => {
126
126
  });
127
127
  });
128
128
 
129
+ describe("Business theme", () => {
130
+ it("uses the semantic brand token for the plan name and lowercases it by default", () => {
131
+ render(<ProductCard {...defaultProps} />);
132
+ const heading = screen.getByRole("heading", { level: 3 });
133
+ // Color is driven by the themeable token, not a hardcoded hex.
134
+ expect(heading.className).toContain("text-text-brand");
135
+ expect(heading.className).toContain("lowercase");
136
+ expect(heading.className).not.toContain("[#0393BA]");
137
+ });
138
+
139
+ it("keeps the authored casing in business mode (color stays theme-driven)", () => {
140
+ render(<ProductCard {...defaultProps} enableBusinessSettings={true} />);
141
+ const heading = screen.getByRole("heading", { level: 3 });
142
+ // Still the semantic brand token — the business teal comes from the
143
+ // data-theme scope, not a card-level class.
144
+ expect(heading.className).toContain("text-text-brand");
145
+ expect(heading.className).not.toContain("lowercase");
146
+ expect(heading.className).not.toContain("[#0393BA]");
147
+ });
148
+
149
+ it("never hardcodes a hex accent on the plan name", () => {
150
+ render(<ProductCard {...defaultProps} enableBusinessSettings={true} />);
151
+ const heading = screen.getByRole("heading", { level: 3 });
152
+ expect(heading.className).not.toMatch(/#[0-9a-fA-F]{6}/);
153
+ });
154
+
155
+ it("keeps the dark (highlighted) accent regardless of business mode", () => {
156
+ render(
157
+ <ProductCard
158
+ {...defaultProps}
159
+ theme="dark"
160
+ enableBusinessSettings={true}
161
+ />
162
+ );
163
+ const heading = screen.getByRole("heading", { level: 3 });
164
+ expect(heading.className).toContain("text-text-accent-on-bg-inverse");
165
+ });
166
+ });
167
+
129
168
  describe("Price Suffix", () => {
130
169
  it("renders priceSuffixBottom when provided", () => {
131
170
  render(
@@ -250,9 +289,9 @@ describe("ProductCard", () => {
250
289
  const dollar = screen.getByText("$100");
251
290
  expect(dollar).toHaveClass("label3");
252
291
  // The surrounding text keeps the parent body3 weight (not label3).
253
- expect(
254
- screen.getByText(/prepaid Mastercard®/)
255
- ).not.toHaveClass("label3");
292
+ expect(screen.getByText(/prepaid Mastercard®/)).not.toHaveClass(
293
+ "label3"
294
+ );
256
295
  });
257
296
  });
258
297
 
@@ -54,6 +54,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
54
54
  priceSuffixBottom,
55
55
  hasAnyBadge = false,
56
56
  twoColumn = false,
57
+ enableBusinessSettings = false,
57
58
  }) => {
58
59
  const [internalExpanded, setInternalExpanded] = useState(false);
59
60
  // Use parent state if provided, otherwise use internal state
@@ -116,7 +117,8 @@ export const ProductCard: React.FC<ProductCardProps> = ({
116
117
  as="h3"
117
118
  className={cx(
118
119
  planNameClass,
119
- "whitespace-normal lowercase",
120
+ "whitespace-normal",
121
+ !enableBusinessSettings && "lowercase",
120
122
  isDark ? "text-text-accent-on-bg-inverse" : "text-text-brand"
121
123
  )}
122
124
  >
@@ -26,7 +26,7 @@ export interface ProductCardProps {
26
26
  onToggleExpand?: () => void;
27
27
  ctaText?: string;
28
28
  cta?: ButtonProps;
29
- onCtaClick: () => void;
29
+ onCtaClick: (event: React.MouseEvent<HTMLElement>) => void;
30
30
  renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
31
31
  /** True when any card in the set has a best-value badge, so non-badge cards
32
32
  * reserve the banner height and stay vertically aligned. */
@@ -35,4 +35,5 @@ export interface ProductCardProps {
35
35
  * compact mobile typography below `md`. One-column stacks keep the standard
36
36
  * sizes at every breakpoint. */
37
37
  twoColumn?: boolean;
38
+ enableBusinessSettings?: boolean;
38
39
  }
@@ -31,12 +31,14 @@ jest.mock("@shared/contentful/blocks/cards/product-card", () => ({
31
31
  onCtaClick,
32
32
  onToggleExpand,
33
33
  isExpanded,
34
+ enableBusinessSettings,
34
35
  }: any) => (
35
36
  <div
36
37
  data-testid="product-card"
37
38
  data-plan={planName}
38
39
  data-speed={speed}
39
40
  data-expanded={isExpanded}
41
+ data-business={enableBusinessSettings ? "true" : "false"}
40
42
  >
41
43
  {planName}
42
44
  <button onClick={onCtaClick} data-testid="product-cta">
@@ -329,15 +331,54 @@ describe("ProductCardCarousel", () => {
329
331
  expect(toggleButtons[0]).toBeInTheDocument();
330
332
  });
331
333
 
332
- it("handles CTA click on product card", () => {
334
+ it("fires onProductCardCtaClick with the product item and click event", () => {
335
+ const onProductCardCtaClick = jest.fn();
336
+ const fields = createProductFields([sampleProduct]);
337
+ render(
338
+ <ProductCardCarousel
339
+ fields={fields}
340
+ onProductCardCtaClick={onProductCardCtaClick}
341
+ />
342
+ );
343
+ const ctaButton = screen.getByTestId("product-cta");
344
+ fireEvent.click(ctaButton);
345
+ expect(onProductCardCtaClick).toHaveBeenCalledTimes(1);
346
+ expect(onProductCardCtaClick.mock.calls[0][0]).toEqual(
347
+ expect.objectContaining({ sys: { id: "prod-1" } })
348
+ );
349
+ // Second argument is the originating click event.
350
+ expect(onProductCardCtaClick.mock.calls[0][1]).toBeDefined();
351
+ });
352
+
353
+ it("handles CTA click when no onProductCardCtaClick is provided", () => {
333
354
  const fields = createProductFields([sampleProduct]);
334
355
  render(<ProductCardCarousel fields={fields} />);
335
356
  const ctaButton = screen.getByTestId("product-cta");
336
357
  fireEvent.click(ctaButton);
337
- // handleCtaClick just returns; no error
358
+ // No handler wired; click is a no-op and must not throw.
338
359
  expect(ctaButton).toBeInTheDocument();
339
360
  });
340
361
 
362
+ it("defaults product cards to the non-business theme", () => {
363
+ const fields = createProductFields([sampleProduct]);
364
+ render(<ProductCardCarousel fields={fields} />);
365
+ expect(screen.getAllByTestId("product-card")[0]).toHaveAttribute(
366
+ "data-business",
367
+ "false"
368
+ );
369
+ });
370
+
371
+ it("forwards enableBusinessSettings to the product cards", () => {
372
+ const fields = createProductFields([sampleProduct]);
373
+ render(
374
+ <ProductCardCarousel fields={fields} enableBusinessSettings={true} />
375
+ );
376
+ expect(screen.getAllByTestId("product-card")[0]).toHaveAttribute(
377
+ "data-business",
378
+ "true"
379
+ );
380
+ });
381
+
341
382
  it("handles toggle expand for mobile cards in carousel mode", () => {
342
383
  const items = Array.from({ length: 4 }, (_, i) => ({
343
384
  ...sampleProduct,
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
2
2
  import {
3
3
  CarouselWithProductCards,
4
4
  CarouselWithTestimonialCards,
5
+ ProductCardFields,
5
6
  TabSwitchProps,
6
7
  } from "./types";
7
8
 
@@ -17,11 +18,18 @@ function ProductCardPanel({
17
18
  fields,
18
19
  renderCheckPlans,
19
20
  isVisible = true,
21
+ onProductCardCtaClick,
22
+ enableBusinessSettings = false,
20
23
  }: {
21
24
  fields: CarouselWithProductCards;
22
25
  onModalButtonClick?: (id?: string) => void;
23
26
  renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
24
27
  isVisible?: boolean;
28
+ onProductCardCtaClick?: (
29
+ item: ProductCardFields,
30
+ event: React.MouseEvent<HTMLElement>
31
+ ) => void;
32
+ enableBusinessSettings?: boolean;
25
33
  }) {
26
34
  const itemsExpanded = fields?.items?.items?.[0]?.benefitsExpanded || false;
27
35
  const [desktopExpanded, setDesktopExpanded] = useState(itemsExpanded);
@@ -284,8 +292,8 @@ function ProductCardPanel({
284
292
  setDesktopExpanded(prev => !prev);
285
293
  };
286
294
 
287
- const handleCtaClick = () => {
288
- return;
295
+ const handleCtaClick = (event: React.MouseEvent<HTMLElement>) => {
296
+ onProductCardCtaClick?.(item as ProductCardFields, event);
289
297
  };
290
298
 
291
299
  return (
@@ -331,6 +339,7 @@ function ProductCardPanel({
331
339
  priceSuffixBottom={item?.priceSuffixBottom}
332
340
  hasAnyBadge={hasAnyBadge}
333
341
  twoColumn={twoColumn}
342
+ enableBusinessSettings={enableBusinessSettings}
334
343
  />
335
344
  </div>
336
345
  );
@@ -425,12 +434,19 @@ export function ProductCardCarousel({
425
434
  renderCheckPlans,
426
435
  activeTab,
427
436
  tabs,
437
+ onProductCardCtaClick,
438
+ enableBusinessSettings = false,
428
439
  }: {
429
440
  fields: CarouselWithProductCards;
430
441
  onModalButtonClick?: (id?: string) => void;
431
442
  renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
432
443
  activeTab?: string;
433
444
  tabs?: string[];
445
+ onProductCardCtaClick?: (
446
+ item: ProductCardFields,
447
+ event: React.MouseEvent<HTMLElement>
448
+ ) => void;
449
+ enableBusinessSettings?: boolean;
434
450
  }) {
435
451
  const allItems = fields?.items?.items || [];
436
452
 
@@ -456,6 +472,8 @@ export function ProductCardCarousel({
456
472
  fields={tabFields as CarouselWithProductCards}
457
473
  renderCheckPlans={renderCheckPlans}
458
474
  isVisible={tab === activeTab}
475
+ onProductCardCtaClick={onProductCardCtaClick}
476
+ enableBusinessSettings={enableBusinessSettings}
459
477
  />
460
478
  </div>
461
479
  );
@@ -465,7 +483,12 @@ export function ProductCardCarousel({
465
483
  }
466
484
 
467
485
  return (
468
- <ProductCardPanel fields={fields} renderCheckPlans={renderCheckPlans} />
486
+ <ProductCardPanel
487
+ fields={fields}
488
+ renderCheckPlans={renderCheckPlans}
489
+ onProductCardCtaClick={onProductCardCtaClick}
490
+ enableBusinessSettings={enableBusinessSettings}
491
+ />
469
492
  );
470
493
  }
471
494
 
@@ -8,8 +8,16 @@ import { fireEvent, render, screen } from "@testing-library/react";
8
8
 
9
9
  // Mock helper components
10
10
  jest.mock("./helper", () => ({
11
- ProductCardCarousel: ({ fields }: any) => (
12
- <div data-testid="product-card-carousel">{fields?.title}</div>
11
+ ProductCardCarousel: ({ fields, onProductCardCtaClick }: any) => (
12
+ <div data-testid="product-card-carousel">
13
+ {fields?.title}
14
+ <button
15
+ data-testid="mock-cta"
16
+ onClick={e => onProductCardCtaClick?.({ sys: { id: "prod-1" } }, e)}
17
+ >
18
+ cta
19
+ </button>
20
+ </div>
13
21
  ),
14
22
  TestimonialCarousel: ({ fields }: any) => (
15
23
  <div data-testid="testimonial-carousel">{fields?.title}</div>
@@ -200,6 +208,68 @@ describe("Carousel", () => {
200
208
  });
201
209
  });
202
210
 
211
+ describe("analytics", () => {
212
+ it("forwards onProductCardCtaClick with the item and click event", () => {
213
+ const onProductCardCtaClick = jest.fn();
214
+ render(
215
+ <Carousel
216
+ {...defaultProps}
217
+ hasProductCards={true}
218
+ onProductCardCtaClick={onProductCardCtaClick}
219
+ />
220
+ );
221
+ fireEvent.click(screen.getByTestId("mock-cta"));
222
+ expect(onProductCardCtaClick).toHaveBeenCalledTimes(1);
223
+ expect(onProductCardCtaClick.mock.calls[0][0]).toEqual({
224
+ sys: { id: "prod-1" },
225
+ });
226
+ // Second argument is the originating click event (needed by consumers to
227
+ // dispatch their own click analytics from the clicked element).
228
+ expect(onProductCardCtaClick.mock.calls[0][1]).toBeDefined();
229
+ });
230
+
231
+ it("attaches productCardsRef to the product-card container", () => {
232
+ const ref = React.createRef<HTMLDivElement>();
233
+ render(
234
+ <Carousel
235
+ {...defaultProps}
236
+ hasProductCards={true}
237
+ productCardsRef={ref}
238
+ />
239
+ );
240
+ expect(ref.current).toBeInstanceOf(HTMLDivElement);
241
+ expect(ref.current).toContainElement(
242
+ screen.getByTestId("product-card-carousel")
243
+ );
244
+ });
245
+ });
246
+
247
+ describe("business theme", () => {
248
+ it("does not scope the business theme by default", () => {
249
+ const { container } = render(
250
+ <Carousel {...defaultProps} hasProductCards={true} />
251
+ );
252
+ expect(
253
+ container.querySelector('[data-theme="business-rebrand"]')
254
+ ).not.toBeInTheDocument();
255
+ });
256
+
257
+ it("scopes the product cards to the business-rebrand theme when enabled", () => {
258
+ const { container } = render(
259
+ <Carousel
260
+ {...defaultProps}
261
+ hasProductCards={true}
262
+ enableBusinessSettings={true}
263
+ />
264
+ );
265
+ const scope = container.querySelector('[data-theme="business-rebrand"]');
266
+ expect(scope).toBeInTheDocument();
267
+ expect(scope).toContainElement(
268
+ screen.getByTestId("product-card-carousel")
269
+ );
270
+ });
271
+ });
272
+
203
273
  describe("testimonial cards", () => {
204
274
  it("renders TestimonialCarousel when hasTestimonialCards is true", () => {
205
275
  render(<Carousel {...defaultProps} hasTestimonialCards={true} />);