@windstream/react-shared-components 0.2.88 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windstream/react-shared-components",
3
- "version": "0.2.88",
3
+ "version": "0.2.89",
4
4
  "type": "module",
5
5
  "description": "Shared React components for Kinetic applications",
6
6
  "main": "dist/index.js",
@@ -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}>{children}</Tag>
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.99" />);
102
+ render(<PrimaryHero {...baseProps} price="49" />);
85
103
  expect(screen.getByText("$")).toBeInTheDocument();
86
- expect(screen.getByText("49.99")).toBeInTheDocument();
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
- {price ? (
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
- <div className="flex">
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
- {price}
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}
@@ -45,7 +45,8 @@ const argTypes: ArgTypes<PrimaryHeroProps> = {
45
45
  },
46
46
  price: {
47
47
  control: { type: "text" as const },
48
- description: "Price value (e.g. '24')",
48
+ description:
49
+ "Price value (e.g. '24'). Decimal prices such as a resolved price token ('19.99') render the cents with the suffix, without repeating cents the suffix already starts with. The lockup is hidden while any price field still contains an unresolved `${token}`.",
49
50
  },
50
51
  priceSuffix: {
51
52
  control: { type: "text" as const },
@@ -324,3 +325,23 @@ export const BundleOffer: Story = {
324
325
  checklist: undefined,
325
326
  },
326
327
  };
328
+
329
+ /** Tokenized price: a resolved price token ("19.99") renders the same lockup as an authored "19" + "99/mo*". */
330
+ export const TokenizedPrice: Story = {
331
+ parameters: {
332
+ docs: {
333
+ description: {
334
+ story:
335
+ "When the Price field holds a price token (e.g. `${price_fiber_300}`) the consuming app resolves it with `replaceTokens` to a decimal value such as `19.99`. The dollars render large and the cents render with the suffix, matching production heroes authored as Price `19` + Price Suffix `99/mo*`.",
336
+ },
337
+ },
338
+ },
339
+ args: {
340
+ title: "switch and save this season",
341
+ subTitle: "Make it festive with fast 100% fiber from Kinetic.",
342
+ price: "19.99",
343
+ priceSuffix: "/mo*",
344
+ priceCallout: "fiber plans|start at ",
345
+ textColor: "dark",
346
+ },
347
+ };
@@ -1,4 +1,5 @@
1
1
  import {
2
+ hasUnresolvedTokens,
2
3
  replaceTokens,
3
4
  type RichTextDocument,
4
5
  type TokenEntry,
@@ -195,3 +196,36 @@ describe("replaceTokens - rich text", () => {
195
196
  expect((result.content[1] as any).content[0].value).toBe("50");
196
197
  });
197
198
  });
199
+
200
+ describe("hasUnresolvedTokens", () => {
201
+ it.each([
202
+ ["${price_tier3_2gig}", true],
203
+ ["From $${price_tier1_300mbps}/mo", true],
204
+ ["39.99", false],
205
+ ["plain text", false],
206
+ ["${}", false],
207
+ ["", false],
208
+ [null, false],
209
+ [undefined, false],
210
+ ])("returns %p -> %p", (input, expected) => {
211
+ expect(hasUnresolvedTokens(input)).toBe(expected);
212
+ });
213
+
214
+ it("is false once every token resolves and true for unknown tokens", () => {
215
+ expect(hasUnresolvedTokens(replaceTokens("${reward_50}", tokens))).toBe(
216
+ false
217
+ );
218
+ expect(hasUnresolvedTokens(replaceTokens("${not_a_token}", tokens))).toBe(
219
+ true
220
+ );
221
+ });
222
+
223
+ it("returns the same result on repeated calls", () => {
224
+ const value = "${price_tier1_300mbps}";
225
+ expect([1, 2, 3].map(() => hasUnresolvedTokens(value))).toEqual([
226
+ true,
227
+ true,
228
+ true,
229
+ ]);
230
+ });
231
+ });
@@ -15,6 +15,10 @@ export type RichTextDocument = {
15
15
  export type ReplaceTokensResult<T> = T extends string ? string : T;
16
16
 
17
17
  const TOKEN_PATTERN = /\$\{([^${}]+)\}/g;
18
+ const UNRESOLVED_TOKEN_PATTERN = new RegExp(TOKEN_PATTERN.source);
19
+
20
+ export const hasUnresolvedTokens = (input?: string | null): boolean =>
21
+ typeof input === "string" && UNRESOLVED_TOKEN_PATTERN.test(input);
18
22
 
19
23
  const normalizeEntries = (tokens: TokenSource): TokenEntry[] => {
20
24
  if (Array.isArray(tokens)) return tokens;