@windstream/react-shared-components 0.2.62 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windstream/react-shared-components",
3
- "version": "0.2.62",
3
+ "version": "0.2.64",
4
4
  "type": "module",
5
5
  "description": "Shared React components for Kinetic applications",
6
6
  "main": "dist/index.js",
@@ -15,6 +15,7 @@ export const SelectPlanButton: React.FC<SelectPlanButtonProps> = ({
15
15
  cta,
16
16
  techType,
17
17
  isMax,
18
+ twoColumn = false,
18
19
  }) => {
19
20
  const [open, setOpen] = useState(false);
20
21
  const className = `btn-medium rounded-button pl-15 pr-15 inline-flex gap-2 items-center justify-center outline-none focus:ring-2 focus:ring-offset-2 cursor-pointer transition-colors duration-200 align-top ${isSelected ? " bg-bg-surface text-text focus:ring-bg-fill-surface-inverse" : " bg-bg-fill-brand text-text-brand-on-bg-fill enabled:hover:bg-bg-fill-brand-hover focus:ring-bg-fill-brand"} `;
@@ -39,15 +40,18 @@ export const SelectPlanButton: React.FC<SelectPlanButtonProps> = ({
39
40
  <>
40
41
  <Button
41
42
  onClick={handleSelect}
42
- className={cx(className, `w-full justify-between pl-5 pr-5`)}
43
+ className={cx(className, "w-full justify-between px-3 md:px-5")}
43
44
  data-track-element-name="speed_plan_select_button"
44
45
  data-track-click-text={`Select plan speed ${speed}`}
45
46
  data-track-element-clicked="speed_plan_card"
46
47
  >
47
- <span>Select plan</span>
48
+ <span className={twoColumn ? "label3 md:label1" : "label1"}>
49
+ Select plan
50
+ </span>
48
51
  <MaterialIcon
49
52
  className={cx(
50
53
  iconClassName,
54
+ "!text-[24px] md:!text-[40px]",
51
55
  isSelected ? "text-icon" : "text-icon-inverse"
52
56
  )}
53
57
  name="expand_circle_right"
@@ -11,6 +11,7 @@ export type SelectPlanButtonProps = {
11
11
  isMax?: boolean;
12
12
  iconSize?: Size;
13
13
  isSelected?: boolean;
14
+ twoColumn?: boolean;
14
15
  iconClassName?: string;
15
16
  renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
16
17
  cta?: ButtonProps;
@@ -5,18 +5,27 @@ import Player from "@vimeo/player";
5
5
  import { cx } from "@shared/utils/cx";
6
6
 
7
7
  const extractVimeoIdandHash = (href: string) => {
8
- // Strip query parameters and fragments
9
- const cleanHref = href.split(/[?#]/)[0].replace(/\/+$/, "");
10
- const match =
11
- cleanHref.match(/vimeo\.com\/(?:.*\/)?(\d{6,})/) ||
12
- cleanHref.match(/player\.vimeo\.com\/video\/(\d{6,})/);
8
+ // Strip query parameters and fragments without a backtracking-prone regex
9
+ let cleanHref = href.split(/[?#]/)[0];
10
+ while (cleanHref.endsWith("/")) cleanHref = cleanHref.slice(0, -1);
13
11
 
14
- const parts = cleanHref.split("/");
15
- const hash = parts[parts.length - 1];
12
+ const parts = cleanHref.split("/").filter(Boolean);
13
+
14
+ // Locate the vimeo domain segment (vimeo.com or player.vimeo.com) manually
15
+ // instead of a backtracking regex like `(?:.*\/)?(\d{6,})`
16
+ const domainIndex = parts.findIndex(
17
+ part => part === "vimeo.com" || part === "player.vimeo.com"
18
+ );
19
+ if (domainIndex === -1) return "";
16
20
 
17
- if (!match?.[1]) return "";
18
- if (match[1] === hash) return match[1];
19
- return `${match[1]}/${hash}`;
21
+ const id = parts.find(
22
+ (part, i) => i > domainIndex && /^\d{6,}$/.test(part)
23
+ );
24
+ if (!id) return "";
25
+
26
+ const hash = parts[parts.length - 1];
27
+ if (id === hash) return id;
28
+ return `${id}/${hash}`;
20
29
  };
21
30
 
22
31
  export const getVimeoEmbedId = (link?: string): string => {
@@ -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", () => {