@windstream/react-shared-components 0.2.91 → 0.2.93

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.
Files changed (72) hide show
  1. package/dist/contentful/index.d.ts +54 -10
  2. package/dist/contentful/index.esm.js +4 -4
  3. package/dist/contentful/index.esm.js.map +1 -1
  4. package/dist/contentful/index.js +3 -3
  5. package/dist/contentful/index.js.map +1 -1
  6. package/dist/core.d.ts +6 -3
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.esm.js +2 -2
  9. package/dist/index.esm.js.map +1 -1
  10. package/dist/index.js +2 -2
  11. package/dist/index.js.map +1 -1
  12. package/dist/utils/index.d.ts +2 -1
  13. package/dist/utils/index.esm.js +1 -1
  14. package/dist/utils/index.esm.js.map +1 -1
  15. package/dist/utils/index.js +1 -1
  16. package/dist/utils/index.js.map +1 -1
  17. package/package.json +1 -1
  18. package/src/components/checklist/index.test.tsx +18 -1
  19. package/src/components/checklist/index.tsx +25 -21
  20. package/src/components/checklist/types.ts +8 -2
  21. package/src/contentful/blocks/address-input-banner/index.test.tsx +14 -0
  22. package/src/contentful/blocks/address-input-banner/index.tsx +3 -1
  23. package/src/contentful/blocks/address-input-banner/types.ts +1 -1
  24. package/src/contentful/blocks/blogs-grid/index.test.tsx +21 -1
  25. package/src/contentful/blocks/blogs-grid/index.tsx +1 -0
  26. package/src/contentful/blocks/blogs-grid/types.ts +3 -0
  27. package/src/contentful/blocks/blogs-grid-base/index.test.tsx +49 -3
  28. package/src/contentful/blocks/blogs-grid-base/index.tsx +11 -2
  29. package/src/contentful/blocks/blogs-grid-base/types.ts +7 -1
  30. package/src/contentful/blocks/callout/{callout.stories.tsx → Callout.stories.tsx} +1 -1
  31. package/src/contentful/blocks/cards/blog-card/index.test.tsx +29 -0
  32. package/src/contentful/blocks/cards/blog-card/index.tsx +18 -4
  33. package/src/contentful/blocks/cards/blog-card/types.ts +3 -1
  34. package/src/contentful/blocks/cards/product-card/index.test.tsx +61 -0
  35. package/src/contentful/blocks/cards/product-card/index.tsx +38 -9
  36. package/src/contentful/blocks/cards/product-card/types.ts +11 -1
  37. package/src/contentful/blocks/carousel/helper.test.tsx +13 -0
  38. package/src/contentful/blocks/carousel/helper.tsx +1 -0
  39. package/src/contentful/blocks/carousel/types.ts +2 -0
  40. package/src/contentful/blocks/dynamic-form/conditional-fields.tsx +1 -0
  41. package/src/contentful/blocks/dynamic-form/form-field.tsx +1 -0
  42. package/src/contentful/blocks/dynamic-form/index.test.tsx +48 -0
  43. package/src/contentful/blocks/dynamic-form/index.tsx +19 -7
  44. package/src/contentful/blocks/dynamic-form/render.tsx +13 -7
  45. package/src/contentful/blocks/dynamic-form/types.ts +28 -2
  46. package/src/contentful/blocks/dynamic-tabs/index.test.tsx +52 -2
  47. package/src/contentful/blocks/dynamic-tabs/index.tsx +9 -2
  48. package/src/contentful/blocks/dynamic-tabs/ordered-tab-views.tsx +16 -6
  49. package/src/contentful/blocks/dynamic-tabs/tab-views.tsx +10 -6
  50. package/src/contentful/blocks/dynamic-tabs/types.ts +7 -0
  51. package/src/contentful/blocks/email-input-block/index.test.tsx +51 -3
  52. package/src/contentful/blocks/email-input-block/index.tsx +88 -31
  53. package/src/contentful/blocks/email-input-block/types.ts +14 -1
  54. package/src/contentful/blocks/fiber-form/index.test.tsx +40 -2
  55. package/src/contentful/blocks/fiber-form/index.tsx +32 -13
  56. package/src/contentful/blocks/fiber-form/types.ts +18 -0
  57. package/src/contentful/blocks/iframe-embed/index.test.tsx +44 -7
  58. package/src/contentful/blocks/iframe-embed/index.tsx +3 -2
  59. package/src/contentful/blocks/iframe-embed/types.ts +1 -0
  60. package/src/contentful/blocks/navigation/{navigation.stories.tsx → Navigation.stories.tsx} +1 -1
  61. package/src/contentful/blocks/ordered-list/index.test.tsx +54 -4
  62. package/src/contentful/blocks/ordered-list/index.tsx +14 -2
  63. package/src/contentful/blocks/ordered-list/types.ts +15 -2
  64. package/src/contentful/blocks/primary-hero/index.test.tsx +2 -2
  65. package/src/contentful/blocks/primary-hero/index.tsx +5 -5
  66. package/src/contentful/blocks/search-block/index.test.tsx +36 -2
  67. package/src/contentful/blocks/search-block/index.tsx +24 -4
  68. package/src/contentful/blocks/search-block/types.ts +10 -0
  69. package/src/utils/index.ts +1 -1
  70. package/src/utils/replace-tokens.test.ts +40 -0
  71. package/src/utils/replace-tokens.ts +13 -0
  72. /package/src/contentful/blocks/carousel/{carousel.stories.tsx → Carousel.stories.tsx} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windstream/react-shared-components",
