@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.
Files changed (35) hide show
  1. package/dist/contentful/index.d.ts +13 -2
  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 +2 -2
  5. package/dist/contentful/index.js.map +1 -1
  6. package/dist/core.d.ts +31 -2
  7. package/dist/index.d.ts +35 -6
  8. package/dist/index.esm.js +8 -8
  9. package/dist/index.esm.js.map +1 -1
  10. package/dist/index.js +8 -8
  11. package/dist/index.js.map +1 -1
  12. package/dist/styles.css +1 -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/rich-text-basic/index.test.tsx +172 -0
  19. package/src/components/rich-text-basic/index.tsx +102 -0
  20. package/src/components/rich-text-basic/types.ts +34 -0
  21. package/src/components/select-plan-button/index.test.tsx +27 -0
  22. package/src/components/select-plan-button/index.tsx +6 -1
  23. package/src/components/video-link/index.tsx +2 -2
  24. package/src/contentful/blocks/cards/product-card/index.test.tsx +36 -0
  25. package/src/contentful/blocks/cards/product-card/index.tsx +23 -24
  26. package/src/contentful/blocks/carousel/index.tsx +1 -1
  27. package/src/contentful/blocks/dynamic-form/field-map.tsx +1 -1
  28. package/src/contentful/blocks/dynamic-form/index.test.tsx +96 -0
  29. package/src/contentful/blocks/dynamic-form/index.tsx +24 -1
  30. package/src/contentful/blocks/dynamic-form/types.ts +13 -2
  31. package/src/contentful/blocks/navigation/index.tsx +4 -1
  32. package/src/contentful/blocks/primary-hero/index.test.tsx +11 -2
  33. package/src/contentful/blocks/primary-hero/index.tsx +3 -2
  34. package/src/hooks/contentful/use-contentful-rich-text.tsx +39 -2
  35. package/src/index.ts +7 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windstream/react-shared-components",
3
- "version": "0.2.68",
3
+ "version": "0.2.70",
4
4
  "type": "module",
5
5
  "description": "Shared React components for Kinetic applications",
6
6
  "main": "dist/index.js",
@@ -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(className, "w-full justify-between px-3 md:px-5")}
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
- /** Renders text where `|...|`-wrapped segments are bold (pipes are stripped). */
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="font-bold">
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
- <span
308
- className={cx(
309
- "body3",
310
- isDark ? "text-text-inverse" : "text-text"
311
- )}
312
- >
313
- {innerBadge.badgeText.includes("|") ? (
314
- <>
315
- <span className="font-bold">
316
- {innerBadge.badgeText.split("|")[0]}
317
- </span>
318
- <span className="font-normal">
319
- {innerBadge.badgeText.split("|")[1]}
320
- </span>
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="footnote mt-8 px-4 text-center text-text-secondary"
78
+ className="body3 mt-8 px-4 text-center text-text-secondary"
79
79
  >
80
80
  {disclaimerText}
81
81
  </Text>
@@ -308,7 +308,7 @@ const RadioButtonInputField: React.FC<FieldRenderProps> = args => {
308
308
  hasError={hasError}
309
309
  onChange={handleInputChange}
310
310
  required={requiredField}
311
- labelClassName="footnote"
311
+ labelClassName="footnote text-left"
312
312
  />
313
313
  </div>
314
314
  ))}
@@ -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
- const actions = payload.formSubmitOptions || [];
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