@windstream/react-shared-components 0.2.92 → 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.
- package/dist/contentful/index.d.ts +46 -6
- package/dist/contentful/index.esm.js +4 -4
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +3 -3
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +4 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.esm.js +2 -2
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/utils/index.d.ts +2 -1
- package/dist/utils/index.esm.js +1 -1
- package/dist/utils/index.esm.js.map +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/checklist/index.test.tsx +18 -1
- package/src/components/checklist/index.tsx +25 -21
- package/src/components/checklist/types.ts +8 -2
- package/src/contentful/blocks/address-input-banner/index.test.tsx +14 -0
- package/src/contentful/blocks/address-input-banner/index.tsx +3 -1
- package/src/contentful/blocks/address-input-banner/types.ts +1 -1
- package/src/contentful/blocks/blogs-grid/index.test.tsx +21 -1
- package/src/contentful/blocks/blogs-grid/index.tsx +1 -0
- package/src/contentful/blocks/blogs-grid/types.ts +3 -0
- package/src/contentful/blocks/blogs-grid-base/index.test.tsx +49 -3
- package/src/contentful/blocks/blogs-grid-base/index.tsx +11 -2
- package/src/contentful/blocks/blogs-grid-base/types.ts +7 -1
- package/src/contentful/blocks/cards/blog-card/index.test.tsx +29 -0
- package/src/contentful/blocks/cards/blog-card/index.tsx +18 -4
- package/src/contentful/blocks/cards/blog-card/types.ts +3 -1
- package/src/contentful/blocks/cards/product-card/index.test.tsx +43 -0
- package/src/contentful/blocks/cards/product-card/index.tsx +37 -9
- package/src/contentful/blocks/cards/product-card/types.ts +3 -0
- package/src/contentful/blocks/carousel/helper.test.tsx +13 -0
- package/src/contentful/blocks/carousel/helper.tsx +1 -0
- package/src/contentful/blocks/carousel/types.ts +2 -0
- package/src/contentful/blocks/dynamic-form/conditional-fields.tsx +1 -0
- package/src/contentful/blocks/dynamic-form/form-field.tsx +1 -0
- package/src/contentful/blocks/dynamic-form/index.test.tsx +48 -0
- package/src/contentful/blocks/dynamic-form/index.tsx +19 -7
- package/src/contentful/blocks/dynamic-form/render.tsx +13 -7
- package/src/contentful/blocks/dynamic-form/types.ts +28 -2
- package/src/contentful/blocks/dynamic-tabs/index.test.tsx +52 -2
- package/src/contentful/blocks/dynamic-tabs/index.tsx +9 -2
- package/src/contentful/blocks/dynamic-tabs/ordered-tab-views.tsx +16 -6
- package/src/contentful/blocks/dynamic-tabs/tab-views.tsx +10 -6
- package/src/contentful/blocks/dynamic-tabs/types.ts +7 -0
- package/src/contentful/blocks/email-input-block/index.test.tsx +51 -3
- package/src/contentful/blocks/email-input-block/index.tsx +88 -31
- package/src/contentful/blocks/email-input-block/types.ts +14 -1
- package/src/contentful/blocks/fiber-form/index.test.tsx +40 -2
- package/src/contentful/blocks/fiber-form/index.tsx +32 -13
- package/src/contentful/blocks/fiber-form/types.ts +18 -0
- package/src/contentful/blocks/iframe-embed/index.test.tsx +44 -7
- package/src/contentful/blocks/iframe-embed/index.tsx +3 -2
- package/src/contentful/blocks/iframe-embed/types.ts +1 -0
- package/src/contentful/blocks/ordered-list/index.test.tsx +54 -4
- package/src/contentful/blocks/ordered-list/index.tsx +14 -2
- package/src/contentful/blocks/ordered-list/types.ts +15 -2
- package/src/contentful/blocks/primary-hero/index.tsx +5 -5
- package/src/contentful/blocks/search-block/index.test.tsx +36 -2
- package/src/contentful/blocks/search-block/index.tsx +24 -4
- package/src/contentful/blocks/search-block/types.ts +10 -0
- package/src/utils/index.ts +1 -1
- package/src/utils/replace-tokens.test.ts +40 -0
- package/src/utils/replace-tokens.ts +13 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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:
|
|
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: (
|
|
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 ?
|
|
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("");
|
|
@@ -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}
|
|
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: ({
|
|
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
|
|
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
|
|
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 {
|
|
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[];
|
|
@@ -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
|
|
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 &&
|
|
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"
|
|
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
|
|
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<
|
|
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} />);
|
|
@@ -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
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
@@ -11,6 +11,9 @@ export interface ProductCardProps {
|
|
|
11
11
|
techType?: string;
|
|
12
12
|
ismaxSpeed?: boolean;
|
|
13
13
|
price?: string;
|
|
14
|
+
/** Regular (pre-promo) price rendered struck through under the price unit.
|
|
15
|
+
* Rendered only when truthy; a leading `$` is added if missing. */
|
|
16
|
+
strikethroughPrice?: string;
|
|
14
17
|
/** The price unit rendered after the cents, e.g. "/mo" or "/mo*". Defaults
|
|
15
18
|
* to "/mo". Lets Contentful control a trailing disclaimer star. */
|
|
16
19
|
priceUnit?: string;
|
|
@@ -35,12 +35,14 @@ jest.mock("@shared/contentful/blocks/cards/product-card", () => ({
|
|
|
35
35
|
giftBadge,
|
|
36
36
|
hasAnyGift,
|
|
37
37
|
price,
|
|
38
|
+
strikethroughPrice,
|
|
38
39
|
}: any) => (
|
|
39
40
|
<article
|
|
40
41
|
data-testid="product-card"
|
|
41
42
|
data-plan={planName}
|
|
42
43
|
data-speed={speed}
|
|
43
44
|
data-price={price}
|
|
45
|
+
data-strikethrough={strikethroughPrice}
|
|
44
46
|
data-expanded={isExpanded}
|
|
45
47
|
data-business={enableBusinessSettings ? "true" : "false"}
|
|
46
48
|
>
|
|
@@ -157,6 +159,17 @@ describe("ProductCardCarousel", () => {
|
|
|
157
159
|
expect(screen.getAllByTestId("product-card")).toHaveLength(1);
|
|
158
160
|
});
|
|
159
161
|
|
|
162
|
+
it("passes strikethroughPrice through to the ProductCard", () => {
|
|
163
|
+
const fields = createProductFields([
|
|
164
|
+
{ ...sampleProduct, strikethroughPrice: "64.99" },
|
|
165
|
+
{ ...sampleProduct, sys: { id: "prod-2" } },
|
|
166
|
+
]);
|
|
167
|
+
render(<ProductCardCarousel fields={fields} />);
|
|
168
|
+
const [withStrike, withoutStrike] = screen.getAllByTestId("product-card");
|
|
169
|
+
expect(withStrike).toHaveAttribute("data-strikethrough", "64.99");
|
|
170
|
+
expect(withoutStrike).not.toHaveAttribute("data-strikethrough");
|
|
171
|
+
});
|
|
172
|
+
|
|
160
173
|
it("renders non-carousel layout for a single item", () => {
|
|
161
174
|
const fields = createProductFields([sampleProduct]);
|
|
162
175
|
const { container } = render(<ProductCardCarousel fields={fields} />);
|
|
@@ -431,6 +431,7 @@ function ProductCardPanel({
|
|
|
431
431
|
techType={item?.techType || ""}
|
|
432
432
|
ismaxSpeed={item?.ismaxSpeed || ""}
|
|
433
433
|
price={formattedPrice}
|
|
434
|
+
strikethroughPrice={item?.strikethroughPrice}
|
|
434
435
|
priceUnit={priceUnit}
|
|
435
436
|
description={item?.productCardDescription || ""}
|
|
436
437
|
giftBadge={rawBadge}
|