@windstream/react-shared-components 0.2.69 → 0.2.71
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 +3 -0
- package/dist/contentful/index.esm.js +4 -4
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +2 -2
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +33 -4
- package/dist/index.d.ts +32 -3
- package/dist/index.esm.js +8 -8
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +8 -8
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -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/rich-text-basic/index.test.tsx +172 -0
- package/src/components/rich-text-basic/index.tsx +102 -0
- package/src/components/rich-text-basic/types.ts +34 -0
- package/src/components/video-link/index.tsx +2 -2
- package/src/contentful/blocks/primary-hero/index.tsx +51 -18
- package/src/contentful/blocks/primary-hero/types.ts +17 -0
- package/src/hooks/contentful/use-contentful-rich-text.tsx +39 -2
- package/src/index.ts +7 -0
- package/src/types/field-props.ts +9 -0
package/package.json
CHANGED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { render, screen } from "@testing-library/react";
|
|
5
|
+
|
|
6
|
+
import { RichTextBasic } from "./index";
|
|
7
|
+
|
|
8
|
+
const paragraphDoc = (text: string) => ({
|
|
9
|
+
nodeType: "document",
|
|
10
|
+
data: {},
|
|
11
|
+
content: [
|
|
12
|
+
{
|
|
13
|
+
nodeType: "paragraph",
|
|
14
|
+
data: {},
|
|
15
|
+
content: [{ nodeType: "text", value: text, marks: [], data: {} }],
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("RichTextBasic", () => {
|
|
21
|
+
it("renders paragraph text", () => {
|
|
22
|
+
render(<RichTextBasic {...paragraphDoc("Hello world")} />);
|
|
23
|
+
expect(screen.getByText("Hello world")).toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("renders a heading using the first child value", () => {
|
|
27
|
+
const doc = {
|
|
28
|
+
nodeType: "document",
|
|
29
|
+
data: {},
|
|
30
|
+
content: [
|
|
31
|
+
{
|
|
32
|
+
nodeType: "heading-1",
|
|
33
|
+
data: {},
|
|
34
|
+
content: [{ nodeType: "text", value: "Title", marks: [], data: {} }],
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
render(<RichTextBasic {...doc} />);
|
|
39
|
+
const heading = screen.getByRole("heading", { level: 1 });
|
|
40
|
+
expect(heading).toHaveTextContent("Title");
|
|
41
|
+
expect(heading.className).toContain("heading1");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("includes both the mobile-base and md: desktop heading classes", () => {
|
|
45
|
+
const doc = {
|
|
46
|
+
nodeType: "document",
|
|
47
|
+
data: {},
|
|
48
|
+
content: [
|
|
49
|
+
{
|
|
50
|
+
nodeType: "heading-1",
|
|
51
|
+
data: {},
|
|
52
|
+
content: [{ nodeType: "text", value: "Title", marks: [], data: {} }],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
render(<RichTextBasic {...doc} />);
|
|
57
|
+
const { className } = screen.getByRole("heading", { level: 1 });
|
|
58
|
+
expect(className).toContain("heading2");
|
|
59
|
+
expect(className).toContain("md:heading1");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("renders bold marks", () => {
|
|
63
|
+
const doc = {
|
|
64
|
+
nodeType: "document",
|
|
65
|
+
data: {},
|
|
66
|
+
content: [
|
|
67
|
+
{
|
|
68
|
+
nodeType: "paragraph",
|
|
69
|
+
data: {},
|
|
70
|
+
content: [
|
|
71
|
+
{ nodeType: "text", value: "Bold", marks: [{ type: "bold" }], data: {} },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
render(<RichTextBasic {...doc} />);
|
|
77
|
+
expect(screen.getByText("Bold").tagName).toBe("STRONG");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("renders hyperlinks", () => {
|
|
81
|
+
const doc = {
|
|
82
|
+
nodeType: "document",
|
|
83
|
+
data: {},
|
|
84
|
+
content: [
|
|
85
|
+
{
|
|
86
|
+
nodeType: "paragraph",
|
|
87
|
+
data: {},
|
|
88
|
+
content: [
|
|
89
|
+
{
|
|
90
|
+
nodeType: "hyperlink",
|
|
91
|
+
data: { uri: "https://example.com" },
|
|
92
|
+
content: [{ nodeType: "text", value: "Link", marks: [], data: {} }],
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
render(<RichTextBasic {...doc} isTargetBlank />);
|
|
99
|
+
const link = screen.getByRole("link", { name: "Link" });
|
|
100
|
+
expect(link).toHaveAttribute("href", "https://example.com");
|
|
101
|
+
expect(link).toHaveAttribute("target", "_blank");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("renders inline [icon_name] tokens as MaterialIcon glyphs", () => {
|
|
105
|
+
render(<RichTextBasic {...paragraphDoc("look [star] here")} />);
|
|
106
|
+
const icon = screen.getByText("star");
|
|
107
|
+
expect(icon.className).toContain("material-symbols-rounded");
|
|
108
|
+
expect(screen.getByText(/look/)).toBeInTheDocument();
|
|
109
|
+
expect(screen.getByText(/here/)).toBeInTheDocument();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("renders embedded callout entries", () => {
|
|
113
|
+
const doc = {
|
|
114
|
+
nodeType: "document",
|
|
115
|
+
data: {},
|
|
116
|
+
content: [
|
|
117
|
+
{
|
|
118
|
+
nodeType: "embedded-entry-block",
|
|
119
|
+
data: {
|
|
120
|
+
target: {
|
|
121
|
+
fields: { title: "Heads up", body: "Important info" },
|
|
122
|
+
sys: { contentType: { sys: { id: "callout" } } },
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
content: [],
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
render(<RichTextBasic {...doc} />);
|
|
130
|
+
expect(screen.getByText("Heads up").tagName).toBe("STRONG");
|
|
131
|
+
expect(screen.getByText("Important info")).toBeInTheDocument();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("renders table content via the table renderer when the document contains a table, regardless of tableLayout className", () => {
|
|
135
|
+
const doc = {
|
|
136
|
+
nodeType: "document",
|
|
137
|
+
data: {},
|
|
138
|
+
content: [
|
|
139
|
+
{
|
|
140
|
+
nodeType: "table",
|
|
141
|
+
data: {},
|
|
142
|
+
content: [
|
|
143
|
+
{
|
|
144
|
+
nodeType: "table-row",
|
|
145
|
+
data: {},
|
|
146
|
+
content: [
|
|
147
|
+
{
|
|
148
|
+
nodeType: "table-header-cell",
|
|
149
|
+
data: {},
|
|
150
|
+
content: [
|
|
151
|
+
{
|
|
152
|
+
nodeType: "paragraph",
|
|
153
|
+
data: {},
|
|
154
|
+
content: [
|
|
155
|
+
{ nodeType: "text", value: "Head", marks: [], data: {} },
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
const { rerender } = render(<RichTextBasic {...doc} className="tableLayout" />);
|
|
167
|
+
expect(screen.getByRole("columnheader")).toHaveTextContent("Head");
|
|
168
|
+
|
|
169
|
+
rerender(<RichTextBasic {...doc} />);
|
|
170
|
+
expect(screen.getByRole("columnheader")).toHaveTextContent("Head");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { FC } from "react";
|
|
2
|
+
|
|
3
|
+
import { BLOCKS } from "@contentful/rich-text-types";
|
|
4
|
+
import {
|
|
5
|
+
renderContentfulRichText,
|
|
6
|
+
renderContentfulRichTextTable,
|
|
7
|
+
} from "@shared/hooks/contentful/use-contentful-rich-text";
|
|
8
|
+
import { cx } from "@shared/utils/cx";
|
|
9
|
+
|
|
10
|
+
import type { RichTextBasicProps } from "./types";
|
|
11
|
+
import { toDocument } from "@shared/utils";
|
|
12
|
+
|
|
13
|
+
// Contentful can store one logical run of text as several adjacent "text"
|
|
14
|
+
// nodes with identical (often empty) marks - an authoring artifact. Per-node
|
|
15
|
+
// regexes (icon tokens like [shopping_cart], ${CITY}/${STATE}) only ever see
|
|
16
|
+
// one node's content, so without merging, a token split across node
|
|
17
|
+
// boundaries never matches and renders as literal bracket text.
|
|
18
|
+
const mergeAdjacentTextNodes = (node: any): any => {
|
|
19
|
+
if (!node || !Array.isArray(node.content)) return node;
|
|
20
|
+
const mergedContent: any[] = [];
|
|
21
|
+
for (const child of node.content) {
|
|
22
|
+
const prev = mergedContent[mergedContent.length - 1];
|
|
23
|
+
if (
|
|
24
|
+
child?.nodeType === "text" &&
|
|
25
|
+
prev?.nodeType === "text" &&
|
|
26
|
+
JSON.stringify(prev.marks) === JSON.stringify(child.marks)
|
|
27
|
+
) {
|
|
28
|
+
mergedContent[mergedContent.length - 1] = {
|
|
29
|
+
...prev,
|
|
30
|
+
value: prev.value + child.value,
|
|
31
|
+
};
|
|
32
|
+
} else {
|
|
33
|
+
mergedContent.push(
|
|
34
|
+
child?.nodeType === "text" ? child : mergeAdjacentTextNodes(child)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { ...node, content: mergedContent };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Checks the actual rich text document (not just className) for a table block,
|
|
42
|
+
// so content authored with a table still renders through the table renderer.
|
|
43
|
+
const containsTable = (node: any): boolean => {
|
|
44
|
+
if (!node) return false;
|
|
45
|
+
if (node.nodeType === BLOCKS.TABLE) return true;
|
|
46
|
+
if (Array.isArray(node.content)) {
|
|
47
|
+
return node.content.some((child: any) => containsTable(child));
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const RichTextBasic: FC<RichTextBasicProps> = props => {
|
|
53
|
+
const {
|
|
54
|
+
options,
|
|
55
|
+
textProps,
|
|
56
|
+
isTargetBlank,
|
|
57
|
+
className,
|
|
58
|
+
linkClassName,
|
|
59
|
+
headerColor = "grey",
|
|
60
|
+
enableMaxWidth,
|
|
61
|
+
headingClass,
|
|
62
|
+
"data-testid": dataTestId,
|
|
63
|
+
} = props;
|
|
64
|
+
|
|
65
|
+
const document = mergeAdjacentTextNodes(toDocument(props));
|
|
66
|
+
const isTable = className?.includes("tableLayout") || containsTable(document);
|
|
67
|
+
|
|
68
|
+
const content = isTable
|
|
69
|
+
? renderContentfulRichTextTable(document, props.links)
|
|
70
|
+
: renderContentfulRichText(
|
|
71
|
+
document,
|
|
72
|
+
isTargetBlank,
|
|
73
|
+
className,
|
|
74
|
+
linkClassName,
|
|
75
|
+
undefined,
|
|
76
|
+
props.links
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
style={
|
|
82
|
+
{
|
|
83
|
+
...textProps?.style,
|
|
84
|
+
["--tw-prose-bullets" as string]: "white",
|
|
85
|
+
["--tw-prose-counters" as string]: "white",
|
|
86
|
+
} as React.CSSProperties
|
|
87
|
+
}
|
|
88
|
+
className={cx(
|
|
89
|
+
"last:[&_p]:mb-0",
|
|
90
|
+
"text-body1 leading-normal",
|
|
91
|
+
"[&_b]:font-bold [&_strong]:font-bold [&_i]:italic [&_em]:italic",
|
|
92
|
+
className,
|
|
93
|
+
!enableMaxWidth && "max-w-none"
|
|
94
|
+
)}
|
|
95
|
+
data-testid={dataTestId}
|
|
96
|
+
>
|
|
97
|
+
{content}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export default RichTextBasic;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import type { Node as RichTextNode } from "@contentful/rich-text-types";
|
|
4
|
+
|
|
5
|
+
export type RichTextBasicTextProps = {
|
|
6
|
+
variant?: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
style?: React.CSSProperties;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type RichTextBasicOptions = {
|
|
12
|
+
isTargetBlank?: boolean;
|
|
13
|
+
textProps?: RichTextBasicTextProps;
|
|
14
|
+
listItem?: (node: RichTextNode, children: ReactNode) => ReactNode;
|
|
15
|
+
props?: any;
|
|
16
|
+
headingClass?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type RichTextBasicProps = {
|
|
20
|
+
/** Contentful rich text document fields (nodeType/content/data), spread directly. */
|
|
21
|
+
[key: string]: any;
|
|
22
|
+
textProps?: RichTextBasicTextProps;
|
|
23
|
+
className?: string;
|
|
24
|
+
linkClassName?: string;
|
|
25
|
+
isTargetBlank?: boolean;
|
|
26
|
+
headingClass?: string;
|
|
27
|
+
options?: Omit<
|
|
28
|
+
RichTextBasicOptions,
|
|
29
|
+
"isTargetBlank" | "textProps" | "headingClass"
|
|
30
|
+
>;
|
|
31
|
+
"data-testid"?: string;
|
|
32
|
+
headerColor?: string;
|
|
33
|
+
enableMaxWidth?: boolean;
|
|
34
|
+
};
|
|
@@ -55,7 +55,7 @@ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
|
|
|
55
55
|
<div
|
|
56
56
|
className={cx(
|
|
57
57
|
!useOriginalImageAspectRatio
|
|
58
|
-
? "relative aspect-[16/9] w-full overflow-hidden rounded-image lg:h-[486px] xl:aspect-square xl:max-h-[486px] xl:w-[486px]"
|
|
58
|
+
? "relative mx-auto aspect-[16/9] w-full overflow-hidden rounded-image lg:h-[486px] xl:aspect-square xl:max-h-[486px] xl:w-[486px]"
|
|
59
59
|
: "overflow-hidden rounded-image"
|
|
60
60
|
)}
|
|
61
61
|
>
|
|
@@ -181,7 +181,7 @@ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
|
|
|
181
181
|
<>
|
|
182
182
|
<div
|
|
183
183
|
className={cx(
|
|
184
|
-
"video-section relative w-full cursor-pointer rounded-image transition-all duration-300 lg:w-[486px]",
|
|
184
|
+
"video-section relative mx-auto w-full cursor-pointer rounded-image transition-all duration-300 lg:w-[486px]",
|
|
185
185
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus",
|
|
186
186
|
!isPlaying && "hover:ring-2 hover:ring-border-focus"
|
|
187
187
|
)}
|
|
@@ -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
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
};
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { Checklist } from "@shared/components/checklist";
|
|
14
14
|
import { Link } from "@shared/components/link";
|
|
15
15
|
import { MaterialIcon } from "@shared/components/material-icon";
|
|
16
|
+
import type { IconName } from "@shared/components/material-icon";
|
|
16
17
|
import { Text } from "@shared/components/text";
|
|
17
18
|
import { VideoLink } from "@shared/components/video-link";
|
|
18
19
|
import type { VideoLinkProps } from "@shared/components/video-link";
|
|
@@ -53,7 +54,10 @@ const toVideoLinkProps = (fields: any): VideoLinkProps => ({
|
|
|
53
54
|
fields?.image?.fields?.file?.url ??
|
|
54
55
|
fields?.image?.url ??
|
|
55
56
|
fields?.image,
|
|
56
|
-
link:
|
|
57
|
+
link:
|
|
58
|
+
typeof fields?.link === "string"
|
|
59
|
+
? fields.link
|
|
60
|
+
: (fields?.link?.fields?.href ?? fields?.link?.href),
|
|
57
61
|
videoPopup: Boolean(fields?.videoPopup),
|
|
58
62
|
videoSourceType: fields?.videoSourceType,
|
|
59
63
|
videoAssetUrl:
|
|
@@ -125,7 +129,40 @@ const isExternalUrl = (url: string): boolean => {
|
|
|
125
129
|
}
|
|
126
130
|
};
|
|
127
131
|
|
|
132
|
+
// Renders "[icon_name]" tokens (e.g. "look [star] here") as MaterialIcon glyphs.
|
|
133
|
+
const renderInlineIcons = (text: string): ReactNode[] => {
|
|
134
|
+
const iconPattern = /\[([a-zA-Z0-9_-]+)\]/g;
|
|
135
|
+
const parts: ReactNode[] = [];
|
|
136
|
+
let lastIndex = 0;
|
|
137
|
+
let match: RegExpExecArray | null;
|
|
138
|
+
|
|
139
|
+
while ((match = iconPattern.exec(text)) !== null) {
|
|
140
|
+
if (match.index > lastIndex) {
|
|
141
|
+
parts.push(text.substring(lastIndex, match.index));
|
|
142
|
+
}
|
|
143
|
+
parts.push(
|
|
144
|
+
<MaterialIcon
|
|
145
|
+
key={`icon-${match.index}-${match[1]}`}
|
|
146
|
+
name={match[1] as IconName}
|
|
147
|
+
size={20}
|
|
148
|
+
className="inline-block align-middle"
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
lastIndex = match.index + match[0].length;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (lastIndex < text.length) {
|
|
155
|
+
parts.push(text.substring(lastIndex));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return parts.length > 0 ? parts : [text];
|
|
159
|
+
};
|
|
160
|
+
|
|
128
161
|
const getDefaultOptions = (links?: any): Options => ({
|
|
162
|
+
renderText: children =>
|
|
163
|
+
children.includes("[") && children.includes("]")
|
|
164
|
+
? renderInlineIcons(children)
|
|
165
|
+
: children,
|
|
129
166
|
renderMark: {
|
|
130
167
|
[MARKS.BOLD]: text => <strong className="font-black">{text}</strong>,
|
|
131
168
|
[MARKS.ITALIC]: text => <em>{text}</em>,
|
|
@@ -467,4 +504,4 @@ export function renderContentfulRichTextTable(
|
|
|
467
504
|
};
|
|
468
505
|
|
|
469
506
|
return documentToReactComponents(doc, options);
|
|
470
|
-
}
|
|
507
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -94,6 +94,13 @@ export type { SkeletonProps } from "./components/skeleton";
|
|
|
94
94
|
export { Tooltip } from "./components/tooltip";
|
|
95
95
|
export type { ToolTipProps } from "./components/tooltip";
|
|
96
96
|
|
|
97
|
+
export { RichTextBasic } from "./components/rich-text-basic";
|
|
98
|
+
export type {
|
|
99
|
+
RichTextBasicProps,
|
|
100
|
+
RichTextBasicOptions,
|
|
101
|
+
RichTextBasicTextProps,
|
|
102
|
+
} from "./components/rich-text-basic/types";
|
|
103
|
+
|
|
97
104
|
export {
|
|
98
105
|
FiberForm,
|
|
99
106
|
fiberFormAddressCollectionFormSchema,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic extension point letting a consumer attach extra DOM attributes
|
|
3
|
+
* (e.g. for visual-editor tagging, analytics, testing) to specific named
|
|
4
|
+
* regions of a component, without this library knowing what those
|
|
5
|
+
* attributes mean or where they come from.
|
|
6
|
+
*/
|
|
7
|
+
export type FieldProps<TSlot extends string> = Partial<
|
|
8
|
+
Record<TSlot, Record<string, unknown>>
|
|
9
|
+
>;
|