3
- "version": "0.2.91",
3
+ "version": "0.2.93",
4
4
  "type": "module",
5
5
  "description": "Shared React components for Kinetic applications",
6
6
  "main": "dist/index.js",
@@ -8,11 +8,13 @@ jest.mock("@shared/components/list", () => ({
8
8
  List: ({
9
9
  children,
10
10
  className,
11
+ ...rest
11
12
  }: {
12
13
  children: React.ReactNode;
13
14
  className?: string;
15
+ [key: string]: unknown;
14
16
  }) => (
15
- <ul data-testid="list" className={className}>
17
+ <ul data-testid="list" className={className} {...rest}>
16
18
  {children}
17
19
  </ul>
18
20
  ),
@@ -117,6 +119,21 @@ describe("Checklist", () => {
117
119
  });
118
120
 
119
121
  describe("fieldProps", () => {
122
+ it("applies the items fieldProps to the list", () => {
123
+ render(
124
+ <Checklist
125
+ items={["One"]}
126
+ fieldProps={{
127
+ items: { "data-contentful-field-id": "checklist-items" },
128
+ }}
129
+ />
130
+ );
131
+ expect(screen.getByTestId("list")).toHaveAttribute(
132
+ "data-contentful-field-id",
133
+ "checklist-items"
134
+ );
135
+ });
136
+
120
137
  it("spreads each structured item's fieldProps onto its own row", () => {
121
138
  render(
122
139
  <Checklist
@@ -31,33 +31,37 @@ export const Checklist: React.FC<ChecklistProps> = props => {
31
31
  iconPosition = "center",
32
32
  iconColor = "green",
33
33
  iconClassName,
34
+ fieldProps,
34
35
  } = props;
35
36
  const showIcons = listIconName !== "disc";
36
37
  if (!items?.length) return null;
37
38
 
38
- const renderContent = (content: React.ReactNode | string) => {
39
- if (
40
- typeof content === "string" &&
41
- content.includes("|") &&
42
- (content.match(/\|/g) || []).length % 2 === 0
43
- ) {
44
- const parts = content.split("|");
45
- const count = (content.match(/\|/g) || []).length;
46
- return parts.map((part, index) => {
47
- if (!part) return null;
48
- const isBold = count > 1 ? index % 2 === 1 : false;
49
- return isBold ? (
50
- <Text as="span" key={index} style={{ fontWeight: 900 }}>{part}</Text>
51
- ) : (
52
- <span key={index}>{part}</span>
53
- );
54
- });
55
- }
56
- return content;
57
- };
39
+ const renderContent = (content: React.ReactNode | string) => {
40
+ if (
41
+ typeof content === "string" &&
42
+ content.includes("|") &&
43
+ (content.match(/\|/g) || []).length % 2 === 0
44
+ ) {
45
+ const parts = content.split("|");
46
+ const count = (content.match(/\|/g) || []).length;
47
+ return parts.map((part, index) => {
48
+ if (!part) return null;
49
+ const isBold = count > 1 ? index % 2 === 1 : false;
50
+ return isBold ? (
51
+ <Text as="span" key={index} style={{ fontWeight: 900 }}>
52
+ {part}
53
+ </Text>
54
+ ) : (
55
+ <span key={index}>{part}</span>
56
+ );
57
+ });
58
+ }
59
+ return content;
60
+ };
58
61
 
59
62
  return (
60
63
  <List
64
+ {...fieldProps?.items}
61
65
  className={cx("mt-2 space-y-1", listContainerClassName)}
62
66
  variant={showIcons ? "unstyled" : "default"}
63
67
  >
@@ -105,7 +109,7 @@ export const Checklist: React.FC<ChecklistProps> = props => {
105
109
  />
106
110
  </div>
107
111
  ) : null}
108
- <Text as="div">{renderContent(content)}</Text>
112
+ <Text as="div">{renderContent(content)}</Text>
109
113
  </ListItem>
110
114
  );
111
115
  })}
@@ -1,5 +1,9 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import type { FieldProps } from "@shared/types/field-props";
4
+
1
5
  export type ChecklistItem = {
2
- title: React.ReactNode;
6
+ title: ReactNode;
3
7
  iconUrl?: string;
4
8
  iconAlt?: string;
5
9
  /** Extra DOM attribute props, spread onto this item's list row. */
@@ -7,7 +11,7 @@ export type ChecklistItem = {
7
11
  };
8
12
 
9
13
  export type ChecklistProps = {
10
- items: (React.ReactNode | ChecklistItem)[];
14
+ items: (ReactNode | ChecklistItem)[];
11
15
  // TODO: add icon names as needed
12
16
  listIconName?: "check" | "disc";
13
17
  listItemClassName?: string;
@@ -16,6 +20,8 @@ export type ChecklistProps = {
16
20
  iconSize?: 20 | 24 | 32 | 40 | 48 | 52 | undefined;
17
21
  iconPosition?: "top" | "center";
18
22
  iconColor?: "green" | "yellow" | "blue";
23
+ /** Inspector attributes for the checklist items field. */
24
+ fieldProps?: FieldProps<"items">;
19
25
  };
20
26
 
21
27
  export const iconColors = {
@@ -27,6 +27,20 @@ describe("AddressInputBanner", () => {
27
27
  );
28
28
  });
29
29
 
30
+ it("tags the custom check-plans CTA region", () => {
31
+ render(
32
+ <AddressInputBanner
33
+ {...defaultProps}
34
+ renderCheckPlans={() => <button>Check plans now</button>}
35
+ fieldProps={{ cta: { "data-contentful-field-id": "cta" } }}
36
+ />
37
+ );
38
+ expect(screen.getByText("Check plans now").parentElement).toHaveAttribute(
39
+ "data-contentful-field-id",
40
+ "cta"
41
+ );
42
+ });
43
+
30
44
  it("returns null when isVisible is false", () => {
31
45
  const { container } = render(
32
46
  <AddressInputBanner {...defaultProps} isVisible={false} />
@@ -50,7 +50,9 @@ export const AddressInputBanner: FC<AddressInputBannerProps> = props => {
50
50
  >
51
51
  {title}
52
52
  </Text>
53
- {renderedCheckPlans ? <div>{renderedCheckPlans}</div> : null}
53
+ {renderedCheckPlans ? (
54
+ <div {...fieldProps?.cta}>{renderedCheckPlans}</div>
55
+ ) : null}
54
56
  </div>
55
57
  );
56
58
  };
@@ -13,5 +13,5 @@ export interface AddressInputBannerProps {
13
13
  isVisible?: boolean;
14
14
  renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
15
15
  /** Extra DOM attribute props, spread onto the respective rendered elements. */
16
- fieldProps?: FieldProps<"title">;
16
+ fieldProps?: FieldProps<"title" | "cta">;
17
17
  }
@@ -70,7 +70,7 @@ jest.mock("@shared/components/text", () => ({
70
70
  }));
71
71
 
72
72
  jest.mock("@shared/contentful/blocks/cards/blog-card", () => ({
73
- BlogCard: ({ href, title, category, image, index }: any) => (
73
+ BlogCard: ({ href, title, category, image, index, fieldProps }: any) => (
74
74
  <a
75
75
  href={href}
76
76
  data-testid={`blog-card-${index}`}
@@ -78,6 +78,7 @@ jest.mock("@shared/contentful/blocks/cards/blog-card", () => ({
78
78
  data-image-alt={image?.alt || ""}
79
79
  data-image-width={image?.width || ""}
80
80
  data-image-height={image?.height || ""}
81
+ data-field-props={JSON.stringify(fieldProps)}
81
82
  >
82
83
  {title} - {category}
83
84
  </a>
@@ -136,6 +137,25 @@ const defaultProps: BlogGridProps = {
136
137
  };
137
138
 
138
139
  describe("BlogGrid", () => {
140
+ it("forwards each article's inspector slots without tagging another article", () => {
141
+ const fields = { title: { "data-contentful-entry-id": "article-one" } };
142
+ render(
143
+ <BlogGrid
144
+ {...defaultProps}
145
+ paginatedArticles={[
146
+ { ...mockArticles[0], fieldProps: fields },
147
+ mockArticles[1],
148
+ ]}
149
+ />
150
+ );
151
+ expect(screen.getByTestId("blog-card-0")).toHaveAttribute(
152
+ "data-field-props",
153
+ JSON.stringify(fields)
154
+ );
155
+ expect(screen.getByTestId("blog-card-1")).not.toHaveAttribute(
156
+ "data-field-props"
157
+ );
158
+ });
139
159
  beforeEach(() => {
140
160
  jest.clearAllMocks();
141
161
  mockSearchParams = new URLSearchParams("");
@@ -128,6 +128,7 @@ export function BlogGrid({
128
128
  description={article.shortDescription}
129
129
  date={article.blogCreationDate}
130
130
  category={article.category}
131
+ fieldProps={article.fieldProps}
131
132
  image={coverImage}
132
133
  index={index}
133
134
  />
@@ -1,5 +1,8 @@
1
+ import type { BlogCardProps as CardProps } from "@shared/contentful/blocks/cards/blog-card/types";
2
+
1
3
  export interface BlogCardProps {
2
4
  slug: string;
5
+ fieldProps?: CardProps["fieldProps"];
3
6
  title?: string;
4
7
  shortDescription?: string;
5
8
  blogCreationDate?: string;
@@ -41,18 +41,29 @@ jest.mock("@shared/components/select", () => ({
41
41
  }));
42
42
 
43
43
  jest.mock("@shared/components/text", () => ({
44
- Text: ({ as: Tag = "span", children, className }: any) => (
45
- <Tag className={className}>{children}</Tag>
44
+ Text: ({ as: Tag = "span", children, className, ...rest }: any) => (
45
+ <Tag className={className} {...rest}>
46
+ {children}
47
+ </Tag>
46
48
  ),
47
49
  }));
48
50
 
49
51
  jest.mock("@shared/contentful/blocks/cards/blog-card", () => ({
50
- BlogCard: ({ href, title, category, image, imageComponent, index }: any) => (
52
+ BlogCard: ({
53
+ href,
54
+ title,
55
+ category,
56
+ image,
57
+ imageComponent,
58
+ index,
59
+ fieldProps,
60
+ }: any) => (
51
61
  <a
52
62
  href={href}
53
63
  data-testid={`blog-card-${index}`}
54
64
  data-has-image={!!image}
55
65
  data-has-image-component={!!imageComponent}
66
+ data-field-props={JSON.stringify(fieldProps)}
56
67
  >
57
68
  {title} - {category}
58
69
  </a>
@@ -108,6 +119,41 @@ const defaultProps: BlogGridBaseProps = {
108
119
  };
109
120
 
110
121
  describe("BlogGridBase", () => {
122
+ it("tags authored headings and forwards child article slots without tagging siblings", () => {
123
+ const articleFields = {
124
+ title: { "data-contentful-entry-id": "article-one" },
125
+ };
126
+ render(
127
+ <BlogGridBase
128
+ {...defaultProps}
129
+ title="Latest Stories"
130
+ description="Updates"
131
+ fieldProps={{
132
+ title: { "data-contentful-field-id": "recentArticlesTitle" },
133
+ description: { "data-contentful-field-id": "description" },
134
+ }}
135
+ paginatedArticles={[
136
+ { ...mockArticles[0], fieldProps: articleFields },
137
+ mockArticles[1],
138
+ ]}
139
+ />
140
+ );
141
+ expect(screen.getByText("Latest Stories")).toHaveAttribute(
142
+ "data-contentful-field-id",
143
+ "recentArticlesTitle"
144
+ );
145
+ expect(screen.getByText("Updates")).toHaveAttribute(
146
+ "data-contentful-field-id",
147
+ "description"
148
+ );
149
+ expect(screen.getByTestId("blog-card-0")).toHaveAttribute(
150
+ "data-field-props",
151
+ JSON.stringify(articleFields)
152
+ );
153
+ expect(screen.getByTestId("blog-card-1")).not.toHaveAttribute(
154
+ "data-field-props"
155
+ );
156
+ });
111
157
  beforeEach(() => jest.clearAllMocks());
112
158
 
113
159
  describe("Rendering", () => {
@@ -19,6 +19,7 @@ export function BlogGridBase({
19
19
  getPageHref,
20
20
  asList = false,
21
21
  imageComponent,
22
+ fieldProps,
22
23
  }: BlogGridBaseProps) {
23
24
  function handleCategoryChange(value: unknown) {
24
25
  if (!value || Array.isArray(value)) return;
@@ -39,11 +40,18 @@ export function BlogGridBase({
39
40
  {/* Controls bar */}
40
41
  <div className="mx-auto flex max-w-[1200px] flex-wrap items-center justify-between gap-3 px-6 pb-6">
41
42
  {description && (
42
- <Text as="p" className="body1 mb-4 w-full text-center md:mb-6">
43
+ <Text
44
+ as="p"
45
+ className="body1 mb-4 w-full text-center md:mb-6"
46
+ {...fieldProps?.description}
47
+ >
43
48
  {description}
44
49
  </Text>
45
50
  )}
46
- <Text className="w-full text-center text-heading5 font-black lowercase text-text-secondary md:w-auto md:text-left">
51
+ <Text
52
+ className="w-full text-center text-heading5 font-black lowercase text-text-secondary md:w-auto md:text-left"
53
+ {...fieldProps?.title}
54
+ >
47
55
  {title}
48
56
  </Text>
49
57
  {categoryOptions.length > 0 && (
@@ -105,6 +113,7 @@ export function BlogGridBase({
105
113
  description={article.shortDescription}
106
114
  date={article.blogCreationDate}
107
115
  category={article.category}
116
+ fieldProps={article.fieldProps}
108
117
  image={coverImage}
109
118
  imageComponent={imageComponent}
110
119
  asGrid={!asList}
@@ -1,9 +1,14 @@
1
1
  import React from "react";
2
2
 
3
- import { BlogCardImageProps } from "@shared/contentful/blocks/cards/blog-card/types";
3
+ import {
4
+ BlogCardImageProps,
5
+ BlogCardProps as CardProps,
6
+ } from "@shared/contentful/blocks/cards/blog-card/types";
7
+ import type { FieldProps } from "@shared/types/field-props";
4
8
 
5
9
  export interface BlogCardProps {
6
10
  slug: string;
11
+ fieldProps?: CardProps["fieldProps"];
7
12
  title?: string;
8
13
  shortDescription?: string;
9
14
  blogCreationDate?: string;
@@ -17,6 +22,7 @@ export interface BlogCardProps {
17
22
  }
18
23
 
19
24
  export interface BlogGridBaseProps {
25
+ fieldProps?: FieldProps<"title" | "description">;
20
26
  title?: string;
21
27
  description?: string;
22
28
  paginatedArticles: BlogCardProps[];
@@ -8,7 +8,7 @@ import {
8
8
  fourColumnItems,
9
9
  fullImageItems,
10
10
  withFloatingImageArgs,
11
- } from "./callout.stories.mocks";
11
+ } from "./Callout.stories.mocks";
12
12
 
13
13
  import { DocsPage } from "@shared/stories/DocsTemplate";
14
14
  import type { Meta, StoryObj } from "@storybook/react";
@@ -32,6 +32,35 @@ describe("BlogCard", () => {
32
32
  };
33
33
 
34
34
  describe("Rendering", () => {
35
+ it("tags each authored child field independently", () => {
36
+ const tag = (field: string) => ({ "data-contentful-field-id": field });
37
+ const { container } = render(
38
+ <BlogCard
39
+ {...defaultProps}
40
+ image={{ src: "/cover.jpg", alt: "Cover", width: 400, height: 300 }}
41
+ fieldProps={{
42
+ title: tag("title"),
43
+ description: tag("shortDescription"),
44
+ date: tag("blogCreationDate"),
45
+ category: tag("category"),
46
+ image: tag("cover"),
47
+ href: tag("slug"),
48
+ }}
49
+ />
50
+ );
51
+ for (const field of [
52
+ "title",
53
+ "shortDescription",
54
+ "blogCreationDate",
55
+ "category",
56
+ "cover",
57
+ "slug",
58
+ ]) {
59
+ expect(
60
+ container.querySelector(`[data-contentful-field-id="${field}"]`)
61
+ ).not.toBeNull();
62
+ }
63
+ });
35
64
  it("renders the article element", () => {
36
65
  const { container } = render(<BlogCard {...defaultProps} />);
37
66
  expect(container.querySelector("article")).toBeInTheDocument();
@@ -43,7 +43,10 @@ export const BlogCard: React.FC<BlogCardProps> = ({
43
43
  >
44
44
  {/* Image (hidden in list view) */}
45
45
  {asGrid && (
46
- <div className="block h-[232px] w-full flex-shrink-0 overflow-hidden bg-gray-100">
46
+ <div
47
+ className="block h-[232px] w-full flex-shrink-0 overflow-hidden bg-gray-100"
48
+ {...fieldProps?.image}
49
+ >
47
50
  {image ? (
48
51
  ImageComponent ? (
49
52
  <ImageComponent
@@ -82,7 +85,11 @@ export const BlogCard: React.FC<BlogCardProps> = ({
82
85
  >
83
86
  {/* Meta: category + date */}
84
87
  <div className="flex items-center gap-2 text-[13px]">
85
- {asGrid && <span className="body2 text-text-brand">{category}</span>}
88
+ {asGrid && (
89
+ <span className="body2 text-text-brand" {...fieldProps?.category}>
90
+ {category}
91
+ </span>
92
+ )}
86
93
  {date && (
87
94
  <>
88
95
  {asGrid && (
@@ -90,7 +97,9 @@ export const BlogCard: React.FC<BlogCardProps> = ({
90
97
  •
91
98
  </span>
92
99
  )}
93
- <time className="body2 text-text">{date}</time>
100
+ <time className="body2 text-text" {...fieldProps?.date}>
101
+ {date}
102
+ </time>
94
103
  </>
95
104
  )}
96
105
  </div>
@@ -100,6 +109,7 @@ export const BlogCard: React.FC<BlogCardProps> = ({
100
109
  <Text as="h2" className="heading6" {...fieldProps?.title}>
101
110
  <Link
102
111
  href={href}
112
+ {...fieldProps?.href}
103
113
  className="m-0 line-clamp-3 font-black transition-colors duration-150 after:absolute after:inset-0 after:z-10 after:content-[''] hover:text-text-brand group-hover:text-text-brand"
104
114
  >
105
115
  {title}
@@ -120,7 +130,11 @@ export const BlogCard: React.FC<BlogCardProps> = ({
120
130
 
121
131
  {/* Fallback stretched link keeps the card navigable when no title link is rendered */}
122
132
  {!title && (
123
- <Link href={href} className="absolute inset-0 z-10">
133
+ <Link
134
+ href={href}
135
+ className="absolute inset-0 z-10"
136
+ {...fieldProps?.href}
137
+ >
124
138
  <span className="sr-only">{category ?? "Read more"}</span>
125
139
  </Link>
126
140
  )}
@@ -35,5 +35,7 @@ export interface BlogCardProps {
35
35
  asGrid?: boolean;
36
36
  index?: number;
37
37
  /** Per-field DOM attributes supplied by the consumer or parent Callout item. */
38
- fieldProps?: FieldProps<"title" | "description">;
38
+ fieldProps?: FieldProps<
39
+ "title" | "description" | "image" | "category" | "date" | "href"
40
+ >;
39
41
  }
@@ -126,6 +126,49 @@ describe("ProductCard", () => {
126
126
  });
127
127
  });
128
128
 
129
+ describe("Strikethrough price", () => {
130
+ it("does not render a strikethrough when not provided", () => {
131
+ const { container } = render(<ProductCard {...defaultProps} />);
132
+ expect(container.querySelector("s")).toBeNull();
133
+ expect(screen.queryByText(/Regular price/)).toBeNull();
134
+ });
135
+
136
+ it("does not render a strikethrough for an empty/whitespace value", () => {
137
+ const { container } = render(
138
+ <ProductCard {...defaultProps} strikethroughPrice=" " />
139
+ );
140
+ expect(container.querySelector("s")).toBeNull();
141
+ });
142
+
143
+ it("renders the strikethrough price with a leading $ when truthy", () => {
144
+ const { container } = render(
145
+ <ProductCard {...defaultProps} strikethroughPrice="64.99" />
146
+ );
147
+ const strike = container.querySelector("s");
148
+ expect(strike).toHaveTextContent("$64.99");
149
+ expect(screen.getByText(/Regular price/)).toHaveClass("sr-only");
150
+ });
151
+
152
+ it("does not double the $ when the value already has one", () => {
153
+ const { container } = render(
154
+ <ProductCard {...defaultProps} strikethroughPrice="$64.99" />
155
+ );
156
+ expect(container.querySelector("s")?.textContent).toBe("$64.99");
157
+ });
158
+
159
+ it("keeps the price unit rendered alongside the strikethrough", () => {
160
+ render(<ProductCard {...defaultProps} strikethroughPrice="64.99" />);
161
+ expect(screen.getByText("99/mo")).toBeInTheDocument();
162
+ });
163
+
164
+ it("does not render a strikethrough when there is no price", () => {
165
+ const { container } = render(
166
+ <ProductCard {...defaultProps} price="" strikethroughPrice="64.99" />
167
+ );
168
+ expect(container.querySelector("s")).toBeNull();
169
+ });
170
+ });
171
+
129
172
  describe("Business theme", () => {
130
173
  it("uses the semantic brand token for the plan name and lowercases it by default", () => {
131
174
  render(<ProductCard {...defaultProps} />);
@@ -299,6 +342,24 @@ describe("ProductCard", () => {
299
342
  expect(screen.getByAltText("Free router")).toBeInTheDocument();
300
343
  });
301
344
 
345
+ it("spreads each gift's fieldProps onto its own row", () => {
346
+ const giftBadge = [
347
+ {
348
+ title: "Free router",
349
+ icon: "/router.png",
350
+ fieldProps: { "data-contentful-entry-id": "gift-1" },
351
+ },
352
+ { title: "Free install", icon: "/install.png" },
353
+ ] as any;
354
+ render(<ProductCard {...defaultProps} giftBadge={giftBadge} />);
355
+ expect(
356
+ screen.getByText("Free router").parentElement
357
+ ).toHaveAttribute("data-contentful-entry-id", "gift-1");
358
+ expect(
359
+ screen.getByText("Free install").parentElement
360
+ ).not.toHaveAttribute("data-contentful-entry-id");
361
+ });
362
+
302
363
  it("does not render gift badges when not provided", () => {
303
364
  render(<ProductCard {...defaultProps} />);
304
365
  expect(screen.queryByText("Free router")).not.toBeInTheDocument();
@@ -30,11 +30,22 @@ const renderPipeBold = (value: React.ReactNode): React.ReactNode => {
30
30
  );
31
31
  };
32
32
 
33
+ /**
34
+ * Normalizes a strikethrough (regular) price to a single leading `$`, e.g.
35
+ * `"44.99"` and `"$44.99"` both render as `"$44.99"`. Returns `""` when empty.
36
+ */
37
+ const formatStrikethroughPrice = (value?: string | null): string => {
38
+ if (typeof value !== "string") return "";
39
+ const amount = value.trim().replace(/^\$\s*/, "");
40
+ return amount ? `$${amount}` : "";
41
+ };
42
+
33
43
  export const ProductCard: React.FC<ProductCardProps> = ({
34
44
  planName,
35
45
  planSubtext,
36
46
  speed,
37
47
  price,
48
+ strikethroughPrice,
38
49
  priceUnit = "/mo",
39
50
  description,
40
51
  bestValue = false,
@@ -73,6 +84,9 @@ export const ProductCard: React.FC<ProductCardProps> = ({
73
84
 
74
85
  const isDark = theme === "dark";
75
86
 
87
+ const formattedStrikethroughPrice =
88
+ formatStrikethroughPrice(strikethroughPrice);
89
+
76
90
  // Two-column mobile cards use smaller type below `md` and step up to the
77
91
  // standard sizes from `md` up (matches production residential). One-column
78
92
  // stacks keep the standard sizes at every breakpoint.
@@ -160,15 +174,29 @@ export const ProductCard: React.FC<ProductCardProps> = ({
160
174
  >
161
175
  {price.split(".")[0]}
162
176
  </Text>
163
- <Text
164
- as="span"
165
- className={cx(
166
- priceUnitClass,
167
- isDark ? "text-text-inverse" : "text-text"
168
- )}
169
- >
170
- {`${price.split(".")[1]}${priceUnit}`}
171
- </Text>
177
+ <div className="flex flex-col gap-1">
178
+ <Text
179
+ as="span"
180
+ className={cx(
181
+ priceUnitClass,
182
+ isDark ? "text-text-inverse" : "text-text"
183
+ )}
184
+ >
185
+ {`${price.split(".")[1]}${priceUnit}`}
186
+ </Text>
187
+ {formattedStrikethroughPrice ? (
188
+ <Text
189
+ as="span"
190
+ className={cx(
191
+ "micro whitespace-nowrap",
192
+ isDark ? "text-text-inverse" : "text-text-info"
193
+ )}
194
+ >
195
+ <span className="sr-only">Regular price </span>
196
+ <s>{formattedStrikethroughPrice}</s>
197
+ </Text>
198
+ ) : null}
199
+ </div>
172
200
  </div>
173
201
  {priceSuffixBottom ? (
174
202
  <Text
@@ -214,6 +242,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
214
242
  <div
215
243
  key={badge.title}
216
244
  className="flex w-full items-center gap-2"
245
+ {...badge.fieldProps}
217
246
  >
218
247
  <Image
219
248
  src={badge.icon}