@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.
- package/dist/contentful/index.d.ts +13 -1
- package/dist/contentful/index.esm.js +4 -4
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +2 -2
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +3 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/select-plan-button/index.tsx +2 -2
- package/src/components/select-plan-button/types.ts +1 -1
- package/src/components/video-link/vimeo-embed.test.tsx +14 -0
- package/src/contentful/blocks/anchored-bottom-banner/index.test.tsx +128 -1
- package/src/contentful/blocks/anchored-bottom-banner/index.tsx +39 -28
- package/src/contentful/blocks/anchored-bottom-banner/types.ts +4 -0
- package/src/contentful/blocks/cards/product-card/index.test.tsx +48 -9
- package/src/contentful/blocks/cards/product-card/index.tsx +3 -1
- package/src/contentful/blocks/cards/product-card/types.ts +2 -1
- package/src/contentful/blocks/carousel/helper.test.tsx +43 -2
- package/src/contentful/blocks/carousel/helper.tsx +26 -3
- package/src/contentful/blocks/carousel/index.test.tsx +72 -2
- package/src/contentful/blocks/carousel/index.tsx +10 -1
- package/src/contentful/blocks/carousel/types.ts +12 -0
- package/src/styles/globals.css +3 -0
- package/tailwind.config.cjs +1 -0
|
@@ -106,7 +106,7 @@ describe("AnchoredBottomBanner", () => {
|
|
|
106
106
|
<AnchoredBottomBanner backgroundColor="blue" />
|
|
107
107
|
);
|
|
108
108
|
expect(
|
|
109
|
-
container.querySelector(".bg-bg-fill-brand-
|
|
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]
|
|
117
|
-
//
|
|
118
|
-
// countdown
|
|
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
|
-
|
|
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-
|
|
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
|
-
}, [
|
|
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
|
-
//
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
)
|
|
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
|
-
|
|
98
|
-
)
|
|
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
|
-
|
|
255
|
-
)
|
|
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
|
|
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("
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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">
|
|
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} />);
|