@windstream/react-shared-components 0.2.70 → 0.2.72

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.70",
3
+ "version": "0.2.72",
4
4
  "type": "module",
5
5
  "description": "Shared React components for Kinetic applications",
6
6
  "main": "dist/index.js",
@@ -31,9 +31,9 @@ export const SelectPlanButton: React.FC<SelectPlanButtonProps> = ({
31
31
  },
32
32
  cta: cta,
33
33
  });
34
- const handleSelect = () => {
34
+ const handleSelect = (event: React.MouseEvent<HTMLElement>) => {
35
35
  setOpen(true);
36
- onSelect();
36
+ onSelect(event);
37
37
  };
38
38
 
39
39
  return (
@@ -5,7 +5,7 @@ import { ButtonProps } from "@shared/contentful/blocks/button/types";
5
5
  import { CheckPlansProps } from "@shared/types/micro-components";
6
6
 
7
7
  export type SelectPlanButtonProps = {
8
- onSelect: () => void;
8
+ onSelect: (event: React.MouseEvent<HTMLElement>) => void;
9
9
  speed: string;
10
10
  techType?: string;
11
11
  isMax?: boolean;
@@ -7,6 +7,20 @@ jest.mock("@shared/utils", () => ({
7
7
  cx: (...args: any[]) => args.filter(Boolean).join(" "),
8
8
  }));
9
9
 
10
+ // Mock the heavy real @vimeo/player. These tests only assert on the rendered
11
+ // iframe, and instantiating the real Player leaves async postMessage work and
12
+ // timers running after the test, which can leak into sibling test files in the
13
+ // same Jest worker and make them flaky. A lightweight mock keeps the suite
14
+ // isolated and deterministic.
15
+ jest.mock("@vimeo/player", () => ({
16
+ __esModule: true,
17
+ default: jest.fn().mockImplementation(() => ({
18
+ on: jest.fn(),
19
+ off: jest.fn(),
20
+ destroy: jest.fn(() => Promise.resolve()),
21
+ })),
22
+ }));
23
+
10
24
  describe("VimeoEmbed", () => {
11
25
  describe("Rendering", () => {
12
26
  it("renders iframe with correct src for standard vimeo link", () => {
@@ -86,16 +86,16 @@ describe("ProductCard", () => {
86
86
 
87
87
  it("renders plan name", () => {
88
88
  render(<ProductCard {...defaultProps} />);
89
- expect(
90
- screen.getByRole("heading", { level: 3 })
91
- ).toHaveTextContent("Kinetic 500");
89
+ expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent(
90
+ "Kinetic 500"
91
+ );
92
92
  });
93
93
 
94
94
  it("renders plan subtext", () => {
95
95
  render(<ProductCard {...defaultProps} />);
96
- expect(
97
- screen.getByRole("heading", { level: 3 })
98
- ).toHaveTextContent("Up to 500 Mbps");
96
+ expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent(
97
+ "Up to 500 Mbps"
98
+ );
99
99
  });
100
100
 
101
101
  it("renders price dollar sign", () => {
@@ -126,6 +126,45 @@ describe("ProductCard", () => {
126
126
  });
127
127
  });
128
128
 
129
+ describe("Business theme", () => {
130
+ it("uses the semantic brand token for the plan name and lowercases it by default", () => {
131
+ render(<ProductCard {...defaultProps} />);
132
+ const heading = screen.getByRole("heading", { level: 3 });
133
+ // Color is driven by the themeable token, not a hardcoded hex.
134
+ expect(heading.className).toContain("text-text-brand");
135
+ expect(heading.className).toContain("lowercase");
136
+ expect(heading.className).not.toContain("[#0393BA]");
137
+ });
138
+
139
+ it("keeps the authored casing in business mode (color stays theme-driven)", () => {
140
+ render(<ProductCard {...defaultProps} enableBusinessSettings={true} />);
141
+ const heading = screen.getByRole("heading", { level: 3 });
142
+ // Still the semantic brand token — the business teal comes from the
143
+ // data-theme scope, not a card-level class.
144
+ expect(heading.className).toContain("text-text-brand");
145
+ expect(heading.className).not.toContain("lowercase");
146
+ expect(heading.className).not.toContain("[#0393BA]");
147
+ });
148
+
149
+ it("never hardcodes a hex accent on the plan name", () => {
150
+ render(<ProductCard {...defaultProps} enableBusinessSettings={true} />);
151
+ const heading = screen.getByRole("heading", { level: 3 });
152
+ expect(heading.className).not.toMatch(/#[0-9a-fA-F]{6}/);
153
+ });
154
+
155
+ it("keeps the dark (highlighted) accent regardless of business mode", () => {
156
+ render(
157
+ <ProductCard
158
+ {...defaultProps}
159
+ theme="dark"
160
+ enableBusinessSettings={true}
161
+ />
162
+ );
163
+ const heading = screen.getByRole("heading", { level: 3 });
164
+ expect(heading.className).toContain("text-text-accent-on-bg-inverse");
165
+ });
166
+ });
167
+
129
168
  describe("Price Suffix", () => {
130
169
  it("renders priceSuffixBottom when provided", () => {
131
170
  render(
@@ -250,9 +289,9 @@ describe("ProductCard", () => {
250
289
  const dollar = screen.getByText("$100");
251
290
  expect(dollar).toHaveClass("label3");
252
291
  // The surrounding text keeps the parent body3 weight (not label3).
253
- expect(
254
- screen.getByText(/prepaid Mastercard®/)
255
- ).not.toHaveClass("label3");
292
+ expect(screen.getByText(/prepaid Mastercard®/)).not.toHaveClass(
293
+ "label3"
294
+ );
256
295
  });
257
296
  });
258
297
 
@@ -54,6 +54,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
54
54
  priceSuffixBottom,
55
55
  hasAnyBadge = false,
56
56
  twoColumn = false,
57
+ enableBusinessSettings = false,
57
58
  }) => {
58
59
  const [internalExpanded, setInternalExpanded] = useState(false);
59
60
  // Use parent state if provided, otherwise use internal state
@@ -116,7 +117,8 @@ export const ProductCard: React.FC<ProductCardProps> = ({
116
117
  as="h3"
117
118
  className={cx(
118
119
  planNameClass,
119
- "whitespace-normal lowercase",
120
+ "whitespace-normal",
121
+ !enableBusinessSettings && "lowercase",
120
122
  isDark ? "text-text-accent-on-bg-inverse" : "text-text-brand"
121
123
  )}
122
124
  >
@@ -26,7 +26,7 @@ export interface ProductCardProps {
26
26
  onToggleExpand?: () => void;
27
27
  ctaText?: string;
28
28
  cta?: ButtonProps;
29
- onCtaClick: () => void;
29
+ onCtaClick: (event: React.MouseEvent<HTMLElement>) => void;
30
30
  renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
31
31
  /** True when any card in the set has a best-value badge, so non-badge cards
32
32
  * reserve the banner height and stay vertically aligned. */
@@ -35,4 +35,5 @@ export interface ProductCardProps {
35
35
  * compact mobile typography below `md`. One-column stacks keep the standard
36
36
  * sizes at every breakpoint. */
37
37
  twoColumn?: boolean;
38
+ enableBusinessSettings?: boolean;
38
39
  }
@@ -31,12 +31,14 @@ jest.mock("@shared/contentful/blocks/cards/product-card", () => ({
31
31
  onCtaClick,
32
32
  onToggleExpand,
33
33
  isExpanded,
34
+ enableBusinessSettings,
34
35
  }: any) => (
35
36
  <div
36
37
  data-testid="product-card"
37
38
  data-plan={planName}
38
39
  data-speed={speed}
39
40
  data-expanded={isExpanded}
41
+ data-business={enableBusinessSettings ? "true" : "false"}
40
42
  >
41
43
  {planName}
42
44
  <button onClick={onCtaClick} data-testid="product-cta">
@@ -329,15 +331,54 @@ describe("ProductCardCarousel", () => {
329
331
  expect(toggleButtons[0]).toBeInTheDocument();
330
332
  });
331
333
 
332
- it("handles CTA click on product card", () => {
334
+ it("fires onProductCardCtaClick with the product item and click event", () => {
335
+ const onProductCardCtaClick = jest.fn();
336
+ const fields = createProductFields([sampleProduct]);
337
+ render(
338
+ <ProductCardCarousel
339
+ fields={fields}
340
+ onProductCardCtaClick={onProductCardCtaClick}
341
+ />
342
+ );
343
+ const ctaButton = screen.getByTestId("product-cta");
344
+ fireEvent.click(ctaButton);
345
+ expect(onProductCardCtaClick).toHaveBeenCalledTimes(1);
346
+ expect(onProductCardCtaClick.mock.calls[0][0]).toEqual(
347
+ expect.objectContaining({ sys: { id: "prod-1" } })
348
+ );
349
+ // Second argument is the originating click event.
350
+ expect(onProductCardCtaClick.mock.calls[0][1]).toBeDefined();
351
+ });
352
+
353
+ it("handles CTA click when no onProductCardCtaClick is provided", () => {
333
354
  const fields = createProductFields([sampleProduct]);
334
355
  render(<ProductCardCarousel fields={fields} />);
335
356
  const ctaButton = screen.getByTestId("product-cta");
336
357
  fireEvent.click(ctaButton);
337
- // handleCtaClick just returns; no error
358
+ // No handler wired; click is a no-op and must not throw.
338
359
  expect(ctaButton).toBeInTheDocument();
339
360
  });
340
361
 
362
+ it("defaults product cards to the non-business theme", () => {
363
+ const fields = createProductFields([sampleProduct]);
364
+ render(<ProductCardCarousel fields={fields} />);
365
+ expect(screen.getAllByTestId("product-card")[0]).toHaveAttribute(
366
+ "data-business",
367
+ "false"
368
+ );
369
+ });
370
+
371
+ it("forwards enableBusinessSettings to the product cards", () => {
372
+ const fields = createProductFields([sampleProduct]);
373
+ render(
374
+ <ProductCardCarousel fields={fields} enableBusinessSettings={true} />
375
+ );
376
+ expect(screen.getAllByTestId("product-card")[0]).toHaveAttribute(
377
+ "data-business",
378
+ "true"
379
+ );
380
+ });
381
+
341
382
  it("handles toggle expand for mobile cards in carousel mode", () => {
342
383
  const items = Array.from({ length: 4 }, (_, i) => ({
343
384
  ...sampleProduct,
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
2
2
  import {
3
3
  CarouselWithProductCards,
4
4
  CarouselWithTestimonialCards,
5
+ ProductCardFields,
5
6
  TabSwitchProps,
6
7
  } from "./types";
7
8
 
@@ -17,11 +18,18 @@ function ProductCardPanel({
17
18
  fields,
18
19
  renderCheckPlans,
19
20
  isVisible = true,
21
+ onProductCardCtaClick,
22
+ enableBusinessSettings = false,
20
23
  }: {
21
24
  fields: CarouselWithProductCards;
22
25
  onModalButtonClick?: (id?: string) => void;
23
26
  renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
24
27
  isVisible?: boolean;
28
+ onProductCardCtaClick?: (
29
+ item: ProductCardFields,
30
+ event: React.MouseEvent<HTMLElement>
31
+ ) => void;
32
+ enableBusinessSettings?: boolean;
25
33
  }) {
26
34
  const itemsExpanded = fields?.items?.items?.[0]?.benefitsExpanded || false;
27
35
  const [desktopExpanded, setDesktopExpanded] = useState(itemsExpanded);
@@ -284,8 +292,8 @@ function ProductCardPanel({
284
292
  setDesktopExpanded(prev => !prev);
285
293
  };
286
294
 
287
- const handleCtaClick = () => {
288
- return;
295
+ const handleCtaClick = (event: React.MouseEvent<HTMLElement>) => {
296
+ onProductCardCtaClick?.(item as ProductCardFields, event);
289
297
  };
290
298
 
291
299
  return (
@@ -331,6 +339,7 @@ function ProductCardPanel({
331
339
  priceSuffixBottom={item?.priceSuffixBottom}
332
340
  hasAnyBadge={hasAnyBadge}
333
341
  twoColumn={twoColumn}
342
+ enableBusinessSettings={enableBusinessSettings}
334
343
  />
335
344
  </div>
336
345
  );
@@ -425,12 +434,19 @@ export function ProductCardCarousel({
425
434
  renderCheckPlans,
426
435
  activeTab,
427
436
  tabs,
437
+ onProductCardCtaClick,
438
+ enableBusinessSettings = false,
428
439
  }: {
429
440
  fields: CarouselWithProductCards;
430
441
  onModalButtonClick?: (id?: string) => void;
431
442
  renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
432
443
  activeTab?: string;
433
444
  tabs?: string[];
445
+ onProductCardCtaClick?: (
446
+ item: ProductCardFields,
447
+ event: React.MouseEvent<HTMLElement>
448
+ ) => void;
449
+ enableBusinessSettings?: boolean;
434
450
  }) {
435
451
  const allItems = fields?.items?.items || [];
436
452
 
@@ -456,6 +472,8 @@ export function ProductCardCarousel({
456
472
  fields={tabFields as CarouselWithProductCards}
457
473
  renderCheckPlans={renderCheckPlans}
458
474
  isVisible={tab === activeTab}
475
+ onProductCardCtaClick={onProductCardCtaClick}
476
+ enableBusinessSettings={enableBusinessSettings}
459
477
  />
460
478
  </div>
461
479
  );
@@ -465,7 +483,12 @@ export function ProductCardCarousel({
465
483
  }
466
484
 
467
485
  return (
468
- <ProductCardPanel fields={fields} renderCheckPlans={renderCheckPlans} />
486
+ <ProductCardPanel
487
+ fields={fields}
488
+ renderCheckPlans={renderCheckPlans}
489
+ onProductCardCtaClick={onProductCardCtaClick}
490
+ enableBusinessSettings={enableBusinessSettings}
491
+ />
469
492
  );
470
493
  }
471
494
 
@@ -8,8 +8,16 @@ import { fireEvent, render, screen } from "@testing-library/react";
8
8
 
9
9
  // Mock helper components
10
10
  jest.mock("./helper", () => ({
11
- ProductCardCarousel: ({ fields }: any) => (
12
- <div data-testid="product-card-carousel">{fields?.title}</div>
11
+ ProductCardCarousel: ({ fields, onProductCardCtaClick }: any) => (
12
+ <div data-testid="product-card-carousel">
13
+ {fields?.title}
14
+ <button
15
+ data-testid="mock-cta"
16
+ onClick={e => onProductCardCtaClick?.({ sys: { id: "prod-1" } }, e)}
17
+ >
18
+ cta
19
+ </button>
20
+ </div>
13
21
  ),
14
22
  TestimonialCarousel: ({ fields }: any) => (
15
23
  <div data-testid="testimonial-carousel">{fields?.title}</div>
@@ -200,6 +208,68 @@ describe("Carousel", () => {
200
208
  });
201
209
  });
202
210
 
211
+ describe("analytics", () => {
212
+ it("forwards onProductCardCtaClick with the item and click event", () => {
213
+ const onProductCardCtaClick = jest.fn();
214
+ render(
215
+ <Carousel
216
+ {...defaultProps}
217
+ hasProductCards={true}
218
+ onProductCardCtaClick={onProductCardCtaClick}
219
+ />
220
+ );
221
+ fireEvent.click(screen.getByTestId("mock-cta"));
222
+ expect(onProductCardCtaClick).toHaveBeenCalledTimes(1);
223
+ expect(onProductCardCtaClick.mock.calls[0][0]).toEqual({
224
+ sys: { id: "prod-1" },
225
+ });
226
+ // Second argument is the originating click event (needed by consumers to
227
+ // dispatch their own click analytics from the clicked element).
228
+ expect(onProductCardCtaClick.mock.calls[0][1]).toBeDefined();
229
+ });
230
+
231
+ it("attaches productCardsRef to the product-card container", () => {
232
+ const ref = React.createRef<HTMLDivElement>();
233
+ render(
234
+ <Carousel
235
+ {...defaultProps}
236
+ hasProductCards={true}
237
+ productCardsRef={ref}
238
+ />
239
+ );
240
+ expect(ref.current).toBeInstanceOf(HTMLDivElement);
241
+ expect(ref.current).toContainElement(
242
+ screen.getByTestId("product-card-carousel")
243
+ );
244
+ });
245
+ });
246
+
247
+ describe("business theme", () => {
248
+ it("does not scope the business theme by default", () => {
249
+ const { container } = render(
250
+ <Carousel {...defaultProps} hasProductCards={true} />
251
+ );
252
+ expect(
253
+ container.querySelector('[data-theme="business-rebrand"]')
254
+ ).not.toBeInTheDocument();
255
+ });
256
+
257
+ it("scopes the product cards to the business-rebrand theme when enabled", () => {
258
+ const { container } = render(
259
+ <Carousel
260
+ {...defaultProps}
261
+ hasProductCards={true}
262
+ enableBusinessSettings={true}
263
+ />
264
+ );
265
+ const scope = container.querySelector('[data-theme="business-rebrand"]');
266
+ expect(scope).toBeInTheDocument();
267
+ expect(scope).toContainElement(
268
+ screen.getByTestId("product-card-carousel")
269
+ );
270
+ });
271
+ });
272
+
203
273
  describe("testimonial cards", () => {
204
274
  it("renders TestimonialCarousel when hasTestimonialCards is true", () => {
205
275
  render(<Carousel {...defaultProps} hasTestimonialCards={true} />);
@@ -22,6 +22,9 @@ export const Carousel: React.FC<CarouselProps> = ({
22
22
  renderCheckPlans,
23
23
  testimonialAutoScroll = true,
24
24
  showSwitch = false,
25
+ productCardsRef,
26
+ onProductCardCtaClick,
27
+ enableBusinessSettings = false,
25
28
  }) => {
26
29
  const baseClass = hasProductCards
27
30
  ? "px-3 py-9 md:py-20"
@@ -45,7 +48,11 @@ export const Carousel: React.FC<CarouselProps> = ({
45
48
  )}
46
49
  </div>
47
50
  {hasProductCards && (
48
- <div className="flex flex-col gap-8">
51
+ <div
52
+ ref={productCardsRef}
53
+ data-theme={enableBusinessSettings ? "business-rebrand" : undefined}
54
+ className="flex flex-col gap-8"
55
+ >
49
56
  {showSwitch && hasProductCards && tabs.length > 1 && (
50
57
  <TabSwitch
51
58
  tabs={tabs}
@@ -60,6 +67,8 @@ export const Carousel: React.FC<CarouselProps> = ({
60
67
  fields={fields as CarouselWithProductCards}
61
68
  activeTab={activeTab}
62
69
  tabs={tabs}
70
+ onProductCardCtaClick={onProductCardCtaClick}
71
+ enableBusinessSettings={enableBusinessSettings}
63
72
  />
64
73
  )}
65
74
  </div>
@@ -136,6 +136,18 @@ export type CarouselProps = {
136
136
  testimonialAutoScroll?: boolean;
137
137
  onModalButtonClick?: (id?: string) => void;
138
138
  renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
139
+ /** Ref attached to the product-card ("speeds list") container so consumers
140
+ * can observe its scroll visibility (e.g. to fire their own view-tracking
141
+ * analytics). Only product-card carousels expose a measurable element. */
142
+ productCardsRef?: React.Ref<HTMLDivElement>;
143
+ /** Fired when a product card's CTA ("Select plan") is clicked, with the
144
+ * Contentful product-card entry and the originating click event. Consumers
145
+ * map this onto their click analytics (e.g. Segment "Site Clicked"). */
146
+ onProductCardCtaClick?: (
147
+ item: ProductCardFields,
148
+ event: React.MouseEvent<HTMLElement>
149
+ ) => void;
150
+ enableBusinessSettings?: boolean;
139
151
  };
140
152
 
141
153
  export interface TabSwitchProps {
@@ -34,6 +34,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
34
34
  logoLockup,
35
35
  heroVideo,
36
36
  heroVideoMobile,
37
+ fieldProps,
37
38
  } = props;
38
39
 
39
40
  const isButtonType = (cta: any) => {
@@ -74,6 +75,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
74
75
  height={208}
75
76
  quality={90}
76
77
  loading="eager"
78
+ {...fieldProps?.badgeImage}
77
79
  />
78
80
  ) : null;
79
81
  return (
@@ -88,6 +90,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
88
90
  aria-label={heroVideo?.title || title || "Hero background video"}
89
91
  className="absolute inset-0 z-0 hidden h-full w-full object-fill xl:block"
90
92
  data-testid="hero-background-video-desktop"
93
+ {...fieldProps?.heroVideo}
91
94
  >
92
95
  <source src={desktopVideoUrl} type="video/mp4" />
93
96
  </video>
@@ -108,12 +111,13 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
108
111
  <Text
109
112
  as={showAsHeading ? "h1" : "h2"}
110
113
  className="heading2 lg:heading1"
114
+ {...fieldProps?.title}
111
115
  >
112
116
  {title}
113
117
  </Text>
114
118
  )}
115
119
  {subTitle && (
116
- <Text as="p" className="subheading3">
120
+ <Text as="p" className="subheading3" {...fieldProps?.subTitle}>
117
121
  {subTitle}
118
122
  </Text>
119
123
  )}
@@ -121,7 +125,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
121
125
  {/* price callout */}
122
126
  {price ? (
123
127
  <div className="flex sm:justify-center sm:text-left lg:justify-start">
124
- <div className="mr-2 mt-0.5">
128
+ <div className="mr-2 mt-0.5" {...fieldProps?.priceCallout}>
125
129
  {priceCallout
126
130
  ? priceCallout.split("|").map((line, index) => (
127
131
  <Text key={index} as="p" className="body2">
@@ -134,11 +138,15 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
134
138
  <Text as="p" className="subheading1 mt-0.5">
135
139
  $
136
140
  </Text>
137
- <Text as="p" className="subheading5">
141
+ <Text as="p" className="subheading5" {...fieldProps?.price}>
138
142
  {price}
139
143
  </Text>
140
144
  {priceSuffix ? (
141
- <Text as="p" className="subheading1 mt-0.5">
145
+ <Text
146
+ as="p"
147
+ className="subheading1 mt-0.5"
148
+ {...fieldProps?.priceSuffix}
149
+ >
142
150
  {priceSuffix}
143
151
  </Text>
144
152
  ) : null}
@@ -153,6 +161,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
153
161
  "subheading1",
154
162
  "text-left sm:text-center xl:text-left"
155
163
  )}
164
+ {...fieldProps?.pricingDescription}
156
165
  >
157
166
  {pricingDescription}
158
167
  </Text>
@@ -163,26 +172,38 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
163
172
  data-cy="hero-pricing-disclaimer"
164
173
  as="p"
165
174
  className={cx("body3", "text-left sm:text-center xl:text-left")}
175
+ {...fieldProps?.pricingDescriptionDisclaimer}
166
176
  >
167
177
  {pricingDescriptionDisclaimer}
168
178
  </Text>
169
179
  ) : null}
170
180
 
171
181
  {/* checklist */}
172
- {checklist && (
173
- <Checklist
174
- iconSize={24}
175
- listItemClassName={`body2 text-left ${textColorClass}`}
176
- items={checklist.map(item =>
177
- item.iconUrl
178
- ? { title: item.title, iconUrl: item.iconUrl }
179
- : item.title
180
- )}
181
- />
182
- )}
182
+ {checklist &&
183
+ (() => {
184
+ const checklistEl = (
185
+ <Checklist
186
+ iconSize={24}
187
+ listItemClassName={`body2 text-left ${textColorClass}`}
188
+ items={checklist.map(item =>
189
+ item.iconUrl
190
+ ? { title: item.title, iconUrl: item.iconUrl }
191
+ : item.title
192
+ )}
193
+ />
194
+ );
195
+ return fieldProps?.checklist ? (
196
+ <div {...fieldProps.checklist}>{checklistEl}</div>
197
+ ) : (
198
+ checklistEl
199
+ );
200
+ })()}
183
201
 
184
202
  {logoLockup ? (
185
- <div className="text-left sm:text-center xl:mb-0 xl:text-left">
203
+ <div
204
+ className="text-left sm:text-center xl:mb-0 xl:text-left"
205
+ {...fieldProps?.logoLockup}
206
+ >
186
207
  <NextImage
187
208
  src={logoLockup}
188
209
  alt={"Logo Lockup"}
@@ -256,7 +277,11 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
256
277
  {secondaryCtaPrefix &&
257
278
  secondaryCta?.preDefinedFunctionExecution !==
258
279
  "check availability" && (
259
- <Text as="span" className="body2 mr-1">
280
+ <Text
281
+ as="span"
282
+ className="body2 mr-1"
283
+ {...fieldProps?.secondaryCtaPrefix}
284
+ >
260
285
  {secondaryCtaPrefix}
261
286
  </Text>
262
287
  )}
@@ -300,6 +325,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
300
325
  aria-label={heroVideoMobile?.title || title || "Hero video"}
301
326
  className="h-[350px] w-full rounded-[40px] object-cover object-center sm:h-[420px] md:h-[520px]"
302
327
  data-testid="hero-video-mobile"
328
+ {...fieldProps?.heroVideo}
303
329
  >
304
330
  <source src={mobileVideoUrl} type="video/mp4" />
305
331
  </video>
@@ -320,6 +346,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
320
346
  height={104}
321
347
  quality={90}
322
348
  loading="eager"
349
+ {...fieldProps?.badgeImage}
323
350
  />
324
351
  ) : null}
325
352
  {heroImageUrl ? (
@@ -336,6 +363,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
336
363
  sizes="100vw"
337
364
  quality={90}
338
365
  loading="eager"
366
+ {...fieldProps?.heroImage}
339
367
  />
340
368
  ) : null}
341
369
  </>
@@ -348,7 +376,11 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
348
376
  >
349
377
  {secondaryCtaPrefix &&
350
378
  !secondaryCta?.preDefinedFunctionExecution && (
351
- <Text as="span" className="body2 mr-1">
379
+ <Text
380
+ as="span"
381
+ className="body2 mr-1"
382
+ {...fieldProps?.secondaryCtaPrefix}
383
+ >
352
384
  {secondaryCtaPrefix}
353
385
  </Text>
354
386
  )}
@@ -396,6 +428,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
396
428
  height={584}
397
429
  quality={90}
398
430
  loading="eager"
431
+ {...fieldProps?.heroImage}
399
432
  />
400
433
  ) : null}
401
434
  </div>
@@ -1,6 +1,7 @@
1
1
  import React from "react";
2
2
 
3
3
  import { CheckPlansProps } from "@shared/types/micro-components";
4
+ import type { FieldProps } from "@shared/types/field-props";
4
5
 
5
6
  export type PrimaryHeroVideo = {
6
7
  url?: string;
@@ -42,4 +43,20 @@ export type PrimaryHeroProps = {
42
43
  renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
43
44
  onModalButtonClick?: (id?: string) => void;
44
45
  onImageClick?: () => void;
46
+ /** Extra DOM attribute props, spread onto the respective rendered elements. */
47
+ fieldProps?: FieldProps<
48
+ | "title"
49
+ | "subTitle"
50
+ | "price"
51
+ | "priceSuffix"
52
+ | "priceCallout"
53
+ | "pricingDescription"
54
+ | "pricingDescriptionDisclaimer"
55
+ | "secondaryCtaPrefix"
56
+ | "checklist"
57
+ | "logoLockup"
58
+ | "badgeImage"
59
+ | "heroImage"
60
+ | "heroVideo"
61
+ >;
45
62
  };