@windstream/react-shared-components 0.2.68 → 0.2.70
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 +13 -2
- 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 +31 -2
- package/dist/index.d.ts +35 -6
- 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/select-plan-button/index.test.tsx +27 -0
- package/src/components/select-plan-button/index.tsx +6 -1
- package/src/components/video-link/index.tsx +2 -2
- package/src/contentful/blocks/cards/product-card/index.test.tsx +36 -0
- package/src/contentful/blocks/cards/product-card/index.tsx +23 -24
- package/src/contentful/blocks/carousel/index.tsx +1 -1
- package/src/contentful/blocks/dynamic-form/field-map.tsx +1 -1
- package/src/contentful/blocks/dynamic-form/index.test.tsx +96 -0
- package/src/contentful/blocks/dynamic-form/index.tsx +24 -1
- package/src/contentful/blocks/dynamic-form/types.ts +13 -2
- package/src/contentful/blocks/navigation/index.tsx +4 -1
- package/src/contentful/blocks/primary-hero/index.test.tsx +11 -2
- package/src/contentful/blocks/primary-hero/index.tsx +3 -2
- package/src/hooks/contentful/use-contentful-rich-text.tsx +39 -2
- package/src/index.ts +7 -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
|
+
};
|
|
@@ -43,6 +43,33 @@ describe("SelectPlanButton", () => {
|
|
|
43
43
|
expect(screen.getByText("expand_circle_right")).toBeInTheDocument();
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
it("keeps the icon visible at all breakpoints by default (one-column)", () => {
|
|
47
|
+
render(<SelectPlanButton {...defaultProps} />);
|
|
48
|
+
const icon = screen.getByText("expand_circle_right");
|
|
49
|
+
expect(icon.className.split(/\s+/)).not.toContain("hidden");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("hides the icon below md in the two-column layout", () => {
|
|
53
|
+
render(<SelectPlanButton {...defaultProps} twoColumn={true} />);
|
|
54
|
+
const icon = screen.getByText("expand_circle_right");
|
|
55
|
+
expect(icon.className.split(/\s+/)).toContain("hidden");
|
|
56
|
+
expect(icon.className.split(/\s+/)).toContain("md:inline-block");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("centers content below md and spreads at md+ in the two-column layout", () => {
|
|
60
|
+
render(<SelectPlanButton {...defaultProps} twoColumn={true} />);
|
|
61
|
+
const button = screen.getByRole("button");
|
|
62
|
+
expect(button.className.split(/\s+/)).toContain("justify-center");
|
|
63
|
+
expect(button.className.split(/\s+/)).toContain("md:justify-between");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("spreads content with justify-between in the one-column layout", () => {
|
|
67
|
+
render(<SelectPlanButton {...defaultProps} />);
|
|
68
|
+
const button = screen.getByRole("button");
|
|
69
|
+
expect(button.className.split(/\s+/)).toContain("justify-between");
|
|
70
|
+
expect(button.className.split(/\s+/)).not.toContain("md:justify-between");
|
|
71
|
+
});
|
|
72
|
+
|
|
46
73
|
it("applies icon className for unselected state", () => {
|
|
47
74
|
render(
|
|
48
75
|
<SelectPlanButton
|
|
@@ -40,7 +40,11 @@ export const SelectPlanButton: React.FC<SelectPlanButtonProps> = ({
|
|
|
40
40
|
<>
|
|
41
41
|
<Button
|
|
42
42
|
onClick={handleSelect}
|
|
43
|
-
className={cx(
|
|
43
|
+
className={cx(
|
|
44
|
+
className,
|
|
45
|
+
"w-full px-3 md:px-5",
|
|
46
|
+
twoColumn ? "justify-center md:justify-between" : "justify-between"
|
|
47
|
+
)}
|
|
44
48
|
data-track-element-name="speed_plan_select_button"
|
|
45
49
|
data-track-click-text={`Select plan speed ${speed}`}
|
|
46
50
|
data-track-element-clicked="speed_plan_card"
|
|
@@ -52,6 +56,7 @@ export const SelectPlanButton: React.FC<SelectPlanButtonProps> = ({
|
|
|
52
56
|
className={cx(
|
|
53
57
|
iconClassName,
|
|
54
58
|
"!text-[24px] md:!text-[40px]",
|
|
59
|
+
twoColumn && "hidden md:inline-block",
|
|
55
60
|
isSelected ? "text-icon" : "text-icon-inverse"
|
|
56
61
|
)}
|
|
57
62
|
name="expand_circle_right"
|
|
@@ -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
|
)}
|
|
@@ -241,6 +241,19 @@ describe("ProductCard", () => {
|
|
|
241
241
|
render(<ProductCard {...defaultProps} />);
|
|
242
242
|
expect(screen.queryByText("Free router")).not.toBeInTheDocument();
|
|
243
243
|
});
|
|
244
|
+
|
|
245
|
+
it("renders the piped dollar value with the heavier label3 weight", () => {
|
|
246
|
+
const giftBadge = [
|
|
247
|
+
{ title: "|$100| prepaid Mastercard®", icon: "/card.png" },
|
|
248
|
+
] as any;
|
|
249
|
+
render(<ProductCard {...defaultProps} giftBadge={giftBadge} />);
|
|
250
|
+
const dollar = screen.getByText("$100");
|
|
251
|
+
expect(dollar).toHaveClass("label3");
|
|
252
|
+
// The surrounding text keeps the parent body3 weight (not label3).
|
|
253
|
+
expect(
|
|
254
|
+
screen.getByText(/prepaid Mastercard®/)
|
|
255
|
+
).not.toHaveClass("label3");
|
|
256
|
+
});
|
|
244
257
|
});
|
|
245
258
|
|
|
246
259
|
describe("Inner Badge", () => {
|
|
@@ -257,6 +270,29 @@ describe("ProductCard", () => {
|
|
|
257
270
|
expect(screen.getByText("Normal")).toBeInTheDocument();
|
|
258
271
|
});
|
|
259
272
|
|
|
273
|
+
it("renders pipe-separated inner badge parts spaced via the gap-1 container", () => {
|
|
274
|
+
const innerBadge = {
|
|
275
|
+
badgeText: "2-year|price guarantee",
|
|
276
|
+
badgeIcon: "/icon.png",
|
|
277
|
+
};
|
|
278
|
+
render(
|
|
279
|
+
<ProductCard
|
|
280
|
+
{...defaultProps}
|
|
281
|
+
isExpanded={true}
|
|
282
|
+
innerBadge={innerBadge}
|
|
283
|
+
/>
|
|
284
|
+
);
|
|
285
|
+
const first = screen.getByText("2-year");
|
|
286
|
+
const second = screen.getByText("price guarantee");
|
|
287
|
+
expect(first).toBeInTheDocument();
|
|
288
|
+
expect(second).toBeInTheDocument();
|
|
289
|
+
// Spacing between parts comes from the parent flex `gap-1` (matches
|
|
290
|
+
// residential), not an inline space character.
|
|
291
|
+
expect(first.parentElement?.className).toContain("gap-1");
|
|
292
|
+
expect(first).toHaveClass("label3");
|
|
293
|
+
expect(second).toHaveClass("body3");
|
|
294
|
+
});
|
|
295
|
+
|
|
260
296
|
it("renders inner badge without pipe as bold", () => {
|
|
261
297
|
const innerBadge = { badgeText: "Simple Badge", badgeIcon: "/icon.png" };
|
|
262
298
|
render(
|
|
@@ -11,12 +11,17 @@ import { SelectPlanButton } from "@shared/components/select-plan-button";
|
|
|
11
11
|
import { Text } from "@shared/components/text";
|
|
12
12
|
import { cx } from "@shared/utils";
|
|
13
13
|
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Renders text where `|...|`-wrapped segments (e.g. the dollar value in a gift
|
|
16
|
+
* badge) use the heavier `label3` weight (900), while the surrounding text keeps
|
|
17
|
+
* the parent `body3` weight (500) — matching residential's gift-reward styling.
|
|
18
|
+
* Pipes are stripped.
|
|
19
|
+
*/
|
|
15
20
|
const renderPipeBold = (value: React.ReactNode): React.ReactNode => {
|
|
16
21
|
if (typeof value !== "string" || !value.includes("|")) return value;
|
|
17
22
|
return value.split("|").map((segment, index) =>
|
|
18
23
|
index % 2 === 1 ? (
|
|
19
|
-
<span key={index} className="
|
|
24
|
+
<span key={index} className="label3">
|
|
20
25
|
{segment}
|
|
21
26
|
</span>
|
|
22
27
|
) : (
|
|
@@ -72,7 +77,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|
|
72
77
|
const priceIntegerClass = twoColumn
|
|
73
78
|
? "heading2 md:subheading5"
|
|
74
79
|
: "subheading5";
|
|
75
|
-
const priceUnitClass = twoColumn ? "label3" : "label1";
|
|
80
|
+
const priceUnitClass = twoColumn ? "label3 md:label1" : "label1";
|
|
76
81
|
|
|
77
82
|
return (
|
|
78
83
|
<article className="relative flex h-full w-full max-w-[392px] flex-col">
|
|
@@ -304,27 +309,21 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|
|
304
309
|
height={24}
|
|
305
310
|
className="ml-1 h-auto pr-2"
|
|
306
311
|
/>
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
) : (
|
|
323
|
-
<Text className="font-bold">
|
|
324
|
-
{innerBadge.badgeText}
|
|
325
|
-
</Text>
|
|
326
|
-
)}
|
|
327
|
-
</span>
|
|
312
|
+
{(innerBadge.badgeText.includes("|")
|
|
313
|
+
? innerBadge.badgeText.split("|")
|
|
314
|
+
: [innerBadge.badgeText]
|
|
315
|
+
).map((badge, index) => (
|
|
316
|
+
<Text
|
|
317
|
+
key={index}
|
|
318
|
+
as="span"
|
|
319
|
+
className={cx(
|
|
320
|
+
index === 0 ? "label3" : "body3",
|
|
321
|
+
isDark ? "text-text-inverse" : "text-text"
|
|
322
|
+
)}
|
|
323
|
+
>
|
|
324
|
+
{badge.trim()}
|
|
325
|
+
</Text>
|
|
326
|
+
))}
|
|
328
327
|
</div>
|
|
329
328
|
) : null}
|
|
330
329
|
</div>
|
|
@@ -75,7 +75,7 @@ export const Carousel: React.FC<CarouselProps> = ({
|
|
|
75
75
|
{disclaimerText && (
|
|
76
76
|
<Text
|
|
77
77
|
as="div"
|
|
78
|
-
className="
|
|
78
|
+
className="body3 mt-8 px-4 text-center text-text-secondary"
|
|
79
79
|
>
|
|
80
80
|
{disclaimerText}
|
|
81
81
|
</Text>
|
|
@@ -119,6 +119,102 @@ describe("DynamicForm", () => {
|
|
|
119
119
|
const submit = () =>
|
|
120
120
|
fireEvent.click(screen.getByRole("button", { name: /submit form/i }));
|
|
121
121
|
|
|
122
|
+
describe("non-prod email rerouting", () => {
|
|
123
|
+
const fillAndSubmit = () => {
|
|
124
|
+
fireEvent.change(screen.getByLabelText("First Name"), {
|
|
125
|
+
target: { value: "Jane", name: "firstName" },
|
|
126
|
+
});
|
|
127
|
+
fireEvent.click(screen.getByRole("checkbox"));
|
|
128
|
+
fireEvent.click(screen.getByRole("button", { name: /submit form/i }));
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
it("reroutes email to multiple comma-separated test recipients when isProduction is false", async () => {
|
|
132
|
+
const onEmailSubmit = jest.fn().mockResolvedValue(undefined);
|
|
133
|
+
render(
|
|
134
|
+
<DynamicForm
|
|
135
|
+
fields={fields}
|
|
136
|
+
formSubmitOptions={["email"]}
|
|
137
|
+
emailTemplateName="welcome"
|
|
138
|
+
toEmail="real@customer.com"
|
|
139
|
+
testEmail=" a@test.com , b@test.com "
|
|
140
|
+
isProduction={false}
|
|
141
|
+
onEmailSubmit={onEmailSubmit}
|
|
142
|
+
successMessage={<div>Done</div>}
|
|
143
|
+
/>
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
fillAndSubmit();
|
|
147
|
+
|
|
148
|
+
await waitFor(() => expect(onEmailSubmit).toHaveBeenCalledTimes(1));
|
|
149
|
+
const body = onEmailSubmit.mock.calls[0][0];
|
|
150
|
+
expect(body.toEmail).toBe("a@test.com,b@test.com");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("skips the email action when isProduction is false and testEmail is empty", async () => {
|
|
154
|
+
const onEmailSubmit = jest.fn().mockResolvedValue(undefined);
|
|
155
|
+
render(
|
|
156
|
+
<DynamicForm
|
|
157
|
+
fields={fields}
|
|
158
|
+
formSubmitOptions={["email"]}
|
|
159
|
+
emailTemplateName="welcome"
|
|
160
|
+
toEmail="real@customer.com"
|
|
161
|
+
testEmail=""
|
|
162
|
+
isProduction={false}
|
|
163
|
+
onEmailSubmit={onEmailSubmit}
|
|
164
|
+
successMessage={<div>Done</div>}
|
|
165
|
+
/>
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
fillAndSubmit();
|
|
169
|
+
|
|
170
|
+
await waitFor(() => expect(screen.getByText("Done")).toBeInTheDocument());
|
|
171
|
+
expect(onEmailSubmit).not.toHaveBeenCalled();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("does not reroute when isProduction is not set (preserves prior behavior)", async () => {
|
|
175
|
+
const onEmailSubmit = jest.fn().mockResolvedValue(undefined);
|
|
176
|
+
render(
|
|
177
|
+
<DynamicForm
|
|
178
|
+
fields={fields}
|
|
179
|
+
formSubmitOptions={["email"]}
|
|
180
|
+
emailTemplateName="welcome"
|
|
181
|
+
toEmail="real@customer.com"
|
|
182
|
+
testEmail="test@test.com"
|
|
183
|
+
onEmailSubmit={onEmailSubmit}
|
|
184
|
+
successMessage={<div>Done</div>}
|
|
185
|
+
/>
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
fillAndSubmit();
|
|
189
|
+
|
|
190
|
+
await waitFor(() => expect(onEmailSubmit).toHaveBeenCalledTimes(1));
|
|
191
|
+
const body = onEmailSubmit.mock.calls[0][0];
|
|
192
|
+
expect(body.toEmail).toBe("real@customer.com");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("does not reroute when isProduction is true", async () => {
|
|
196
|
+
const onEmailSubmit = jest.fn().mockResolvedValue(undefined);
|
|
197
|
+
render(
|
|
198
|
+
<DynamicForm
|
|
199
|
+
fields={fields}
|
|
200
|
+
formSubmitOptions={["email"]}
|
|
201
|
+
emailTemplateName="welcome"
|
|
202
|
+
toEmail="real@customer.com"
|
|
203
|
+
testEmail="test@test.com"
|
|
204
|
+
isProduction={true}
|
|
205
|
+
onEmailSubmit={onEmailSubmit}
|
|
206
|
+
successMessage={<div>Done</div>}
|
|
207
|
+
/>
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
fillAndSubmit();
|
|
211
|
+
|
|
212
|
+
await waitFor(() => expect(onEmailSubmit).toHaveBeenCalledTimes(1));
|
|
213
|
+
const body = onEmailSubmit.mock.calls[0][0];
|
|
214
|
+
expect(body.toEmail).toBe("real@customer.com");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
122
218
|
it("applies TN/IP preset validation before submitting", async () => {
|
|
123
219
|
const onEmailSubmit = jest.fn().mockResolvedValue(undefined);
|
|
124
220
|
const presetFields: DynamicFormItem[] = [
|
|
@@ -108,6 +108,8 @@ export const DynamicForm: React.FC<DynamicFormProps> = props => {
|
|
|
108
108
|
emailSubject,
|
|
109
109
|
emailTemplateName,
|
|
110
110
|
toEmail,
|
|
111
|
+
testEmail,
|
|
112
|
+
isProduction,
|
|
111
113
|
segmentEventId,
|
|
112
114
|
onEmailSubmit,
|
|
113
115
|
onSmtpSubmit,
|
|
@@ -380,7 +382,28 @@ export const DynamicForm: React.FC<DynamicFormProps> = props => {
|
|
|
380
382
|
|
|
381
383
|
onTrack?.(segmentEventId, payload);
|
|
382
384
|
|
|
383
|
-
|
|
385
|
+
// Non-prod safety: when the app flags a non-production environment,
|
|
386
|
+
// reroute the `email` action to the configured test recipient(s) — which
|
|
387
|
+
// may be multiple comma-separated addresses — or skip `email` entirely
|
|
388
|
+
// when no test recipient is set. Gated on an explicit `isProduction ===
|
|
389
|
+
// false` so prod/undefined behavior is unchanged.
|
|
390
|
+
let effectiveActions = payload.formSubmitOptions || [];
|
|
391
|
+
if (isProduction === false && effectiveActions.includes("email")) {
|
|
392
|
+
const normalizedTestEmail = (testEmail || "")
|
|
393
|
+
.split(",")
|
|
394
|
+
.map(email => email.trim())
|
|
395
|
+
.filter(Boolean)
|
|
396
|
+
.join(",");
|
|
397
|
+
if (normalizedTestEmail) {
|
|
398
|
+
payload.toEmail = normalizedTestEmail;
|
|
399
|
+
} else {
|
|
400
|
+
effectiveActions = effectiveActions.filter(
|
|
401
|
+
action => action !== "email"
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const actions = effectiveActions;
|
|
384
407
|
const hasPost = actions.includes("post");
|
|
385
408
|
const serverActions = actions.filter(a => a !== "post");
|
|
386
409
|
|