@windstream/react-shared-components 0.2.63 → 0.2.65

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.
@@ -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
- className={cx(
68
- "relative flex h-full w-full max-w-[392px] flex-col",
69
- !bestValue && "md:pt-[38px]"
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 className="overflow-none flex items-start justify-between gap-4">
91
- <div className="flex flex-col">
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
- "subheading2 lowercase",
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
- "label2 mr-1 mt-[2px]",
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
- "subheading6",
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
- "label2",
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 className="invisible min-h-[76px] p-2" aria-hidden="true">
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-center">
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={12}
276
- height={12}
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: "Reliable internet for your home.",
43
+ productCardDescription,
40
44
  benefitsTitle: "Plan benefits",
41
45
  benefitsExpanded: false,
42
46
  cta: { buttonLabel: "Select plan" },
43
47
  benefits: {
44
- items: [{ text: "No data caps" }, { text: "Free installation" }],
48
+ items: [{ title: "No data caps" }, { title: "Free installation" }],
45
49
  },
46
- giftRewards: { list: { items: [] } },
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("plan-1", "300 Mbps", "49"),
59
- sampleProduct("plan-2", "600 Mbps", "59", true),
60
- sampleProduct("plan-3", "1 Gig", "69"),
61
- sampleProduct("plan-4", "2 Gig", "89"),
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: 4 }, (_, i) => ({
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
- // Should have mobile stack view (flex-col for md:hidden)
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 in carousel mode when twoColumn is set", () => {
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("renders a two-column grid on mobile in non-carousel mode when twoColumn is set", () => {
179
- const fields = createProductFields([
180
- sampleProduct,
181
- { ...sampleProduct, sys: { id: "prod-2" } },
182
- ]);
183
- fields.twoColumn = true;
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
- const twoColumn = fields?.twoColumn || false;
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 on mount and when expanded state changes
153
+ // Run immediately for content changes while already visible.
147
154
  equalizeHeights();
148
155
 
149
- // Re-run after a short delay due to dynamic content
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
- return () => clearTimeout(timeoutId);
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
- // Mobile: individual card state, Desktop: shared state
178
- const isExpanded = isMobileView
179
- ? mobileExpandedStates[globalIndex] || false
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
- if (isMobileView) {
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"
@@ -205,6 +205,23 @@ describe("Footer", () => {
205
205
  });
206
206
 
207
207
  describe("Copyright and terms", () => {
208
+ it("wraps copyrights in a <div> (not a <p>) so block-level rich text is valid", () => {
209
+ render(<Footer {...defaultProps} />);
210
+ const copyEl = screen.getByText("All rights reserved.");
211
+ expect(copyEl.tagName).toBe("DIV");
212
+ });
213
+
214
+ it("renders block-level rich text in copyrights without invalid <div>-in-<p> nesting", () => {
215
+ render(
216
+ <Footer
217
+ {...defaultProps}
218
+ copyrights={<div data-testid="rich-copyright">© 2026 Windstream</div>}
219
+ />
220
+ );
221
+ // the rich-text <div> must NOT be nested inside a <p>
222
+ expect(screen.getByTestId("rich-copyright").closest("p")).toBeNull();
223
+ });
224
+
208
225
  it("renders copyright year with current year", () => {
209
226
  render(<Footer {...defaultProps} />);
210
227
  const currentYear = new Date().getFullYear().toString();
@@ -64,7 +64,7 @@ export const Footer: React.FC<FooterProps> = ({
64
64
  <p className="terms-text footnote text-white">
65
65
  &copy; {new Date().getFullYear()} {terms}
66
66
  </p>
67
- <p className="copy-rights whitespace-pre-line">{copyrights}</p>
67
+ <div className="copy-rights whitespace-pre-line">{copyrights}</div>
68
68
  <nav aria-label="Footer legal">
69
69
  <ul className="bottom-links list-none">
70
70
  {bottomLinks?.map((link: any, index: number) => (