@windstream/react-shared-components 0.2.83 → 0.2.85
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.esm.js +1 -1
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +1 -1
- package/dist/contentful/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/utils/index.d.ts +16 -2
- 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/contentful/blocks/email-input-block/index.test.tsx +19 -0
- package/src/contentful/blocks/email-input-block/index.tsx +4 -4
- package/src/utils/index.test.ts +5 -0
- package/src/utils/index.ts +10 -0
- package/src/utils/replace-tokens.test.ts +197 -0
- package/src/utils/replace-tokens.ts +89 -0
package/package.json
CHANGED
|
@@ -33,6 +33,8 @@ describe("EmailInputBlock", () => {
|
|
|
33
33
|
const title = screen.getByText("Join us");
|
|
34
34
|
expect(title).toBeInTheDocument();
|
|
35
35
|
expect(title.tagName).toBe("H2");
|
|
36
|
+
expect(title).toHaveClass("md:text-heading2");
|
|
37
|
+
expect(title).not.toHaveClass("md:text-heading1");
|
|
36
38
|
});
|
|
37
39
|
|
|
38
40
|
it("does not render title when not provided", () => {
|
|
@@ -45,6 +47,8 @@ describe("EmailInputBlock", () => {
|
|
|
45
47
|
const subtitle = screen.getByText("Stay updated");
|
|
46
48
|
expect(subtitle).toBeInTheDocument();
|
|
47
49
|
expect(subtitle.tagName).toBe("H3");
|
|
50
|
+
expect(subtitle).toHaveClass("subheading3");
|
|
51
|
+
expect(subtitle).not.toHaveClass("text-heading1");
|
|
48
52
|
});
|
|
49
53
|
|
|
50
54
|
it("does not render subTitle when not provided", () => {
|
|
@@ -177,6 +181,21 @@ describe("EmailInputBlock", () => {
|
|
|
177
181
|
).toBeInTheDocument();
|
|
178
182
|
});
|
|
179
183
|
|
|
184
|
+
it("uses min-height instead of fixed height on the container", () => {
|
|
185
|
+
const { container } = render(<EmailInputBlock {...defaultProps} />);
|
|
186
|
+
const card = container.querySelector(".bg-bg-fill-brand");
|
|
187
|
+
expect(card).toHaveClass("min-h-[344px]", "md:min-h-[367px]");
|
|
188
|
+
expect(card).not.toHaveClass("!h-[344px]");
|
|
189
|
+
expect(card).not.toHaveClass("md:!h-[367px]");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("keeps a 40px side gutter around the card from the md breakpoint", () => {
|
|
193
|
+
const { container } = render(<EmailInputBlock {...defaultProps} />);
|
|
194
|
+
const wrapper = container.querySelector("section");
|
|
195
|
+
expect(wrapper).toHaveClass("px-4", "md:px-10");
|
|
196
|
+
expect(wrapper).not.toHaveClass("md:px-0");
|
|
197
|
+
});
|
|
198
|
+
|
|
180
199
|
it("applies blue theme background class", () => {
|
|
181
200
|
const { container } = render(
|
|
182
201
|
<EmailInputBlock {...defaultProps} themeColor="blue" />
|
|
@@ -99,9 +99,9 @@ export const EmailInputBlock: React.FC<EmailInputBlockProps> = props => {
|
|
|
99
99
|
};
|
|
100
100
|
|
|
101
101
|
return (
|
|
102
|
-
<section id={anchorId} className={`w-full px-4 ${paddingClass} md:px-
|
|
102
|
+
<section id={anchorId} className={`w-full px-4 ${paddingClass} md:px-10 ${outerBgClass}`}>
|
|
103
103
|
<div
|
|
104
|
-
className={`mx-auto max-w-[1200px] overflow-hidden !rounded-[20px] md:rounded-[20px] flex flex-col items-start !px-5 !pt-6 !pb-10 md:!p-10 md:!pt-12
|
|
104
|
+
className={`mx-auto max-w-[1200px] overflow-hidden !rounded-[20px] md:rounded-[20px] flex flex-col items-start !px-5 !pt-6 !pb-10 md:!p-10 md:!pt-12 min-h-[344px] md:!w-auto md:min-h-[367px] relative ${containerBgClass}`}
|
|
105
105
|
>
|
|
106
106
|
{showPinWheel && (
|
|
107
107
|
<div className="absolute right-[-50px] top-[-30px] hidden h-[420px] w-[420px] pointer-events-none lg:block" aria-hidden="true">
|
|
@@ -111,10 +111,10 @@ export const EmailInputBlock: React.FC<EmailInputBlockProps> = props => {
|
|
|
111
111
|
<div className="w-full">
|
|
112
112
|
<div className="lg:w-[548px]">
|
|
113
113
|
{title && (
|
|
114
|
-
<h2 className={`mb-3 text-[2.25rem] leading-[2.5rem] font-black md:text-
|
|
114
|
+
<h2 className={`mb-3 text-[2.25rem] leading-[2.5rem] font-black md:text-heading2 md:font-black ${textColor}`}>{title}</h2>
|
|
115
115
|
)}
|
|
116
116
|
{subTitle && (
|
|
117
|
-
<h3 className={`mb-3
|
|
117
|
+
<h3 className={`subheading3 mb-3 font-black ${textColor}`}>
|
|
118
118
|
{subTitle}
|
|
119
119
|
</h3>
|
|
120
120
|
)}
|
package/src/utils/index.test.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
removeUTMs,
|
|
18
18
|
renderContentfulRichText,
|
|
19
19
|
renderContentfulRichTextTable,
|
|
20
|
+
replaceTokens,
|
|
20
21
|
setCookie,
|
|
21
22
|
setUTMs,
|
|
22
23
|
SpeedCardBg,
|
|
@@ -92,6 +93,10 @@ describe("utils barrel exports", () => {
|
|
|
92
93
|
expect(typeof toDocument).toBe("function");
|
|
93
94
|
});
|
|
94
95
|
|
|
96
|
+
it("exports replaceTokens", () => {
|
|
97
|
+
expect(typeof replaceTokens).toBe("function");
|
|
98
|
+
});
|
|
99
|
+
|
|
95
100
|
it("exports renderContentfulRichText", () => {
|
|
96
101
|
expect(typeof renderContentfulRichText).toBe("function");
|
|
97
102
|
});
|
package/src/utils/index.ts
CHANGED
|
@@ -32,6 +32,16 @@ export {
|
|
|
32
32
|
redirectToWifiGrantUrl,
|
|
33
33
|
} from "./wifi-grant-redirect";
|
|
34
34
|
|
|
35
|
+
// Token replacement — framework-agnostic string / rich-text interpolation.
|
|
36
|
+
// No dependency on Contentful or any particular CMS/tool.
|
|
37
|
+
export { replaceTokens } from "./replace-tokens";
|
|
38
|
+
export type {
|
|
39
|
+
TokenEntry,
|
|
40
|
+
TokenSource,
|
|
41
|
+
RichTextDocument,
|
|
42
|
+
ReplaceTokensResult,
|
|
43
|
+
} from "./replace-tokens";
|
|
44
|
+
|
|
35
45
|
// Contentful utilities (server-safe, no "use client")
|
|
36
46
|
export { toDocument } from "./contentful/to-document";
|
|
37
47
|
export {
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import {
|
|
2
|
+
replaceTokens,
|
|
3
|
+
type RichTextDocument,
|
|
4
|
+
type TokenEntry,
|
|
5
|
+
type TokenSource,
|
|
6
|
+
} from "./replace-tokens";
|
|
7
|
+
|
|
8
|
+
// Generic rich-text node types. This utility is framework-agnostic and
|
|
9
|
+
// intentionally has no dependency on any CMS or tool.
|
|
10
|
+
const DOCUMENT = "document";
|
|
11
|
+
const PARAGRAPH = "paragraph";
|
|
12
|
+
const HR = "hr";
|
|
13
|
+
|
|
14
|
+
const tokens: TokenEntry[] = [
|
|
15
|
+
{ token: "price_tier1_300mbps", value: "44.99", promo: "39.99" },
|
|
16
|
+
{ token: "price_value_only", value: "50.00" },
|
|
17
|
+
{ token: "reward_50", value: "50" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const makeDocument = (value: string): RichTextDocument => ({
|
|
21
|
+
nodeType: DOCUMENT,
|
|
22
|
+
content: [
|
|
23
|
+
{
|
|
24
|
+
nodeType: PARAGRAPH,
|
|
25
|
+
content: [{ nodeType: "text", value, marks: [], data: {} }],
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("replaceTokens - strings", () => {
|
|
31
|
+
it("resolves a price placeholder to its promo value", () => {
|
|
32
|
+
expect(replaceTokens("Only ${price_tier1_300mbps}/mo", tokens)).toBe(
|
|
33
|
+
"Only 39.99/mo"
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("falls back to value when the entry has no promo (reward tokens)", () => {
|
|
38
|
+
expect(replaceTokens("Get a $${reward_50} reward", tokens)).toBe(
|
|
39
|
+
"Get a $50 reward"
|
|
40
|
+
);
|
|
41
|
+
expect(replaceTokens("${price_value_only}", tokens)).toBe("50.00");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("replaces multiple placeholders in one string", () => {
|
|
45
|
+
expect(
|
|
46
|
+
replaceTokens("${price_tier1_300mbps} then ${reward_50}", tokens)
|
|
47
|
+
).toBe("39.99 then 50");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("leaves unknown placeholders intact", () => {
|
|
51
|
+
expect(replaceTokens("was ${not_a_token} now", tokens)).toBe(
|
|
52
|
+
"was ${not_a_token} now"
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns the string unchanged when there are no placeholders", () => {
|
|
57
|
+
expect(replaceTokens("plain text", tokens)).toBe("plain text");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("accepts a JSON string source", () => {
|
|
61
|
+
expect(
|
|
62
|
+
replaceTokens("${price_tier1_300mbps}", JSON.stringify(tokens))
|
|
63
|
+
).toBe("39.99");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("replaceTokens - empty / invalid sources", () => {
|
|
68
|
+
it.each<[string, TokenSource]>([
|
|
69
|
+
["undefined", undefined],
|
|
70
|
+
["null", null],
|
|
71
|
+
["empty array", []],
|
|
72
|
+
["blank string", " "],
|
|
73
|
+
["malformed JSON", "not json"],
|
|
74
|
+
["non-array JSON", JSON.stringify({ token: "x" })],
|
|
75
|
+
])("returns input unchanged for %s source", (_label, source) => {
|
|
76
|
+
expect(replaceTokens("${price_tier1_300mbps}", source)).toBe(
|
|
77
|
+
"${price_tier1_300mbps}"
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("ignores entries without a usable token key or value", () => {
|
|
82
|
+
const source: TokenEntry[] = [
|
|
83
|
+
{ value: "1" },
|
|
84
|
+
{ token: "", value: "2" },
|
|
85
|
+
{ token: "no_values" },
|
|
86
|
+
];
|
|
87
|
+
expect(replaceTokens("${no_values}", source)).toBe("${no_values}");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("replaceTokens - malformed values and ReDoS safety", () => {
|
|
92
|
+
it("ignores entries whose resolved value is not a string (never throws)", () => {
|
|
93
|
+
const malformed = [
|
|
94
|
+
{ token: "reward_50", value: { toString: null } },
|
|
95
|
+
{ token: "price_x", promo: 123 },
|
|
96
|
+
] as unknown as TokenEntry[];
|
|
97
|
+
|
|
98
|
+
expect(replaceTokens("${reward_50} / ${price_x}", malformed)).toBe(
|
|
99
|
+
"${reward_50} / ${price_x}"
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("falls back to value when promo is not a string", () => {
|
|
104
|
+
const entries = [
|
|
105
|
+
{ token: "reward_50", promo: { bad: true }, value: "50" },
|
|
106
|
+
] as unknown as TokenEntry[];
|
|
107
|
+
|
|
108
|
+
expect(replaceTokens("$${reward_50}", entries)).toBe("$50");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("resolves pathological ${-heavy input quickly without backtracking", () => {
|
|
112
|
+
const evil = "${".repeat(50000);
|
|
113
|
+
const start = Date.now();
|
|
114
|
+
const result = replaceTokens(evil, tokens);
|
|
115
|
+
|
|
116
|
+
expect(result).toBe(evil);
|
|
117
|
+
expect(Date.now() - start).toBeLessThan(2000);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("widens a replaced string result to string, not the literal input type", () => {
|
|
121
|
+
const result = replaceTokens("${reward_50}", tokens);
|
|
122
|
+
// Compiles only because `result` is `string`; a preserved literal type
|
|
123
|
+
// would make this comparison a TypeScript no-overlap error.
|
|
124
|
+
expect(result === "50").toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("replaceTokens - empty / non-tokenizable input", () => {
|
|
129
|
+
it.each([
|
|
130
|
+
["empty string", ""],
|
|
131
|
+
["null", null],
|
|
132
|
+
["undefined", undefined],
|
|
133
|
+
])("returns %s input unchanged", (_label, input) => {
|
|
134
|
+
expect(replaceTokens(input as any, tokens)).toBe(input);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("returns a non-document object unchanged", () => {
|
|
138
|
+
const obj = { foo: "bar" } as any;
|
|
139
|
+
expect(replaceTokens(obj, tokens)).toBe(obj);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("replaceTokens - rich text", () => {
|
|
144
|
+
it("replaces placeholders inside nested text nodes", () => {
|
|
145
|
+
const doc = makeDocument("Only ${price_tier1_300mbps}/mo");
|
|
146
|
+
const result = replaceTokens(doc, tokens) as RichTextDocument;
|
|
147
|
+
|
|
148
|
+
expect((result.content[0] as any).content[0].value).toBe("Only 39.99/mo");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("does not mutate the original document", () => {
|
|
152
|
+
const doc = makeDocument("${price_tier1_300mbps}");
|
|
153
|
+
replaceTokens(doc, tokens);
|
|
154
|
+
|
|
155
|
+
expect((doc.content[0] as any).content[0].value).toBe(
|
|
156
|
+
"${price_tier1_300mbps}"
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("preserves marks and data on text nodes", () => {
|
|
161
|
+
const doc = makeDocument("${reward_50}");
|
|
162
|
+
(doc.content[0] as any).content[0].marks = [{ type: "bold" }];
|
|
163
|
+
const result = replaceTokens(doc, tokens) as RichTextDocument;
|
|
164
|
+
|
|
165
|
+
const textNode = (result.content[0] as any).content[0];
|
|
166
|
+
expect(textNode.value).toBe("50");
|
|
167
|
+
expect(textNode.marks).toEqual([{ type: "bold" }]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("returns the document unchanged when no tokens match", () => {
|
|
171
|
+
const doc = makeDocument("nothing to replace");
|
|
172
|
+
const result = replaceTokens(doc, tokens) as RichTextDocument;
|
|
173
|
+
|
|
174
|
+
expect((result.content[0] as any).content[0].value).toBe(
|
|
175
|
+
"nothing to replace"
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("leaves non-text leaf nodes (e.g. hr) untouched", () => {
|
|
180
|
+
const doc: RichTextDocument = {
|
|
181
|
+
nodeType: DOCUMENT,
|
|
182
|
+
content: [
|
|
183
|
+
{ nodeType: HR, content: [] },
|
|
184
|
+
{
|
|
185
|
+
nodeType: PARAGRAPH,
|
|
186
|
+
content: [
|
|
187
|
+
{ nodeType: "text", value: "${reward_50}", marks: [], data: {} },
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
const result = replaceTokens(doc, tokens) as RichTextDocument;
|
|
193
|
+
|
|
194
|
+
expect((result.content[0] as any).nodeType).toBe(HR);
|
|
195
|
+
expect((result.content[1] as any).content[0].value).toBe("50");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export type TokenEntry = {
|
|
2
|
+
token?: string;
|
|
3
|
+
value?: string;
|
|
4
|
+
promo?: string;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type TokenSource = TokenEntry[] | string | null | undefined;
|
|
9
|
+
|
|
10
|
+
export type RichTextDocument = {
|
|
11
|
+
nodeType: string;
|
|
12
|
+
content: unknown[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ReplaceTokensResult<T> = T extends string ? string : T;
|
|
16
|
+
|
|
17
|
+
const TOKEN_PATTERN = /\$\{([^${}]+)\}/g;
|
|
18
|
+
|
|
19
|
+
const normalizeEntries = (tokens: TokenSource): TokenEntry[] => {
|
|
20
|
+
if (Array.isArray(tokens)) return tokens;
|
|
21
|
+
if (typeof tokens === "string" && tokens.trim()) {
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(tokens);
|
|
24
|
+
return Array.isArray(parsed) ? (parsed as TokenEntry[]) : [];
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return [];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const buildTokenMap = (tokens: TokenSource): Record<string, string> =>
|
|
33
|
+
normalizeEntries(tokens).reduce<Record<string, string>>((acc, entry) => {
|
|
34
|
+
if (entry && typeof entry.token === "string" && entry.token) {
|
|
35
|
+
const promo = typeof entry.promo === "string" ? entry.promo : undefined;
|
|
36
|
+
const value = typeof entry.value === "string" ? entry.value : undefined;
|
|
37
|
+
const resolved = promo ?? value;
|
|
38
|
+
if (resolved !== undefined) acc[entry.token] = resolved;
|
|
39
|
+
}
|
|
40
|
+
return acc;
|
|
41
|
+
}, {});
|
|
42
|
+
|
|
43
|
+
const replaceInString = (input: string, map: Record<string, string>): string =>
|
|
44
|
+
input.replace(TOKEN_PATTERN, (match, token) =>
|
|
45
|
+
Object.prototype.hasOwnProperty.call(map, token) ? map[token] : match
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const isRichTextDocument = (value: unknown): value is RichTextDocument =>
|
|
49
|
+
!!value &&
|
|
50
|
+
typeof value === "object" &&
|
|
51
|
+
"nodeType" in (value as Record<string, unknown>) &&
|
|
52
|
+
"content" in (value as Record<string, unknown>);
|
|
53
|
+
|
|
54
|
+
const replaceInNode = <T>(node: T, map: Record<string, string>): T => {
|
|
55
|
+
if (!node || typeof node !== "object") return node;
|
|
56
|
+
const current = node as Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
if (current.nodeType === "text" && typeof current.value === "string") {
|
|
59
|
+
return { ...current, value: replaceInString(current.value, map) } as T;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (Array.isArray(current.content)) {
|
|
63
|
+
return {
|
|
64
|
+
...current,
|
|
65
|
+
content: current.content.map(child => replaceInNode(child, map)),
|
|
66
|
+
} as T;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return node;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export function replaceTokens<
|
|
73
|
+
T extends string | RichTextDocument | null | undefined,
|
|
74
|
+
>(input: T, tokens: TokenSource): ReplaceTokensResult<T> {
|
|
75
|
+
if (input == null || input === "") return input as ReplaceTokensResult<T>;
|
|
76
|
+
|
|
77
|
+
const map = buildTokenMap(tokens);
|
|
78
|
+
if (Object.keys(map).length === 0) return input as ReplaceTokensResult<T>;
|
|
79
|
+
|
|
80
|
+
if (typeof input === "string") {
|
|
81
|
+
return replaceInString(input, map) as ReplaceTokensResult<T>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (isRichTextDocument(input)) {
|
|
85
|
+
return replaceInNode(input, map) as ReplaceTokensResult<T>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return input as ReplaceTokensResult<T>;
|
|
89
|
+
}
|