@windstream/react-shared-components 0.2.82 → 0.2.84
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 +4 -0
- package/dist/contentful/index.esm.js +1 -1
- 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 +2 -2
- package/dist/index.d.ts +3 -3
- 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/award-marquee/AwardMarquee.stories.tsx +45 -1
- package/src/contentful/blocks/award-marquee/index.test.tsx +203 -5
- package/src/contentful/blocks/award-marquee/index.tsx +77 -26
- package/src/contentful/blocks/award-marquee/types.ts +6 -0
- 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
|
@@ -13,7 +13,7 @@ const meta: Meta<typeof AwardMarquee> = {
|
|
|
13
13
|
page: DocsPage,
|
|
14
14
|
description: {
|
|
15
15
|
component:
|
|
16
|
-
"Server-rendered marquee of award badges. Optional `heading` and `footerText` display gold text above and below the awards, with divider lines around the heading. Animates when `scroll` is set; with 3 or fewer items it animates on mobile and falls back to a static row at the `sm` breakpoint (CSS-driven, no client-side JS).",
|
|
16
|
+
"Server-rendered marquee of award badges. Optional `heading` and `footerText` display gold text above and below the awards, with divider lines around the heading. A single-entry static display can optionally swap in a distinct `mobileIconUrl` below the `sm` breakpoint. Animates when `scroll` is set; with 3 or fewer items it animates on mobile and falls back to a static row at the `sm` breakpoint (CSS-driven, no client-side JS).",
|
|
17
17
|
},
|
|
18
18
|
},
|
|
19
19
|
},
|
|
@@ -56,3 +56,47 @@ export const WithHeadingAndFooter: Story = {
|
|
|
56
56
|
scroll: false,
|
|
57
57
|
},
|
|
58
58
|
};
|
|
59
|
+
|
|
60
|
+
export const SingleConsolidatedLogo: Story = {
|
|
61
|
+
args: {
|
|
62
|
+
heading: "As seen on",
|
|
63
|
+
scroll: false,
|
|
64
|
+
items: [
|
|
65
|
+
{
|
|
66
|
+
iconUrl: "https://placehold.co/1441x622?text=Consolidated+Logo",
|
|
67
|
+
iconAlt: "As seen on logo collection",
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
parameters: {
|
|
72
|
+
docs: {
|
|
73
|
+
description: {
|
|
74
|
+
story:
|
|
75
|
+
"A single consolidated logo entry (no title/description) renders at up to 1100px wide, scales down responsively, and is horizontally centered. The caption block is omitted entirely since there is no caption text.",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const SingleLogoWithMobileImage: Story = {
|
|
82
|
+
args: {
|
|
83
|
+
heading: "As seen on",
|
|
84
|
+
scroll: false,
|
|
85
|
+
items: [
|
|
86
|
+
{
|
|
87
|
+
iconUrl: "https://placehold.co/1441x622?text=Desktop+Logo",
|
|
88
|
+
iconAlt: "As seen on logo collection (desktop)",
|
|
89
|
+
mobileIconUrl: "https://placehold.co/622x622?text=Mobile+Logo",
|
|
90
|
+
mobileIconAlt: "As seen on logo collection (mobile)",
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
parameters: {
|
|
95
|
+
docs: {
|
|
96
|
+
description: {
|
|
97
|
+
story:
|
|
98
|
+
"A single consolidated logo entry can optionally supply `mobileIconUrl` (plus matching `mobileIconAlt`/`mobileIconWidth`/`mobileIconHeight`) to swap in a different, often more vertical, crop below the `sm` (640px) breakpoint. Resize the viewport to see the swap. If `mobileIconUrl` is omitted, the desktop image is used at every breakpoint. This swap only applies when the marquee has a single entry and `scroll` is `false`.",
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
|
@@ -92,17 +92,128 @@ describe("AwardMarquee", () => {
|
|
|
92
92
|
expect(icon.src).toContain("icon-0.png");
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
-
it("renders intrinsic icon dimensions when provided", () => {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
it("renders intrinsic icon dimensions when provided (multi-entry, unchanged sizing)", () => {
|
|
96
|
+
const items = buildItems(2).map(item => ({
|
|
97
|
+
...item,
|
|
98
|
+
iconWidth: 320,
|
|
99
|
+
iconHeight: 180,
|
|
100
|
+
}));
|
|
101
|
+
render(<AwardMarquee items={items} />);
|
|
100
102
|
const icon = screen.getByAltText("Award 0");
|
|
101
103
|
expect(icon).toHaveAttribute("width", "320");
|
|
102
104
|
expect(icon).toHaveAttribute("height", "180");
|
|
103
105
|
expect(icon).toHaveClass("w-[200px]");
|
|
104
106
|
});
|
|
105
107
|
|
|
108
|
+
it("renders intrinsic icon dimensions when provided (single static entry)", () => {
|
|
109
|
+
const [item] = buildItems(1);
|
|
110
|
+
render(
|
|
111
|
+
<AwardMarquee items={[{ ...item, iconWidth: 1441, iconHeight: 622 }]} />
|
|
112
|
+
);
|
|
113
|
+
const icon = screen.getByAltText("Award 0");
|
|
114
|
+
expect(icon).toHaveAttribute("width", "1441");
|
|
115
|
+
expect(icon).toHaveAttribute("height", "622");
|
|
116
|
+
expect(icon).toHaveClass("w-full", "max-w-[1100px]", "h-auto");
|
|
117
|
+
expect(icon).not.toHaveClass("w-[200px]");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("renders a distinct mobile image for a single static entry when one is supplied", () => {
|
|
121
|
+
const { container } = render(
|
|
122
|
+
<AwardMarquee
|
|
123
|
+
items={[
|
|
124
|
+
{
|
|
125
|
+
iconUrl: "https://example.com/desktop-logo.png",
|
|
126
|
+
iconAlt: "Desktop Logo",
|
|
127
|
+
iconWidth: 1441,
|
|
128
|
+
iconHeight: 622,
|
|
129
|
+
mobileIconUrl: "https://example.com/mobile-logo.png",
|
|
130
|
+
mobileIconAlt: "Mobile Logo",
|
|
131
|
+
mobileIconWidth: 600,
|
|
132
|
+
mobileIconHeight: 400,
|
|
133
|
+
},
|
|
134
|
+
]}
|
|
135
|
+
scroll={false}
|
|
136
|
+
/>
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const mobileIcon = screen.getByAltText("Mobile Logo") as HTMLImageElement;
|
|
140
|
+
const desktopIcon = screen.getByAltText("Desktop Logo") as HTMLImageElement;
|
|
141
|
+
|
|
142
|
+
expect(mobileIcon.src).toContain("mobile-logo.png");
|
|
143
|
+
expect(mobileIcon).toHaveAttribute("width", "600");
|
|
144
|
+
expect(mobileIcon).toHaveAttribute("height", "400");
|
|
145
|
+
expect(mobileIcon).toHaveClass("sm:hidden", "max-w-[1100px]", "h-auto");
|
|
146
|
+
|
|
147
|
+
expect(desktopIcon.src).toContain("desktop-logo.png");
|
|
148
|
+
expect(desktopIcon).toHaveAttribute("width", "1441");
|
|
149
|
+
expect(desktopIcon).toHaveAttribute("height", "622");
|
|
150
|
+
expect(desktopIcon).toHaveClass(
|
|
151
|
+
"hidden",
|
|
152
|
+
"sm:block",
|
|
153
|
+
"max-w-[1100px]",
|
|
154
|
+
"h-auto"
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// Exactly one wrapper div (not duplicated) still holds both images.
|
|
158
|
+
expect(
|
|
159
|
+
container.querySelectorAll("#Award-Marquee > div > div")
|
|
160
|
+
).toHaveLength(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("falls back to the desktop image at every breakpoint when no mobile image is supplied", () => {
|
|
164
|
+
render(
|
|
165
|
+
<AwardMarquee
|
|
166
|
+
items={[
|
|
167
|
+
{
|
|
168
|
+
iconUrl: "https://example.com/desktop-logo.png",
|
|
169
|
+
iconAlt: "Desktop Logo",
|
|
170
|
+
},
|
|
171
|
+
]}
|
|
172
|
+
scroll={false}
|
|
173
|
+
/>
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const icon = screen.getByAltText("Desktop Logo");
|
|
177
|
+
expect(icon).not.toHaveClass("sm:hidden");
|
|
178
|
+
expect(icon).not.toHaveClass("hidden", "sm:block");
|
|
179
|
+
expect(screen.queryAllByAltText("Desktop Logo")).toHaveLength(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("ignores the mobile image field for multi-entry and animated-carousel rendering", () => {
|
|
183
|
+
const itemWithMobile = {
|
|
184
|
+
iconUrl: "https://example.com/desktop-logo.png",
|
|
185
|
+
iconAlt: "Desktop Logo",
|
|
186
|
+
mobileIconUrl: "https://example.com/mobile-logo.png",
|
|
187
|
+
mobileIconAlt: "Mobile Logo",
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const { container: multiContainer } = render(
|
|
191
|
+
<AwardMarquee
|
|
192
|
+
items={[itemWithMobile, { ...buildItems(1)[0] }]}
|
|
193
|
+
scroll={false}
|
|
194
|
+
/>
|
|
195
|
+
);
|
|
196
|
+
expect(multiContainer.querySelectorAll('[alt="Mobile Logo"]')).toHaveLength(
|
|
197
|
+
0
|
|
198
|
+
);
|
|
199
|
+
expect(screen.getByAltText("Desktop Logo")).not.toHaveClass("sm:hidden");
|
|
200
|
+
|
|
201
|
+
const { container: carouselContainer } = render(
|
|
202
|
+
<AwardMarquee items={[itemWithMobile]} scroll={true} />
|
|
203
|
+
);
|
|
204
|
+
// Split view for a single item never applies the mobile/desktop swap:
|
|
205
|
+
// the mobile half is already handled by the separate animated carousel,
|
|
206
|
+
// and the desktop half shows a single, unswapped image.
|
|
207
|
+
expect(
|
|
208
|
+
carouselContainer.querySelectorAll('[alt="Mobile Logo"]')
|
|
209
|
+
).toHaveLength(0);
|
|
210
|
+
const desktopHalfIcon = carouselContainer.querySelector(
|
|
211
|
+
".hidden.sm\\:block img"
|
|
212
|
+
);
|
|
213
|
+
expect(desktopHalfIcon).toHaveClass("w-full", "max-w-[1100px]", "h-auto");
|
|
214
|
+
expect(desktopHalfIcon).not.toHaveClass("sm:hidden");
|
|
215
|
+
});
|
|
216
|
+
|
|
106
217
|
it("omits intrinsic icon dimensions when unavailable", () => {
|
|
107
218
|
render(<AwardMarquee items={buildItems(1)} />);
|
|
108
219
|
const icon = screen.getByAltText("Award 0");
|
|
@@ -139,6 +250,93 @@ describe("AwardMarquee", () => {
|
|
|
139
250
|
expect(screen.getAllByText("Award 0")).toHaveLength(3);
|
|
140
251
|
});
|
|
141
252
|
|
|
253
|
+
it("centers a single static entry and hides the caption block when it has no caption text", () => {
|
|
254
|
+
const { container } = render(
|
|
255
|
+
<AwardMarquee
|
|
256
|
+
items={[{ iconUrl: "https://example.com/logo.png", iconAlt: "Logo" }]}
|
|
257
|
+
scroll={false}
|
|
258
|
+
/>
|
|
259
|
+
);
|
|
260
|
+
const wrapper = container.querySelector("#Award-Marquee > div > div");
|
|
261
|
+
expect(wrapper).toHaveClass("mx-auto", "w-full", "justify-center");
|
|
262
|
+
expect(wrapper).not.toHaveClass("ml-4", "w-auto");
|
|
263
|
+
|
|
264
|
+
const icon = screen.getByAltText("Logo");
|
|
265
|
+
expect(icon).toHaveClass("w-full", "max-w-[1100px]", "h-auto");
|
|
266
|
+
expect(icon).not.toHaveClass("w-[200px]");
|
|
267
|
+
|
|
268
|
+
// No title/description -> the 250px caption block takes no space.
|
|
269
|
+
expect(container.querySelector(".w-\\[250px\\]")).toBeNull();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("still renders the caption block for a single static entry that has caption text", () => {
|
|
273
|
+
const { container } = render(
|
|
274
|
+
<AwardMarquee items={buildItems(1)} scroll={false} />
|
|
275
|
+
);
|
|
276
|
+
expect(container.querySelector(".w-\\[250px\\]")).not.toBeNull();
|
|
277
|
+
expect(screen.getByText("Award 0")).toBeInTheDocument();
|
|
278
|
+
expect(screen.getByText("Description 0")).toBeInTheDocument();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("omits the caption block for any entry lacking caption text, even with multiple entries", () => {
|
|
282
|
+
const items: AwardMarqueeItem[] = [
|
|
283
|
+
...buildItems(1),
|
|
284
|
+
{ iconUrl: "https://example.com/logo-2.png", iconAlt: "Logo 2" },
|
|
285
|
+
];
|
|
286
|
+
const { container } = render(<AwardMarquee items={items} scroll={false} />);
|
|
287
|
+
// Only the first item (with title/description) gets a caption block.
|
|
288
|
+
expect(container.querySelectorAll(".w-\\[250px\\]")).toHaveLength(1);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("leaves multi-entry static rendering unchanged (fixed-width icon, ml-4 wrapper)", () => {
|
|
292
|
+
const { container } = render(
|
|
293
|
+
<AwardMarquee items={buildItems(2)} scroll={false} />
|
|
294
|
+
);
|
|
295
|
+
const wrappers = container.querySelectorAll("#Award-Marquee > div > div");
|
|
296
|
+
wrappers.forEach(wrapper => {
|
|
297
|
+
expect(wrapper).toHaveClass("ml-4", "w-auto");
|
|
298
|
+
expect(wrapper).not.toHaveClass("mx-auto", "justify-center");
|
|
299
|
+
});
|
|
300
|
+
const icon = screen.getByAltText("Award 0");
|
|
301
|
+
expect(icon).toHaveClass("w-[200px]");
|
|
302
|
+
expect(icon).not.toHaveClass("max-w-[1100px]");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("leaves the multi-entry animated carousel unchanged regardless of single-item styling", () => {
|
|
306
|
+
const { container } = render(
|
|
307
|
+
<AwardMarquee items={buildItems(5)} scroll={true} />
|
|
308
|
+
);
|
|
309
|
+
const icons = screen.getAllByAltText(/Award \d/);
|
|
310
|
+
icons.forEach(icon => expect(icon).toHaveClass("w-[200px]"));
|
|
311
|
+
const wrappers = container.querySelectorAll(".animate-award-marquee > div");
|
|
312
|
+
wrappers.forEach(wrapper => {
|
|
313
|
+
expect(wrapper).toHaveClass("ml-4", "w-auto");
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("applies the single-static treatment only to the desktop (static) half of a split view", () => {
|
|
318
|
+
const { container } = render(
|
|
319
|
+
<AwardMarquee
|
|
320
|
+
items={[{ iconUrl: "https://example.com/logo.png", iconAlt: "Logo" }]}
|
|
321
|
+
scroll={true}
|
|
322
|
+
/>
|
|
323
|
+
);
|
|
324
|
+
const mobileIcon = container.querySelector(
|
|
325
|
+
".sm\\:hidden img"
|
|
326
|
+
) as HTMLImageElement;
|
|
327
|
+
const desktopIcon = container.querySelector(
|
|
328
|
+
".hidden.sm\\:block img"
|
|
329
|
+
) as HTMLImageElement;
|
|
330
|
+
|
|
331
|
+
// Mobile (animated) half keeps the original fixed-width treatment.
|
|
332
|
+
expect(mobileIcon).toHaveClass("w-[200px]");
|
|
333
|
+
expect(mobileIcon).not.toHaveClass("max-w-[1100px]");
|
|
334
|
+
|
|
335
|
+
// Desktop (static) half gets the large, centered single-entry treatment.
|
|
336
|
+
expect(desktopIcon).toHaveClass("w-full", "max-w-[1100px]", "h-auto");
|
|
337
|
+
expect(desktopIcon).not.toHaveClass("w-[200px]");
|
|
338
|
+
});
|
|
339
|
+
|
|
142
340
|
it("applies the anchorId to the wrapper in every render branch", () => {
|
|
143
341
|
const staticView = render(
|
|
144
342
|
<AwardMarquee
|
|
@@ -26,32 +26,76 @@ const renderAwardItem = (
|
|
|
26
26
|
iconAlt,
|
|
27
27
|
iconWidth,
|
|
28
28
|
iconHeight,
|
|
29
|
+
mobileIconUrl,
|
|
30
|
+
mobileIconAlt,
|
|
31
|
+
mobileIconWidth,
|
|
32
|
+
mobileIconHeight,
|
|
29
33
|
title,
|
|
30
34
|
description,
|
|
31
35
|
}: AwardMarqueeItem,
|
|
32
|
-
key: React.Key
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
36
|
+
key: React.Key,
|
|
37
|
+
// Only the single-entry, non-animated (static) rendering gets the large,
|
|
38
|
+
// centered treatment; the animated carousel is unaffected.
|
|
39
|
+
isSingleStatic = false,
|
|
40
|
+
// A distinct mobile image is only honored for the standalone static render
|
|
41
|
+
// (scroll={false}). When this is the "desktop half" of a responsive split
|
|
42
|
+
// (scroll={true} with <=3 items), the sm:hidden mobile side is already
|
|
43
|
+
// handled by the separate animated half, so a nested swap here would never
|
|
44
|
+
// be visible at any viewport.
|
|
45
|
+
allowMobileSwap = false
|
|
46
|
+
) => {
|
|
47
|
+
const hasCaption = Boolean(title || description);
|
|
48
|
+
const hasMobileIcon = allowMobileSwap && Boolean(mobileIconUrl);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
key={key}
|
|
53
|
+
className={cx(
|
|
54
|
+
"flex flex-shrink-0 items-center whitespace-nowrap",
|
|
55
|
+
isSingleStatic ? "mx-auto w-full justify-center" : "ml-4 w-auto"
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
{hasMobileIcon ? (
|
|
59
|
+
<>
|
|
60
|
+
<img
|
|
61
|
+
src={mobileIconUrl}
|
|
62
|
+
alt={mobileIconAlt ?? iconAlt ?? ""}
|
|
63
|
+
width={mobileIconWidth}
|
|
64
|
+
height={mobileIconHeight}
|
|
65
|
+
className="h-auto w-full max-w-[1100px] sm:hidden"
|
|
66
|
+
/>
|
|
67
|
+
<img
|
|
68
|
+
src={iconUrl}
|
|
69
|
+
alt={iconAlt ?? ""}
|
|
70
|
+
width={iconWidth}
|
|
71
|
+
height={iconHeight}
|
|
72
|
+
className="hidden h-auto w-full max-w-[1100px] sm:block"
|
|
73
|
+
/>
|
|
74
|
+
</>
|
|
75
|
+
) : (
|
|
76
|
+
<img
|
|
77
|
+
src={iconUrl}
|
|
78
|
+
alt={iconAlt ?? ""}
|
|
79
|
+
width={iconWidth}
|
|
80
|
+
height={iconHeight}
|
|
81
|
+
className={
|
|
82
|
+
isSingleStatic ? "h-auto w-full max-w-[1100px]" : "w-[200px]"
|
|
83
|
+
}
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
{hasCaption && (
|
|
87
|
+
<div className="w-[250px] text-center">
|
|
88
|
+
<p className="flex justify-center text-center text-body3 font-bold">
|
|
89
|
+
{title}
|
|
90
|
+
</p>
|
|
91
|
+
<p className="flex flex-wrap whitespace-normal text-center text-footnote">
|
|
92
|
+
{description}
|
|
93
|
+
</p>
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
52
96
|
</div>
|
|
53
|
-
|
|
54
|
-
|
|
97
|
+
);
|
|
98
|
+
};
|
|
55
99
|
|
|
56
100
|
export const AwardMarquee: React.FC<AwardMarqueeProps> = ({
|
|
57
101
|
anchorId = "Award-Marquee",
|
|
@@ -91,20 +135,27 @@ export const AwardMarquee: React.FC<AwardMarqueeProps> = ({
|
|
|
91
135
|
);
|
|
92
136
|
};
|
|
93
137
|
|
|
94
|
-
const staticMarquee = (
|
|
138
|
+
const staticMarquee = (allowMobileSwap: boolean) => (
|
|
95
139
|
<div className="flex flex-nowrap items-center justify-around overflow-clip">
|
|
96
|
-
{items.map((item, index) =>
|
|
140
|
+
{items.map((item, index) =>
|
|
141
|
+
renderAwardItem(
|
|
142
|
+
item,
|
|
143
|
+
index,
|
|
144
|
+
items.length === 1,
|
|
145
|
+
allowMobileSwap && items.length === 1
|
|
146
|
+
)
|
|
147
|
+
)}
|
|
97
148
|
</div>
|
|
98
149
|
);
|
|
99
150
|
|
|
100
151
|
const marquee = !scroll ? (
|
|
101
|
-
staticMarquee
|
|
152
|
+
staticMarquee(true)
|
|
102
153
|
) : scrollAvailable ? (
|
|
103
154
|
animatedMarquee()
|
|
104
155
|
) : (
|
|
105
156
|
<>
|
|
106
157
|
<div className="sm:hidden">{animatedMarquee()}</div>
|
|
107
|
-
<div className="hidden sm:block">{staticMarquee}</div>
|
|
158
|
+
<div className="hidden sm:block">{staticMarquee(false)}</div>
|
|
108
159
|
</>
|
|
109
160
|
);
|
|
110
161
|
|
|
@@ -7,6 +7,12 @@ export type AwardMarqueeItem = {
|
|
|
7
7
|
iconAlt?: string;
|
|
8
8
|
iconWidth?: number;
|
|
9
9
|
iconHeight?: number;
|
|
10
|
+
// Optional mobile-specific image for the single-entry static display.
|
|
11
|
+
// Falls back to iconUrl at every breakpoint when omitted.
|
|
12
|
+
mobileIconUrl?: string;
|
|
13
|
+
mobileIconAlt?: string;
|
|
14
|
+
mobileIconWidth?: number;
|
|
15
|
+
mobileIconHeight?: number;
|
|
10
16
|
title?: string;
|
|
11
17
|
description?: string;
|
|
12
18
|
};
|
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
|
+
});
|