@windstream/react-shared-components 0.2.87 → 0.2.89
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 +1 -1
- package/dist/contentful/index.esm.js +4 -4
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +4 -4
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +3 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/utils/index.esm.js.map +1 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/checklist/index.test.tsx +31 -1
- package/src/components/checklist/index.tsx +1 -0
- package/src/components/checklist/types.ts +2 -0
- package/src/contentful/blocks/cards/product-card/index.test.tsx +29 -4
- package/src/contentful/blocks/cards/product-card/index.tsx +46 -44
- package/src/contentful/blocks/cards/product-card/types.ts +1 -1
- package/src/contentful/blocks/carousel/helper.test.tsx +24 -0
- package/src/contentful/blocks/carousel/helper.tsx +1 -1
- package/src/contentful/blocks/primary-hero/index.test.tsx +263 -4
- package/src/contentful/blocks/primary-hero/index.tsx +48 -4
- package/src/contentful/blocks/primary-hero/primary-hero.stories.tsx +22 -1
- package/src/utils/replace-tokens.test.ts +34 -0
- package/src/utils/replace-tokens.ts +4 -0
package/package.json
CHANGED
|
@@ -19,11 +19,18 @@ jest.mock("@shared/components/list", () => ({
|
|
|
19
19
|
ListItem: ({
|
|
20
20
|
children,
|
|
21
21
|
className,
|
|
22
|
+
...rest
|
|
22
23
|
}: {
|
|
23
24
|
children: React.ReactNode;
|
|
24
25
|
className?: string;
|
|
25
26
|
}) => (
|
|
26
|
-
<li
|
|
27
|
+
<li
|
|
28
|
+
data-testid="list-item"
|
|
29
|
+
className={className}
|
|
30
|
+
{...Object.fromEntries(
|
|
31
|
+
Object.entries(rest).filter(([k]) => k.startsWith("data-"))
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
27
34
|
{children}
|
|
28
35
|
</li>
|
|
29
36
|
),
|
|
@@ -109,6 +116,29 @@ describe("Checklist", () => {
|
|
|
109
116
|
});
|
|
110
117
|
});
|
|
111
118
|
|
|
119
|
+
describe("fieldProps", () => {
|
|
120
|
+
it("spreads each structured item's fieldProps onto its own row", () => {
|
|
121
|
+
render(
|
|
122
|
+
<Checklist
|
|
123
|
+
items={[
|
|
124
|
+
{ title: "One", fieldProps: { "data-contentful-entry-id": "a" } },
|
|
125
|
+
{ title: "Two", fieldProps: { "data-contentful-entry-id": "b" } },
|
|
126
|
+
]}
|
|
127
|
+
/>
|
|
128
|
+
);
|
|
129
|
+
const rows = screen.getAllByTestId("list-item");
|
|
130
|
+
expect(rows[0]).toHaveAttribute("data-contentful-entry-id", "a");
|
|
131
|
+
expect(rows[1]).toHaveAttribute("data-contentful-entry-id", "b");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("renders no tagging attributes for plain or untagged items", () => {
|
|
135
|
+
render(<Checklist items={["Plain", { title: "Structured" }]} />);
|
|
136
|
+
screen.getAllByTestId("list-item").forEach(row => {
|
|
137
|
+
expect(row).not.toHaveAttribute("data-contentful-entry-id");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
112
142
|
describe("icons", () => {
|
|
113
143
|
it("renders check icons by default", () => {
|
|
114
144
|
render(<Checklist items={["Item 1"]} />);
|
|
@@ -74,6 +74,7 @@ export const Checklist: React.FC<ChecklistProps> = props => {
|
|
|
74
74
|
`${showIcons ? "flex" : ""} ${iconPosition === "top" ? "items-start" : "items-center"} gap-2 text-text-secondary`,
|
|
75
75
|
listItemClassName
|
|
76
76
|
)}
|
|
77
|
+
{...(structured ? item.fieldProps : undefined)}
|
|
77
78
|
>
|
|
78
79
|
{showIcons && hasCustomIcon ? (
|
|
79
80
|
<div className="flex shrink-0 leading-none">
|
|
@@ -179,6 +179,29 @@ describe("ProductCard", () => {
|
|
|
179
179
|
});
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
+
describe("Missing price", () => {
|
|
183
|
+
it.each([
|
|
184
|
+
["empty", ""],
|
|
185
|
+
["undefined", undefined],
|
|
186
|
+
])("hides the price block when price is %s", (_label, price) => {
|
|
187
|
+
const { container } = render(
|
|
188
|
+
<ProductCard
|
|
189
|
+
{...defaultProps}
|
|
190
|
+
price={price}
|
|
191
|
+
priceSuffixBottom="for 12 months"
|
|
192
|
+
/>
|
|
193
|
+
);
|
|
194
|
+
expect(screen.queryByText("$")).not.toBeInTheDocument();
|
|
195
|
+
expect(screen.queryByText(/\/mo/)).not.toBeInTheDocument();
|
|
196
|
+
expect(screen.queryByText("for 12 months")).not.toBeInTheDocument();
|
|
197
|
+
expect(container.textContent).not.toContain("0.00");
|
|
198
|
+
expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent(
|
|
199
|
+
"Kinetic 500"
|
|
200
|
+
);
|
|
201
|
+
expect(screen.getByTestId("select-plan-btn")).toBeInTheDocument();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
182
205
|
describe("Best Value", () => {
|
|
183
206
|
it("renders best value banner when bestValue is true", () => {
|
|
184
207
|
render(<ProductCard {...defaultProps} bestValue={true} />);
|
|
@@ -300,11 +323,13 @@ describe("ProductCard", () => {
|
|
|
300
323
|
});
|
|
301
324
|
|
|
302
325
|
it("renders the real gift slot (not the empty placeholder) when the card has its own gift", () => {
|
|
303
|
-
const giftBadge = [
|
|
304
|
-
{ title: "Free router", icon: "/router.png" },
|
|
305
|
-
] as any;
|
|
326
|
+
const giftBadge = [{ title: "Free router", icon: "/router.png" }] as any;
|
|
306
327
|
const { container } = render(
|
|
307
|
-
<ProductCard
|
|
328
|
+
<ProductCard
|
|
329
|
+
{...defaultProps}
|
|
330
|
+
giftBadge={giftBadge}
|
|
331
|
+
hasAnyGift={true}
|
|
332
|
+
/>
|
|
308
333
|
);
|
|
309
334
|
expect(
|
|
310
335
|
container.querySelector("[data-pc-gift-empty]")
|
|
@@ -137,51 +137,53 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|
|
137
137
|
) : null}
|
|
138
138
|
</Text>
|
|
139
139
|
</div>
|
|
140
|
-
|
|
141
|
-
<div className="flex items-start"
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
140
|
+
{price ? (
|
|
141
|
+
<div className="flex flex-col items-start md:items-end">
|
|
142
|
+
<div className="flex items-start" {...fieldProps?.price}>
|
|
143
|
+
<Text
|
|
144
|
+
as="span"
|
|
145
|
+
className={cx(
|
|
146
|
+
priceUnitClass,
|
|
147
|
+
"mr-1 mt-[2px]",
|
|
148
|
+
isDark ? "text-text-inverse" : "text-text"
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
$
|
|
152
|
+
</Text>
|
|
153
|
+
<Text
|
|
154
|
+
as="span"
|
|
155
|
+
className={cx(
|
|
156
|
+
priceIntegerClass,
|
|
157
|
+
"leading-none",
|
|
158
|
+
isDark ? "text-text-inverse" : "text-text"
|
|
159
|
+
)}
|
|
160
|
+
>
|
|
161
|
+
{price.split(".")[0]}
|
|
162
|
+
</Text>
|
|
163
|
+
<Text
|
|
164
|
+
as="span"
|
|
165
|
+
className={cx(
|
|
166
|
+
priceUnitClass,
|
|
167
|
+
isDark ? "text-text-inverse" : "text-text"
|
|
168
|
+
)}
|
|
169
|
+
>
|
|
170
|
+
{`${price.split(".")[1]}${priceUnit}`}
|
|
171
|
+
</Text>
|
|
172
|
+
</div>
|
|
173
|
+
{priceSuffixBottom ? (
|
|
174
|
+
<Text
|
|
175
|
+
as="span"
|
|
176
|
+
{...fieldProps?.priceSuffixBottom}
|
|
177
|
+
className={cx(
|
|
178
|
+
"micro text-nowrap",
|
|
179
|
+
isDark ? "text-text-inverse" : "text-text"
|
|
180
|
+
)}
|
|
181
|
+
>
|
|
182
|
+
{priceSuffixBottom}
|
|
183
|
+
</Text>
|
|
184
|
+
) : null}
|
|
171
185
|
</div>
|
|
172
|
-
|
|
173
|
-
<Text
|
|
174
|
-
as="span"
|
|
175
|
-
{...fieldProps?.priceSuffixBottom}
|
|
176
|
-
className={cx(
|
|
177
|
-
"micro text-nowrap",
|
|
178
|
-
isDark ? "text-text-inverse" : "text-text"
|
|
179
|
-
)}
|
|
180
|
-
>
|
|
181
|
-
{priceSuffixBottom}
|
|
182
|
-
</Text>
|
|
183
|
-
) : null}
|
|
184
|
-
</div>
|
|
186
|
+
) : null}
|
|
185
187
|
</header>
|
|
186
188
|
|
|
187
189
|
{/* Description */}
|
|
@@ -10,7 +10,7 @@ export interface ProductCardProps {
|
|
|
10
10
|
speed: string;
|
|
11
11
|
techType?: string;
|
|
12
12
|
ismaxSpeed?: boolean;
|
|
13
|
-
price
|
|
13
|
+
price?: string;
|
|
14
14
|
/** The price unit rendered after the cents, e.g. "/mo" or "/mo*". Defaults
|
|
15
15
|
* to "/mo". Lets Contentful control a trailing disclaimer star. */
|
|
16
16
|
priceUnit?: string;
|
|
@@ -34,11 +34,13 @@ jest.mock("@shared/contentful/blocks/cards/product-card", () => ({
|
|
|
34
34
|
enableBusinessSettings,
|
|
35
35
|
giftBadge,
|
|
36
36
|
hasAnyGift,
|
|
37
|
+
price,
|
|
37
38
|
}: any) => (
|
|
38
39
|
<article
|
|
39
40
|
data-testid="product-card"
|
|
40
41
|
data-plan={planName}
|
|
41
42
|
data-speed={speed}
|
|
43
|
+
data-price={price}
|
|
42
44
|
data-expanded={isExpanded}
|
|
43
45
|
data-business={enableBusinessSettings ? "true" : "false"}
|
|
44
46
|
>
|
|
@@ -373,6 +375,28 @@ describe("ProductCardCarousel", () => {
|
|
|
373
375
|
expect(card).toHaveAttribute("data-plan", "500 Mbps");
|
|
374
376
|
});
|
|
375
377
|
|
|
378
|
+
it("passes the formatted price when a price is authored", () => {
|
|
379
|
+
const fields = createProductFields([sampleProduct]);
|
|
380
|
+
render(<ProductCardCarousel fields={fields} />);
|
|
381
|
+
expect(screen.getByTestId("product-card")).toHaveAttribute(
|
|
382
|
+
"data-price",
|
|
383
|
+
"49.99"
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it.each([
|
|
388
|
+
["undefined", undefined],
|
|
389
|
+
["empty", ""],
|
|
390
|
+
])("passes an empty price (not 0.00) when price is %s", (_label, price) => {
|
|
391
|
+
const item = { ...sampleProduct, price };
|
|
392
|
+
const fields = createProductFields([item]);
|
|
393
|
+
render(<ProductCardCarousel fields={fields} />);
|
|
394
|
+
expect(screen.getByTestId("product-card")).toHaveAttribute(
|
|
395
|
+
"data-price",
|
|
396
|
+
""
|
|
397
|
+
);
|
|
398
|
+
});
|
|
399
|
+
|
|
376
400
|
it("renders Next arrow and hides Previous on first slide in carousel mode", async () => {
|
|
377
401
|
const items = Array.from({ length: 4 }, (_, i) => ({
|
|
378
402
|
...sampleProduct,
|
|
@@ -380,7 +380,7 @@ function ProductCardPanel({
|
|
|
380
380
|
const planName = planContent?.[0] || "";
|
|
381
381
|
const planSubtext = planContent?.[1] || "";
|
|
382
382
|
const priceCents = item?.priceSuffix?.split("/")?.[0] || "00";
|
|
383
|
-
const formattedPrice =
|
|
383
|
+
const formattedPrice = item?.price ? `${item.price}.${priceCents}` : "";
|
|
384
384
|
// Preserve the full unit after the cents (e.g. "/mo*") so a Contentful
|
|
385
385
|
// disclaimer star renders; fall back to "/mo" when no unit is present.
|
|
386
386
|
const suffixParts = item?.priceSuffix?.split("/") || [];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { PrimaryHero } from "./index";
|
|
3
3
|
|
|
4
|
+
import { replaceTokens } from "@shared/utils/replace-tokens";
|
|
4
5
|
import { fireEvent, render, screen } from "@testing-library/react";
|
|
5
6
|
|
|
6
7
|
jest.mock("@shared/components/checklist", () => ({
|
|
@@ -26,8 +27,10 @@ jest.mock("@shared/components/next-image", () => ({
|
|
|
26
27
|
}));
|
|
27
28
|
|
|
28
29
|
jest.mock("@shared/components/text", () => ({
|
|
29
|
-
Text: ({ as: Tag = "span", children, className }: any) => (
|
|
30
|
-
<Tag className={className}
|
|
30
|
+
Text: ({ as: Tag = "span", children, className, ...rest }: any) => (
|
|
31
|
+
<Tag className={className} {...rest}>
|
|
32
|
+
{children}
|
|
33
|
+
</Tag>
|
|
31
34
|
),
|
|
32
35
|
}));
|
|
33
36
|
|
|
@@ -80,10 +83,25 @@ describe("PrimaryHero", () => {
|
|
|
80
83
|
});
|
|
81
84
|
|
|
82
85
|
describe("Price section", () => {
|
|
86
|
+
const getPriceLockup = (container: HTMLElement) => {
|
|
87
|
+
const amount = container.querySelector("p.subheading5");
|
|
88
|
+
const lockup = amount?.parentElement;
|
|
89
|
+
const [dollarSign, , suffix] = Array.from(lockup?.children ?? []);
|
|
90
|
+
return { lockup, dollarSign, amount, suffix };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const textExposedToAssistiveTech = (container: HTMLElement) => {
|
|
94
|
+
const clone = container.cloneNode(true) as HTMLElement;
|
|
95
|
+
clone
|
|
96
|
+
.querySelectorAll('[aria-hidden="true"]')
|
|
97
|
+
.forEach(node => node.remove());
|
|
98
|
+
return clone.textContent ?? "";
|
|
99
|
+
};
|
|
100
|
+
|
|
83
101
|
it("renders price with dollar sign", () => {
|
|
84
|
-
render(<PrimaryHero {...baseProps} price="49
|
|
102
|
+
render(<PrimaryHero {...baseProps} price="49" />);
|
|
85
103
|
expect(screen.getByText("$")).toBeInTheDocument();
|
|
86
|
-
expect(screen.getByText("49
|
|
104
|
+
expect(screen.getByText("49")).toBeInTheDocument();
|
|
87
105
|
});
|
|
88
106
|
|
|
89
107
|
it("renders priceSuffix when provided", () => {
|
|
@@ -117,6 +135,247 @@ describe("PrimaryHero", () => {
|
|
|
117
135
|
render(<PrimaryHero {...baseProps} />);
|
|
118
136
|
expect(screen.queryByText("$")).not.toBeInTheDocument();
|
|
119
137
|
});
|
|
138
|
+
|
|
139
|
+
it("keeps the whole-dollar authored lockup unchanged", () => {
|
|
140
|
+
const { container } = render(
|
|
141
|
+
<PrimaryHero {...baseProps} price="19" priceSuffix="99/mo*" />
|
|
142
|
+
);
|
|
143
|
+
const { lockup, dollarSign, amount, suffix } = getPriceLockup(container);
|
|
144
|
+
|
|
145
|
+
expect(lockup?.children).toHaveLength(3);
|
|
146
|
+
expect(dollarSign).toHaveTextContent(/^\$$/);
|
|
147
|
+
expect(dollarSign).toHaveClass("subheading1", "mt-0.5");
|
|
148
|
+
expect(amount).toHaveTextContent(/^19$/);
|
|
149
|
+
expect(suffix?.tagName).toBe("P");
|
|
150
|
+
expect(suffix).toHaveClass("subheading1", "mt-0.5");
|
|
151
|
+
expect(suffix).toHaveTextContent(/^99\/mo\*$/);
|
|
152
|
+
expect(suffix?.querySelector("span")).toBeNull();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("renders decimal cents with the suffix so it matches the authored lockup", () => {
|
|
156
|
+
const authored = render(
|
|
157
|
+
<PrimaryHero {...baseProps} price="19" priceSuffix="99/mo*" />
|
|
158
|
+
);
|
|
159
|
+
const expected = getPriceLockup(authored.container);
|
|
160
|
+
const expectedLockup = {
|
|
161
|
+
amount: expected.amount?.textContent,
|
|
162
|
+
amountClass: expected.amount?.className,
|
|
163
|
+
suffix: expected.suffix?.textContent,
|
|
164
|
+
suffixClass: expected.suffix?.className,
|
|
165
|
+
count: expected.lockup?.children.length,
|
|
166
|
+
};
|
|
167
|
+
authored.unmount();
|
|
168
|
+
|
|
169
|
+
const { container } = render(
|
|
170
|
+
<PrimaryHero {...baseProps} price="19.99" priceSuffix="/mo*" />
|
|
171
|
+
);
|
|
172
|
+
const actual = getPriceLockup(container);
|
|
173
|
+
|
|
174
|
+
expect({
|
|
175
|
+
amount: actual.amount?.textContent,
|
|
176
|
+
amountClass: actual.amount?.className,
|
|
177
|
+
suffix: actual.suffix?.textContent,
|
|
178
|
+
suffixClass: actual.suffix?.className,
|
|
179
|
+
count: actual.lockup?.children.length,
|
|
180
|
+
}).toEqual(expectedLockup);
|
|
181
|
+
expect(screen.queryByText("19.99")).not.toBeInTheDocument();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("renders a resolved price token like the authored production lockup", () => {
|
|
185
|
+
const tokens = [
|
|
186
|
+
{ token: "price_fiber_300", value: "24.99", promo: "19.99" },
|
|
187
|
+
];
|
|
188
|
+
const { container } = render(
|
|
189
|
+
<PrimaryHero
|
|
190
|
+
{...baseProps}
|
|
191
|
+
price={replaceTokens("${price_fiber_300}", tokens)}
|
|
192
|
+
priceSuffix={replaceTokens("/mo*", tokens)}
|
|
193
|
+
priceCallout="fiber plans|start at "
|
|
194
|
+
/>
|
|
195
|
+
);
|
|
196
|
+
const { amount, suffix } = getPriceLockup(container);
|
|
197
|
+
|
|
198
|
+
expect(amount).toHaveTextContent(/^19$/);
|
|
199
|
+
expect(suffix).toHaveTextContent(/^99\/mo\*$/);
|
|
200
|
+
expect(screen.getByText("fiber plans")).toBeInTheDocument();
|
|
201
|
+
expect(screen.getByText("$19.99/mo*")).toHaveClass("sr-only");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("renders decimal cents on their own when no suffix is provided", () => {
|
|
205
|
+
const { container } = render(
|
|
206
|
+
<PrimaryHero {...baseProps} price="39.99" />
|
|
207
|
+
);
|
|
208
|
+
const { amount, suffix } = getPriceLockup(container);
|
|
209
|
+
|
|
210
|
+
expect(amount).toHaveTextContent(/^39$/);
|
|
211
|
+
expect(suffix).toHaveClass("subheading1", "mt-0.5");
|
|
212
|
+
expect(suffix).toHaveTextContent(/^99$/);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("splits prices with thousands separators", () => {
|
|
216
|
+
const { container } = render(
|
|
217
|
+
<PrimaryHero {...baseProps} price="1,299.50" priceSuffix="/yr" />
|
|
218
|
+
);
|
|
219
|
+
const { amount, suffix } = getPriceLockup(container);
|
|
220
|
+
|
|
221
|
+
expect(amount).toHaveTextContent(/^1,299$/);
|
|
222
|
+
expect(suffix).toHaveTextContent(/^50\/yr$/);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it.each(["Free", "$19.99", "19.", ".99"])(
|
|
226
|
+
"renders non-decimal price %p verbatim",
|
|
227
|
+
price => {
|
|
228
|
+
const { container } = render(
|
|
229
|
+
<PrimaryHero {...baseProps} price={price} priceSuffix="/mo" />
|
|
230
|
+
);
|
|
231
|
+
const { amount, suffix } = getPriceLockup(container);
|
|
232
|
+
|
|
233
|
+
expect(amount?.textContent).toBe(price);
|
|
234
|
+
expect(suffix).toHaveTextContent(/^\/mo$/);
|
|
235
|
+
}
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
it.each([
|
|
239
|
+
["price", { price: "${price_tier3_2gig}" }],
|
|
240
|
+
["priceSuffix", { price: "19", priceSuffix: "${price_suffix}" }],
|
|
241
|
+
["priceCallout", { price: "19", priceCallout: "save $${reward_x}" }],
|
|
242
|
+
])("hides the price lockup when %s has an unresolved token", (_, props) => {
|
|
243
|
+
const { container } = render(
|
|
244
|
+
<PrimaryHero
|
|
245
|
+
{...baseProps}
|
|
246
|
+
priceSuffix="/mo*"
|
|
247
|
+
priceCallout="fiber plans|start at "
|
|
248
|
+
{...props}
|
|
249
|
+
/>
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
expect(container.querySelector("p.subheading5")).toBeNull();
|
|
253
|
+
expect(screen.queryByText("$")).not.toBeInTheDocument();
|
|
254
|
+
expect(screen.queryByText("fiber plans")).not.toBeInTheDocument();
|
|
255
|
+
expect(container.textContent).not.toContain("${");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it.each(["99/mo*", " 99/mo*", "99"])(
|
|
259
|
+
"does not repeat cents already leading the suffix %p",
|
|
260
|
+
priceSuffix => {
|
|
261
|
+
const { container } = render(
|
|
262
|
+
<PrimaryHero {...baseProps} price="59.99" priceSuffix={priceSuffix} />
|
|
263
|
+
);
|
|
264
|
+
const { amount, suffix } = getPriceLockup(container);
|
|
265
|
+
|
|
266
|
+
expect(amount).toHaveTextContent(/^59$/);
|
|
267
|
+
expect(suffix?.textContent).toBe(priceSuffix);
|
|
268
|
+
expect(suffix?.querySelector("span")).toBeNull();
|
|
269
|
+
}
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
it.each([
|
|
273
|
+
["59.49", "99/mo*", "4999/mo*"],
|
|
274
|
+
["19.99", "990/mo", "99990/mo"],
|
|
275
|
+
])(
|
|
276
|
+
"keeps the cents when %p has a suffix %p that does not lead with them",
|
|
277
|
+
(price, priceSuffix, expected) => {
|
|
278
|
+
const { container } = render(
|
|
279
|
+
<PrimaryHero {...baseProps} price={price} priceSuffix={priceSuffix} />
|
|
280
|
+
);
|
|
281
|
+
const { suffix } = getPriceLockup(container);
|
|
282
|
+
|
|
283
|
+
expect(suffix?.textContent).toBe(expected);
|
|
284
|
+
}
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
it("tags split cents with the price field and the suffix with the priceSuffix field", () => {
|
|
288
|
+
const { container } = render(
|
|
289
|
+
<PrimaryHero
|
|
290
|
+
{...baseProps}
|
|
291
|
+
price="19.99"
|
|
292
|
+
priceSuffix="/mo*"
|
|
293
|
+
fieldProps={{
|
|
294
|
+
price: { "data-field": "price" },
|
|
295
|
+
priceSuffix: { "data-field": "priceSuffix" },
|
|
296
|
+
}}
|
|
297
|
+
/>
|
|
298
|
+
);
|
|
299
|
+
const { amount, suffix } = getPriceLockup(container);
|
|
300
|
+
const cents = suffix?.querySelector("span");
|
|
301
|
+
|
|
302
|
+
expect(amount).toHaveAttribute("data-field", "price");
|
|
303
|
+
expect(suffix).toHaveAttribute("data-field", "priceSuffix");
|
|
304
|
+
expect(cents).toHaveAttribute("data-field", "price");
|
|
305
|
+
expect(cents).toHaveTextContent(/^99$/);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it.each([
|
|
309
|
+
["19.99", "/mo*", "$19.99/mo*"],
|
|
310
|
+
["59.99", "99/mo*", "$59.99/mo*"],
|
|
311
|
+
["59.99", " 99/mo*", "$59.99/mo*"],
|
|
312
|
+
["39.99", undefined, "$39.99"],
|
|
313
|
+
["1,299.50", "/yr", "$1,299.50/yr"],
|
|
314
|
+
["19.99", " per month", "$19.99 per month"],
|
|
315
|
+
["59.49", "99/mo*", "$59.49 99/mo*"],
|
|
316
|
+
])(
|
|
317
|
+
"exposes %p + %p to assistive technology as %p only once",
|
|
318
|
+
(price, priceSuffix, expected) => {
|
|
319
|
+
const { container } = render(
|
|
320
|
+
<PrimaryHero
|
|
321
|
+
{...baseProps}
|
|
322
|
+
price={price}
|
|
323
|
+
priceSuffix={priceSuffix}
|
|
324
|
+
priceCallout="fiber plans|start at "
|
|
325
|
+
/>
|
|
326
|
+
);
|
|
327
|
+
const { lockup } = getPriceLockup(container);
|
|
328
|
+
const accessibleText = textExposedToAssistiveTech(container);
|
|
329
|
+
|
|
330
|
+
expect(screen.getByText(expected)).toHaveClass("sr-only");
|
|
331
|
+
expect(lockup).toHaveAttribute("aria-hidden", "true");
|
|
332
|
+
expect(accessibleText).toContain(expected);
|
|
333
|
+
expect(accessibleText.replace(expected, "")).not.toMatch(/\d/);
|
|
334
|
+
}
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
it("keeps the split visual fragments in the DOM for crawlers", () => {
|
|
338
|
+
const { container } = render(
|
|
339
|
+
<PrimaryHero {...baseProps} price="19.99" priceSuffix="/mo*" />
|
|
340
|
+
);
|
|
341
|
+
const { dollarSign, amount, suffix } = getPriceLockup(container);
|
|
342
|
+
|
|
343
|
+
expect(dollarSign).toBeInTheDocument();
|
|
344
|
+
expect(amount).toHaveTextContent(/^19$/);
|
|
345
|
+
expect(suffix).toHaveTextContent(/^99\/mo\*$/);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it.each([
|
|
349
|
+
["19", "99/mo*"],
|
|
350
|
+
["Free", "/mo"],
|
|
351
|
+
["19.", "/mo"],
|
|
352
|
+
])(
|
|
353
|
+
"leaves the unsplit lockup %p + %p exposed to assistive technology as before",
|
|
354
|
+
(price, priceSuffix) => {
|
|
355
|
+
const { container } = render(
|
|
356
|
+
<PrimaryHero {...baseProps} price={price} priceSuffix={priceSuffix} />
|
|
357
|
+
);
|
|
358
|
+
const { lockup } = getPriceLockup(container);
|
|
359
|
+
|
|
360
|
+
expect(container.querySelector(".sr-only")).toBeNull();
|
|
361
|
+
expect(lockup).not.toHaveAttribute("aria-hidden");
|
|
362
|
+
expect(textExposedToAssistiveTech(container)).toContain(
|
|
363
|
+
`$${price}${priceSuffix}`
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
it("does not expose an accessible price when the lockup is hidden", () => {
|
|
369
|
+
const { container } = render(
|
|
370
|
+
<PrimaryHero
|
|
371
|
+
{...baseProps}
|
|
372
|
+
price="${price_tier3_2gig}"
|
|
373
|
+
priceSuffix="/mo*"
|
|
374
|
+
/>
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
expect(container.querySelector(".sr-only")).toBeNull();
|
|
378
|
+
});
|
|
120
379
|
});
|
|
121
380
|
|
|
122
381
|
describe("Checklist", () => {
|
|
@@ -6,6 +6,27 @@ import { NextImage } from "@shared/components/next-image";
|
|
|
6
6
|
import { Text } from "@shared/components/text";
|
|
7
7
|
import { Button } from "@shared/contentful/blocks/button";
|
|
8
8
|
import { cx } from "@shared/utils";
|
|
9
|
+
import { hasUnresolvedTokens } from "@shared/utils/replace-tokens";
|
|
10
|
+
|
|
11
|
+
const DECIMAL_PRICE_PATTERN = /^(\d[\d,]*)\.(\d+)$/;
|
|
12
|
+
|
|
13
|
+
const splitPrice = (price?: string, priceSuffix?: string) => {
|
|
14
|
+
const match = price ? DECIMAL_PRICE_PATTERN.exec(price.trim()) : null;
|
|
15
|
+
if (!match) return { dollars: price, cents: "", accessiblePrice: "" };
|
|
16
|
+
const [, dollars, cents] = match;
|
|
17
|
+
const suffix = priceSuffix?.trimStart() ?? "";
|
|
18
|
+
const suffixLeadsWithCents =
|
|
19
|
+
suffix.startsWith(cents) && !/^\d/.test(suffix.slice(cents.length));
|
|
20
|
+
const unit = suffixLeadsWithCents
|
|
21
|
+
? suffix.slice(cents.length)
|
|
22
|
+
: (priceSuffix ?? "");
|
|
23
|
+
const unitSeparator = /^\d/.test(unit) ? " " : "";
|
|
24
|
+
return {
|
|
25
|
+
dollars,
|
|
26
|
+
cents: suffixLeadsWithCents ? "" : cents,
|
|
27
|
+
accessiblePrice: `$${dollars}.${cents}${unitSeparator}${unit}`,
|
|
28
|
+
};
|
|
29
|
+
};
|
|
9
30
|
|
|
10
31
|
export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
|
|
11
32
|
const {
|
|
@@ -59,6 +80,18 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
|
|
|
59
80
|
const badgeImageUrl = normalizeImageUrl(badgeImage);
|
|
60
81
|
const heroImageUrl = normalizeImageUrl(carouselImages?.[0]);
|
|
61
82
|
const bottomLinkLabel = bottomLink?.buttonLabel ?? bottomLink?.label;
|
|
83
|
+
const {
|
|
84
|
+
dollars: priceDollars,
|
|
85
|
+
cents: priceCents,
|
|
86
|
+
accessiblePrice,
|
|
87
|
+
} = splitPrice(price, priceSuffix);
|
|
88
|
+
// An unresolved `${token}` (e.g. a missing token source) hides the lockup
|
|
89
|
+
// instead of rendering the raw placeholder.
|
|
90
|
+
const showPrice =
|
|
91
|
+
!!price &&
|
|
92
|
+
![price, priceSuffix, priceCallout].some(value =>
|
|
93
|
+
hasUnresolvedTokens(value)
|
|
94
|
+
);
|
|
62
95
|
const baseTextColorClass = textColor === "dark" ? "text-text" : "text-white";
|
|
63
96
|
const textColorClass =
|
|
64
97
|
hasDesktopVideo && textColor === "dark"
|
|
@@ -123,7 +156,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
|
|
|
123
156
|
)}
|
|
124
157
|
|
|
125
158
|
{/* price callout */}
|
|
126
|
-
{
|
|
159
|
+
{showPrice ? (
|
|
127
160
|
<div className="flex sm:justify-center sm:text-left lg:justify-start">
|
|
128
161
|
<div className="mr-2 mt-0.5" {...fieldProps?.priceCallout}>
|
|
129
162
|
{priceCallout
|
|
@@ -134,19 +167,30 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
|
|
|
134
167
|
))
|
|
135
168
|
: null}
|
|
136
169
|
</div>
|
|
137
|
-
|
|
170
|
+
{accessiblePrice ? (
|
|
171
|
+
<Text as="p" className="sr-only">
|
|
172
|
+
{accessiblePrice}
|
|
173
|
+
</Text>
|
|
174
|
+
) : null}
|
|
175
|
+
<div
|
|
176
|
+
className="flex"
|
|
177
|
+
aria-hidden={accessiblePrice ? true : undefined}
|
|
178
|
+
>
|
|
138
179
|
<Text as="p" className="subheading1 mt-0.5">
|
|
139
180
|
$
|
|
140
181
|
</Text>
|
|
141
182
|
<Text as="p" className="subheading5" {...fieldProps?.price}>
|
|
142
|
-
{
|
|
183
|
+
{priceDollars}
|
|
143
184
|
</Text>
|
|
144
|
-
{priceSuffix ? (
|
|
185
|
+
{priceCents || priceSuffix ? (
|
|
145
186
|
<Text
|
|
146
187
|
as="p"
|
|
147
188
|
className="subheading1 mt-0.5"
|
|
148
189
|
{...fieldProps?.priceSuffix}
|
|
149
190
|
>
|
|
191
|
+
{priceCents ? (
|
|
192
|
+
<span {...fieldProps?.price}>{priceCents}</span>
|
|
193
|
+
) : null}
|
|
150
194
|
{priceSuffix}
|
|
151
195
|
</Text>
|
|
152
196
|
) : null}
|