@windstream/react-shared-components 0.2.63 → 0.2.64
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 +7 -0
- package/dist/contentful/index.esm.js +4 -4
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +1 -1
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +2 -1
- package/dist/index.d.ts +1 -0
- 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 +6 -2
- package/src/components/select-plan-button/types.ts +1 -0
- package/src/contentful/blocks/cards/product-card/index.tsx +50 -24
- package/src/contentful/blocks/cards/product-card/types.ts +7 -0
- package/src/contentful/blocks/carousel/Carousel.stories.tsx +34 -8
- package/src/contentful/blocks/carousel/helper.test.tsx +127 -10
- package/src/contentful/blocks/carousel/helper.tsx +114 -21
|
@@ -46,6 +46,8 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|
|
46
46
|
techType,
|
|
47
47
|
ismaxSpeed,
|
|
48
48
|
priceSuffixBottom,
|
|
49
|
+
hasAnyBadge = false,
|
|
50
|
+
twoColumn = false,
|
|
49
51
|
}) => {
|
|
50
52
|
const [internalExpanded, setInternalExpanded] = useState(false);
|
|
51
53
|
// Use parent state if provided, otherwise use internal state
|
|
@@ -62,19 +64,31 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|
|
62
64
|
|
|
63
65
|
const isDark = theme === "dark";
|
|
64
66
|
|
|
67
|
+
// Two-column mobile cards use smaller type below `md` and step up to the
|
|
68
|
+
// standard sizes from `md` up (matches production residential). One-column
|
|
69
|
+
// stacks keep the standard sizes at every breakpoint.
|
|
70
|
+
const planNameClass = twoColumn ? "label3 md:subheading2" : "subheading2";
|
|
71
|
+
const priceIntegerClass = twoColumn
|
|
72
|
+
? "heading2 md:subheading6"
|
|
73
|
+
: "subheading6";
|
|
74
|
+
const priceUnitClass = twoColumn ? "label3 md:label2" : "label2";
|
|
75
|
+
|
|
65
76
|
return (
|
|
66
|
-
<article
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
)}
|
|
71
|
-
>
|
|
72
|
-
{/* Best Value Banner */}
|
|
73
|
-
{bestValue && (
|
|
77
|
+
<article className="relative flex h-full w-full max-w-[392px] flex-col">
|
|
78
|
+
{/* Best Value Banner. Non-best cards render an invisible banner of the
|
|
79
|
+
same height when a sibling has one, so content stays aligned. */}
|
|
80
|
+
{bestValue ? (
|
|
74
81
|
<div className="label3 rounded-t-[20px] bg-bg-fill-brand-accent px-6 py-2 text-center text-text">
|
|
75
82
|
{bestValueText}
|
|
76
83
|
</div>
|
|
77
|
-
)
|
|
84
|
+
) : hasAnyBadge ? (
|
|
85
|
+
<div
|
|
86
|
+
aria-hidden="true"
|
|
87
|
+
className="label3 invisible px-6 py-2 text-center"
|
|
88
|
+
>
|
|
89
|
+
{"\u00A0"}
|
|
90
|
+
</div>
|
|
91
|
+
) : null}
|
|
78
92
|
|
|
79
93
|
{/* Main Card */}
|
|
80
94
|
<div
|
|
@@ -87,12 +101,16 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|
|
87
101
|
)}
|
|
88
102
|
>
|
|
89
103
|
{/* Plan Name & Price */}
|
|
90
|
-
<header
|
|
91
|
-
|
|
104
|
+
<header
|
|
105
|
+
data-pc-header
|
|
106
|
+
className="overflow-none flex flex-wrap items-start justify-between gap-2 md:gap-4"
|
|
107
|
+
>
|
|
108
|
+
<div className="flex w-full flex-col md:w-auto">
|
|
92
109
|
<Text
|
|
93
110
|
as="h3"
|
|
94
111
|
className={cx(
|
|
95
|
-
|
|
112
|
+
planNameClass,
|
|
113
|
+
"whitespace-normal lowercase",
|
|
96
114
|
isDark ? "text-text-accent-on-bg-inverse" : "text-text-brand"
|
|
97
115
|
)}
|
|
98
116
|
>
|
|
@@ -102,18 +120,19 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|
|
102
120
|
as="p"
|
|
103
121
|
className={cx(
|
|
104
122
|
"label3 text-wrap lowercase",
|
|
105
|
-
isDark ? "text-text-inverse" : "text-text"
|
|
123
|
+
isDark ? "text-text-accent-on-bg-inverse" : "text-text-brand"
|
|
106
124
|
)}
|
|
107
125
|
>
|
|
108
126
|
{planSubtext}
|
|
109
127
|
</Text>
|
|
110
128
|
</div>
|
|
111
|
-
<div className="flex flex-col items-end">
|
|
129
|
+
<div className="flex flex-col items-start md:items-end">
|
|
112
130
|
<div className="flex items-start">
|
|
113
131
|
<Text
|
|
114
132
|
as="span"
|
|
115
133
|
className={cx(
|
|
116
|
-
|
|
134
|
+
priceUnitClass,
|
|
135
|
+
"mr-1 mt-[2px]",
|
|
117
136
|
isDark ? "text-text-inverse" : "text-text"
|
|
118
137
|
)}
|
|
119
138
|
>
|
|
@@ -122,7 +141,8 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|
|
122
141
|
<Text
|
|
123
142
|
as="span"
|
|
124
143
|
className={cx(
|
|
125
|
-
|
|
144
|
+
priceIntegerClass,
|
|
145
|
+
"leading-none",
|
|
126
146
|
isDark ? "text-text-inverse" : "text-text"
|
|
127
147
|
)}
|
|
128
148
|
>
|
|
@@ -131,7 +151,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|
|
131
151
|
<Text
|
|
132
152
|
as="span"
|
|
133
153
|
className={cx(
|
|
134
|
-
|
|
154
|
+
priceUnitClass,
|
|
135
155
|
isDark ? "text-text-inverse" : "text-text"
|
|
136
156
|
)}
|
|
137
157
|
>
|
|
@@ -153,10 +173,10 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|
|
153
173
|
</header>
|
|
154
174
|
|
|
155
175
|
{/* Description */}
|
|
156
|
-
<section>
|
|
176
|
+
<section data-pc-description>
|
|
157
177
|
<Text
|
|
158
178
|
className={cx(
|
|
159
|
-
"body2 h-[56px]",
|
|
179
|
+
"footnote md:body2 md:h-[56px]",
|
|
160
180
|
isDark ? "text-text-inverse" : "text-text"
|
|
161
181
|
)}
|
|
162
182
|
>
|
|
@@ -166,7 +186,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|
|
166
186
|
|
|
167
187
|
{/* Gift Badge */}
|
|
168
188
|
{giftBadge?.length ? (
|
|
169
|
-
<div className="min-h-[76px]">
|
|
189
|
+
<div data-pc-gift className="min-h-[76px]">
|
|
170
190
|
<div
|
|
171
191
|
className={cx(
|
|
172
192
|
"flex-col items-center rounded-surface-sm p-2",
|
|
@@ -197,7 +217,12 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|
|
197
217
|
</div>
|
|
198
218
|
</div>
|
|
199
219
|
) : (
|
|
200
|
-
<div
|
|
220
|
+
<div
|
|
221
|
+
data-pc-gift
|
|
222
|
+
data-pc-gift-empty
|
|
223
|
+
className="invisible min-h-[76px] p-2"
|
|
224
|
+
aria-hidden="true"
|
|
225
|
+
>
|
|
201
226
|
<Text
|
|
202
227
|
as="div"
|
|
203
228
|
className={cx(
|
|
@@ -222,6 +247,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|
|
222
247
|
iconSize={40}
|
|
223
248
|
techType={techType}
|
|
224
249
|
isMax={ismaxSpeed}
|
|
250
|
+
twoColumn={twoColumn}
|
|
225
251
|
/>
|
|
226
252
|
{/* Features */}
|
|
227
253
|
{features.length > 0 && (
|
|
@@ -268,12 +294,12 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|
|
268
294
|
/>
|
|
269
295
|
</div>
|
|
270
296
|
{innerBadge?.badgeIcon ? (
|
|
271
|
-
<div className="mt-3 flex items-center gap-2 text-
|
|
297
|
+
<div className="mt-3 flex items-center gap-2 text-left">
|
|
272
298
|
<Image
|
|
273
299
|
src={innerBadge.badgeIcon}
|
|
274
300
|
alt="Inner Badge"
|
|
275
|
-
width={
|
|
276
|
-
height={
|
|
301
|
+
width={24}
|
|
302
|
+
height={24}
|
|
277
303
|
/>
|
|
278
304
|
<span
|
|
279
305
|
className={cx(
|
|
@@ -25,4 +25,11 @@ export interface ProductCardProps {
|
|
|
25
25
|
cta?: ButtonProps;
|
|
26
26
|
onCtaClick: () => void;
|
|
27
27
|
renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
|
|
28
|
+
/** True when any card in the set has a best-value badge, so non-badge cards
|
|
29
|
+
* reserve the banner height and stay vertically aligned. */
|
|
30
|
+
hasAnyBadge?: boolean;
|
|
31
|
+
/** True when the card renders in the two-column mobile layout, which uses the
|
|
32
|
+
* compact mobile typography below `md`. One-column stacks keep the standard
|
|
33
|
+
* sizes at every breakpoint. */
|
|
34
|
+
twoColumn?: boolean;
|
|
28
35
|
}
|
|
@@ -27,7 +27,11 @@ const sampleProduct = (
|
|
|
27
27
|
id: string,
|
|
28
28
|
planName: string,
|
|
29
29
|
price: string,
|
|
30
|
-
highlighted = false
|
|
30
|
+
highlighted = false,
|
|
31
|
+
productCardDescription = "Reliable internet for your home.",
|
|
32
|
+
giftRewards: { list: { items: Array<{ title: string; icon: string }> } } = {
|
|
33
|
+
list: { items: [] },
|
|
34
|
+
}
|
|
31
35
|
) => ({
|
|
32
36
|
__typename: "ComponentProductCard" as const,
|
|
33
37
|
sys: { id },
|
|
@@ -36,14 +40,14 @@ const sampleProduct = (
|
|
|
36
40
|
priceSuffix: "99/mo",
|
|
37
41
|
techType: "Fiber",
|
|
38
42
|
highlighted,
|
|
39
|
-
productCardDescription
|
|
43
|
+
productCardDescription,
|
|
40
44
|
benefitsTitle: "Plan benefits",
|
|
41
45
|
benefitsExpanded: false,
|
|
42
46
|
cta: { buttonLabel: "Select plan" },
|
|
43
47
|
benefits: {
|
|
44
|
-
items: [{
|
|
48
|
+
items: [{ title: "No data caps" }, { title: "Free installation" }],
|
|
45
49
|
},
|
|
46
|
-
giftRewards
|
|
50
|
+
giftRewards,
|
|
47
51
|
});
|
|
48
52
|
|
|
49
53
|
const twoColumnFields: carouselFieldsInterface = {
|
|
@@ -55,10 +59,32 @@ const twoColumnFields: carouselFieldsInterface = {
|
|
|
55
59
|
backgroundColor: "white",
|
|
56
60
|
items: {
|
|
57
61
|
items: [
|
|
58
|
-
sampleProduct(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
sampleProduct(
|
|
63
|
+
"plan-1",
|
|
64
|
+
"300 Mbps",
|
|
65
|
+
"49",
|
|
66
|
+
false,
|
|
67
|
+
"Reliable support for essentials like email, browsing, and video chat."
|
|
68
|
+
),
|
|
69
|
+
sampleProduct(
|
|
70
|
+
"plan-2",
|
|
71
|
+
"600 Mbps",
|
|
72
|
+
"59",
|
|
73
|
+
true,
|
|
74
|
+
"Ultra-fast speeds for large smart homes.",
|
|
75
|
+
{
|
|
76
|
+
list: {
|
|
77
|
+
items: [
|
|
78
|
+
{
|
|
79
|
+
title: "$100 prepaid Mastercard®",
|
|
80
|
+
icon: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Crect width='24' height='24' rx='4' fill='%23ffd200'/%3E%3C/svg%3E",
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
),
|
|
86
|
+
sampleProduct("plan-3", "1 Gig", "69", false, "Good for most day-to-day internet uses including streaming video."),
|
|
87
|
+
sampleProduct("plan-4", "2 Gig", "89", false, "Worry-free internet for the whole home."),
|
|
62
88
|
],
|
|
63
89
|
},
|
|
64
90
|
};
|
|
@@ -152,37 +152,63 @@ describe("ProductCardCarousel", () => {
|
|
|
152
152
|
});
|
|
153
153
|
|
|
154
154
|
it("renders carousel layout for more than 2 items", () => {
|
|
155
|
-
const items = Array.from({ length:
|
|
155
|
+
const items = Array.from({ length: 3 }, (_, i) => ({
|
|
156
156
|
...sampleProduct,
|
|
157
157
|
sys: { id: `prod-${i}` },
|
|
158
158
|
}));
|
|
159
159
|
const fields = createProductFields(items);
|
|
160
160
|
const { container } = render(<ProductCardCarousel fields={fields} />);
|
|
161
|
-
//
|
|
161
|
+
// 3 cards stay a vertical stack on mobile (flex-col for md:hidden)
|
|
162
162
|
expect(container.querySelector(".flex-col")).toBeInTheDocument();
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
-
it("renders a two-column grid on mobile
|
|
165
|
+
it("renders a two-column grid on mobile when more than 3 cards", () => {
|
|
166
166
|
const items = Array.from({ length: 4 }, (_, i) => ({
|
|
167
167
|
...sampleProduct,
|
|
168
168
|
sys: { id: `prod-${i}` },
|
|
169
169
|
}));
|
|
170
170
|
const fields = createProductFields(items);
|
|
171
|
-
fields.twoColumn = true;
|
|
172
171
|
const { container } = render(<ProductCardCarousel fields={fields} />);
|
|
173
172
|
// Mobile view uses a two-column grid instead of a vertical stack
|
|
174
173
|
expect(container.querySelector(".grid.grid-cols-2")).toBeInTheDocument();
|
|
175
174
|
expect(container.querySelector(".flex-col")).not.toBeInTheDocument();
|
|
176
175
|
});
|
|
177
176
|
|
|
178
|
-
it("
|
|
179
|
-
const
|
|
180
|
-
sampleProduct,
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
fields
|
|
177
|
+
it("does not render a two-column grid on mobile for 3 or fewer cards", () => {
|
|
178
|
+
const items = Array.from({ length: 3 }, (_, i) => ({
|
|
179
|
+
...sampleProduct,
|
|
180
|
+
sys: { id: `prod-${i}` },
|
|
181
|
+
}));
|
|
182
|
+
const fields = createProductFields(items);
|
|
183
|
+
const { container } = render(<ProductCardCarousel fields={fields} />);
|
|
184
|
+
expect(
|
|
185
|
+
container.querySelector(".grid.grid-cols-2")
|
|
186
|
+
).not.toBeInTheDocument();
|
|
187
|
+
expect(container.querySelector(".flex-col")).toBeInTheDocument();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("forces a two-column grid on mobile when the CMS twoColumn flag is set, even with 3 or fewer cards", () => {
|
|
191
|
+
const items = Array.from({ length: 3 }, (_, i) => ({
|
|
192
|
+
...sampleProduct,
|
|
193
|
+
sys: { id: `prod-${i}` },
|
|
194
|
+
}));
|
|
195
|
+
const fields = { ...createProductFields(items), twoColumn: true };
|
|
184
196
|
const { container } = render(<ProductCardCarousel fields={fields} />);
|
|
185
197
|
expect(container.querySelector(".grid.grid-cols-2")).toBeInTheDocument();
|
|
198
|
+
expect(container.querySelector(".flex-col")).not.toBeInTheDocument();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("keeps a vertical stack when the CMS twoColumn flag is explicitly false", () => {
|
|
202
|
+
const items = Array.from({ length: 4 }, (_, i) => ({
|
|
203
|
+
...sampleProduct,
|
|
204
|
+
sys: { id: `prod-${i}` },
|
|
205
|
+
}));
|
|
206
|
+
const fields = { ...createProductFields(items), twoColumn: false };
|
|
207
|
+
const { container } = render(<ProductCardCarousel fields={fields} />);
|
|
208
|
+
expect(
|
|
209
|
+
container.querySelector(".grid.grid-cols-2")
|
|
210
|
+
).not.toBeInTheDocument();
|
|
211
|
+
expect(container.querySelector(".flex-col")).toBeInTheDocument();
|
|
186
212
|
});
|
|
187
213
|
|
|
188
214
|
it("parses speed field correctly (planName | planSubtext)", () => {
|
|
@@ -433,6 +459,97 @@ describe("ProductCardCarousel", () => {
|
|
|
433
459
|
const visibleTab = container.querySelector("[style*='display: block']");
|
|
434
460
|
expect(visibleTab).toBeInTheDocument();
|
|
435
461
|
});
|
|
462
|
+
|
|
463
|
+
describe("desktop card height equalization", () => {
|
|
464
|
+
const getDesktopCards = (container: HTMLElement) =>
|
|
465
|
+
Array.from(container.querySelectorAll<HTMLElement>(".flex-shrink-0"));
|
|
466
|
+
|
|
467
|
+
const makeItems = (speeds: string[]) =>
|
|
468
|
+
speeds.map((speed, index) => ({
|
|
469
|
+
...sampleProduct,
|
|
470
|
+
sys: { id: `prod-${index}` },
|
|
471
|
+
speed,
|
|
472
|
+
}));
|
|
473
|
+
|
|
474
|
+
// Drive offsetHeight off each card's plan name so cards can report
|
|
475
|
+
// differing natural heights. jsdom otherwise reports every element as 0.
|
|
476
|
+
const mockCardHeights = (heights: Record<string, number>) => {
|
|
477
|
+
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
|
478
|
+
configurable: true,
|
|
479
|
+
get() {
|
|
480
|
+
if (
|
|
481
|
+
typeof this.className === "string" &&
|
|
482
|
+
this.className.includes("flex-shrink-0")
|
|
483
|
+
) {
|
|
484
|
+
const label = Object.keys(heights).find(key =>
|
|
485
|
+
(this.textContent || "").includes(key)
|
|
486
|
+
);
|
|
487
|
+
return label ? heights[label] : 0;
|
|
488
|
+
}
|
|
489
|
+
return 0;
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
afterEach(() => {
|
|
495
|
+
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
|
496
|
+
configurable: true,
|
|
497
|
+
get: () => 0,
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("sets every desktop card to the tallest card's height", () => {
|
|
502
|
+
mockCardHeights({ "100 Mbps": 100, "200 Mbps": 200, "300 Mbps": 150 });
|
|
503
|
+
const fields = createProductFields(
|
|
504
|
+
makeItems(["100 Mbps", "200 Mbps", "300 Mbps"])
|
|
505
|
+
);
|
|
506
|
+
const { container } = render(<ProductCardCarousel fields={fields} />);
|
|
507
|
+
|
|
508
|
+
const cards = getDesktopCards(container);
|
|
509
|
+
expect(cards).toHaveLength(3);
|
|
510
|
+
cards.forEach(card => expect(card.style.height).toBe("200px"));
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("recalculates heights when the window is resized", () => {
|
|
514
|
+
const heights: Record<string, number> = {
|
|
515
|
+
"100 Mbps": 100,
|
|
516
|
+
"200 Mbps": 200,
|
|
517
|
+
"300 Mbps": 150,
|
|
518
|
+
};
|
|
519
|
+
mockCardHeights(heights);
|
|
520
|
+
const fields = createProductFields(
|
|
521
|
+
makeItems(["100 Mbps", "200 Mbps", "300 Mbps"])
|
|
522
|
+
);
|
|
523
|
+
const { container } = render(<ProductCardCarousel fields={fields} />);
|
|
524
|
+
|
|
525
|
+
getDesktopCards(container).forEach(card =>
|
|
526
|
+
expect(card.style.height).toBe("200px")
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
// A card grows taller (e.g. late-loading content); resize should
|
|
530
|
+
// re-equalize every card to the new maximum.
|
|
531
|
+
heights["300 Mbps"] = 300;
|
|
532
|
+
fireEvent(window, new Event("resize"));
|
|
533
|
+
|
|
534
|
+
getDesktopCards(container).forEach(card =>
|
|
535
|
+
expect(card.style.height).toBe("300px")
|
|
536
|
+
);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("removes the resize listener on unmount", () => {
|
|
540
|
+
mockCardHeights({ "100 Mbps": 100, "200 Mbps": 200, "300 Mbps": 150 });
|
|
541
|
+
const removeSpy = jest.spyOn(window, "removeEventListener");
|
|
542
|
+
const fields = createProductFields(
|
|
543
|
+
makeItems(["100 Mbps", "200 Mbps", "300 Mbps"])
|
|
544
|
+
);
|
|
545
|
+
const { unmount } = render(<ProductCardCarousel fields={fields} />);
|
|
546
|
+
|
|
547
|
+
unmount();
|
|
548
|
+
|
|
549
|
+
expect(removeSpy).toHaveBeenCalledWith("resize", expect.any(Function));
|
|
550
|
+
removeSpy.mockRestore();
|
|
551
|
+
});
|
|
552
|
+
});
|
|
436
553
|
});
|
|
437
554
|
|
|
438
555
|
describe("TestimonialCarousel", () => {
|
|
@@ -25,19 +25,23 @@ function ProductCardPanel({
|
|
|
25
25
|
}) {
|
|
26
26
|
const itemsExpanded = fields?.items?.items?.[0]?.benefitsExpanded || false;
|
|
27
27
|
const [desktopExpanded, setDesktopExpanded] = useState(itemsExpanded);
|
|
28
|
-
const [mobileExpandedStates, setMobileExpandedStates] = useState<
|
|
29
|
-
Record<number, boolean>
|
|
30
|
-
>({});
|
|
31
28
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
32
29
|
const [maxIndex, setMaxIndex] = useState(0);
|
|
33
30
|
const [swipeOffset, setSwipeOffset] = useState(0);
|
|
34
31
|
const [isSwiping, setIsSwiping] = useState(false);
|
|
35
32
|
const items = fields?.items?.items || [];
|
|
36
|
-
|
|
33
|
+
// Prefer the CMS-provided `twoColumn` layout flag.
|
|
34
|
+
// When unset, fall back to the count heuristic: two-column once there are
|
|
35
|
+
// more than 3 cards; 3 or fewer stack vertically.
|
|
36
|
+
const twoColumn = fields?.twoColumn ?? items.length > 3;
|
|
37
37
|
const isCarousel = items.length > 2;
|
|
38
|
+
// When any card is flagged best-value, every card reserves the banner height
|
|
39
|
+
// so plan name/price stay aligned across a two-column row.
|
|
40
|
+
const hasAnyBadge = items.some((item: any) => item?.highlighted);
|
|
38
41
|
const showArrows = fields?.showArrows !== false && isCarousel;
|
|
39
42
|
const cardsRef = useRef<(HTMLDivElement | null)[]>([]);
|
|
40
43
|
const windowRef = useRef<HTMLDivElement | null>(null);
|
|
44
|
+
const mobileGridRef = useRef<HTMLDivElement | null>(null);
|
|
41
45
|
const touchStartX = useRef<number>(0);
|
|
42
46
|
|
|
43
47
|
// Determine how many cards fit so we never scroll past the last card
|
|
@@ -121,7 +125,9 @@ function ProductCardPanel({
|
|
|
121
125
|
setSwipeOffset(0);
|
|
122
126
|
}, [isSwiping, swipeOffset, prevSlide, nextSlide]);
|
|
123
127
|
|
|
124
|
-
// Equalize card heights
|
|
128
|
+
// Equalize card heights. Re-runs whenever the panel becomes visible so a tab
|
|
129
|
+
// that was rendered while hidden (display:none) gets its heights recalculated
|
|
130
|
+
// once it is shown. Residential (single panel, always visible) is unaffected.
|
|
125
131
|
useEffect(() => {
|
|
126
132
|
if (!isCarousel || !isVisible) return;
|
|
127
133
|
|
|
@@ -129,7 +135,7 @@ function ProductCardPanel({
|
|
|
129
135
|
const cards = cardsRef.current.filter(Boolean) as HTMLDivElement[];
|
|
130
136
|
if (cards.length === 0) return;
|
|
131
137
|
|
|
132
|
-
// Reset heights first
|
|
138
|
+
// Reset heights first so each card reports its natural height
|
|
133
139
|
cards.forEach(card => {
|
|
134
140
|
card.style.height = "auto";
|
|
135
141
|
});
|
|
@@ -137,21 +143,112 @@ function ProductCardPanel({
|
|
|
137
143
|
// Find the tallest card
|
|
138
144
|
const maxHeight = Math.max(...cards.map(card => card.offsetHeight));
|
|
139
145
|
|
|
146
|
+
if (maxHeight <= 0) return;
|
|
140
147
|
// Set all cards to the max height
|
|
141
148
|
cards.forEach(card => {
|
|
142
149
|
card.style.height = `${maxHeight}px`;
|
|
143
150
|
});
|
|
144
151
|
};
|
|
145
152
|
|
|
146
|
-
// Run
|
|
153
|
+
// Run immediately for content changes while already visible.
|
|
147
154
|
equalizeHeights();
|
|
148
155
|
|
|
149
|
-
|
|
156
|
+
const rafId = requestAnimationFrame(equalizeHeights);
|
|
157
|
+
|
|
158
|
+
// Re-run after a short delay to account for late-loading content (fonts/images)
|
|
150
159
|
const timeoutId = setTimeout(equalizeHeights, 100);
|
|
151
160
|
|
|
152
|
-
|
|
161
|
+
window.addEventListener("resize", equalizeHeights);
|
|
162
|
+
|
|
163
|
+
return () => {
|
|
164
|
+
cancelAnimationFrame(rafId);
|
|
165
|
+
clearTimeout(timeoutId);
|
|
166
|
+
window.removeEventListener("resize", equalizeHeights);
|
|
167
|
+
};
|
|
153
168
|
}, [isCarousel, isVisible, desktopExpanded, items.length]);
|
|
154
169
|
|
|
170
|
+
// Two-column mobile: match each card's sections to the tallest card in the
|
|
171
|
+
// same row (pair) so section spacing and the CTA align per row. Desktop keeps
|
|
172
|
+
// its fixed section heights and is left untouched.
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (!twoColumn || !isVisible) return;
|
|
175
|
+
|
|
176
|
+
const selectors = [
|
|
177
|
+
"[data-pc-header]",
|
|
178
|
+
"[data-pc-description]",
|
|
179
|
+
"[data-pc-gift]",
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
const clear = () => {
|
|
183
|
+
const grid = mobileGridRef.current;
|
|
184
|
+
if (!grid) return;
|
|
185
|
+
selectors.forEach(sel =>
|
|
186
|
+
grid.querySelectorAll<HTMLElement>(sel).forEach(el => {
|
|
187
|
+
el.style.removeProperty("min-height");
|
|
188
|
+
el.style.removeProperty("display");
|
|
189
|
+
})
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// `important` is required because Tailwind utilities are generated with
|
|
194
|
+
// !important in this project (tailwind.config `important: true`).
|
|
195
|
+
const equalizePairs = (sel: string, collapseEmpty = false) => {
|
|
196
|
+
const grid = mobileGridRef.current;
|
|
197
|
+
if (!grid) return;
|
|
198
|
+
const els = Array.from(grid.querySelectorAll<HTMLElement>(sel));
|
|
199
|
+
// Reset before measuring so a previous pass doesn't inflate the result.
|
|
200
|
+
els.forEach(el => {
|
|
201
|
+
el.style.removeProperty("min-height");
|
|
202
|
+
el.style.removeProperty("display");
|
|
203
|
+
});
|
|
204
|
+
for (let i = 0; i < els.length; i += 2) {
|
|
205
|
+
const pair = els.slice(i, i + 2);
|
|
206
|
+
// Collapse a row (or lone trailing card) whose gift slots are all empty
|
|
207
|
+
// so no space is reserved when the row has no gift.
|
|
208
|
+
if (
|
|
209
|
+
collapseEmpty &&
|
|
210
|
+
pair.every(el => el.hasAttribute("data-pc-gift-empty"))
|
|
211
|
+
) {
|
|
212
|
+
pair.forEach(el =>
|
|
213
|
+
el.style.setProperty("display", "none", "important")
|
|
214
|
+
);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (pair.length < 2) break;
|
|
218
|
+
const max = Math.max(
|
|
219
|
+
...pair.map(el => el.getBoundingClientRect().height)
|
|
220
|
+
);
|
|
221
|
+
pair.forEach(el =>
|
|
222
|
+
el.style.setProperty("min-height", `${max}px`, "important")
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const equalize = () => {
|
|
228
|
+
// Only the two-column mobile breakpoint needs JS equalization.
|
|
229
|
+
if (!window.matchMedia("(max-width: 767px)").matches) {
|
|
230
|
+
clear();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
equalizePairs("[data-pc-header]");
|
|
234
|
+
equalizePairs("[data-pc-description]");
|
|
235
|
+
equalizePairs("[data-pc-gift]", true);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const rafId = requestAnimationFrame(equalize);
|
|
239
|
+
const t1 = setTimeout(equalize, 150);
|
|
240
|
+
const t2 = setTimeout(equalize, 500);
|
|
241
|
+
window.addEventListener("resize", equalize);
|
|
242
|
+
|
|
243
|
+
return () => {
|
|
244
|
+
cancelAnimationFrame(rafId);
|
|
245
|
+
clearTimeout(t1);
|
|
246
|
+
clearTimeout(t2);
|
|
247
|
+
window.removeEventListener("resize", equalize);
|
|
248
|
+
clear();
|
|
249
|
+
};
|
|
250
|
+
}, [twoColumn, isVisible, desktopExpanded, items.length]);
|
|
251
|
+
|
|
155
252
|
if (!items.length && !fields?.title) {
|
|
156
253
|
return null;
|
|
157
254
|
}
|
|
@@ -174,20 +271,12 @@ function ProductCardPanel({
|
|
|
174
271
|
badgeIcon: item.innerBadgeIcon?.url || "",
|
|
175
272
|
};
|
|
176
273
|
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
: desktopExpanded;
|
|
274
|
+
// Shared expand state so toggling "More details" on one card opens the
|
|
275
|
+
// section on the adjacent cards too (matches the residential behavior).
|
|
276
|
+
const isExpanded = desktopExpanded;
|
|
181
277
|
|
|
182
278
|
const handleToggle = () => {
|
|
183
|
-
|
|
184
|
-
setMobileExpandedStates(prev => ({
|
|
185
|
-
...prev,
|
|
186
|
-
[globalIndex]: !prev[globalIndex],
|
|
187
|
-
}));
|
|
188
|
-
} else {
|
|
189
|
-
setDesktopExpanded(!desktopExpanded);
|
|
190
|
-
}
|
|
279
|
+
setDesktopExpanded(prev => !prev);
|
|
191
280
|
};
|
|
192
281
|
|
|
193
282
|
const handleCtaClick = () => {
|
|
@@ -234,6 +323,8 @@ function ProductCardPanel({
|
|
|
234
323
|
renderCheckPlans={renderCheckPlans}
|
|
235
324
|
cta={item.cta}
|
|
236
325
|
priceSuffixBottom={item?.priceSuffixBottom}
|
|
326
|
+
hasAnyBadge={hasAnyBadge}
|
|
327
|
+
twoColumn={twoColumn}
|
|
237
328
|
/>
|
|
238
329
|
</div>
|
|
239
330
|
);
|
|
@@ -241,6 +332,7 @@ function ProductCardPanel({
|
|
|
241
332
|
|
|
242
333
|
return !isCarousel ? (
|
|
243
334
|
<div
|
|
335
|
+
ref={mobileGridRef}
|
|
244
336
|
className={cx(
|
|
245
337
|
"w-full max-w-[1280px] gap-4",
|
|
246
338
|
twoColumn
|
|
@@ -254,6 +346,7 @@ function ProductCardPanel({
|
|
|
254
346
|
<>
|
|
255
347
|
{/* Mobile/Tablet View: Vertical Stack (or two-column grid) */}
|
|
256
348
|
<div
|
|
349
|
+
ref={mobileGridRef}
|
|
257
350
|
className={cx(
|
|
258
351
|
"md:hidden",
|
|
259
352
|
twoColumn ? "grid grid-cols-2 gap-4" : "flex flex-col gap-6"
|