@windstream/react-shared-components 0.2.41 → 0.2.43

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 (34) hide show
  1. package/dist/contentful/index.d.ts +1 -1
  2. package/dist/contentful/index.esm.js +2 -2
  3. package/dist/contentful/index.esm.js.map +1 -1
  4. package/dist/contentful/index.js +3 -3
  5. package/dist/contentful/index.js.map +1 -1
  6. package/dist/index.esm.js +1 -1
  7. package/dist/index.esm.js.map +1 -1
  8. package/dist/index.js +1 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/next/index.esm.js +1 -1
  11. package/dist/next/index.esm.js.map +1 -1
  12. package/dist/next/index.js +1 -1
  13. package/dist/next/index.js.map +1 -1
  14. package/dist/styles.css +1 -1
  15. package/dist/utils/index.esm.js +1 -1
  16. package/dist/utils/index.esm.js.map +1 -1
  17. package/dist/utils/index.js +1 -1
  18. package/dist/utils/index.js.map +1 -1
  19. package/package.json +1 -1
  20. package/src/components/animation-wrapper/index.test.tsx +6 -6
  21. package/src/components/link/index.test.tsx +2 -2
  22. package/src/components/link/index.tsx +1 -1
  23. package/src/components/video-link/index.test.tsx +43 -4
  24. package/src/components/video-link/index.tsx +32 -4
  25. package/src/components/video-link/types.ts +1 -0
  26. package/src/contentful/blocks/blogs-grid/index.test.tsx +45 -14
  27. package/src/contentful/blocks/email-input-block/index.test.tsx +32 -41
  28. package/src/contentful/blocks/find-kinetic/index.test.tsx +3 -2
  29. package/src/contentful/blocks/image-promo-bar/index.test.tsx +55 -10
  30. package/src/contentful/blocks/image-promo-bar/index.tsx +5 -2
  31. package/src/contentful/blocks/image-promo-bar/types.ts +1 -1
  32. package/src/contentful/blocks/primary-hero/index.tsx +53 -41
  33. package/src/hooks/contentful/use-contentful-rich-text.test.tsx +4 -3
  34. package/src/hooks/contentful/use-contentful-rich-text.tsx +69 -9
@@ -52,12 +52,28 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
52
52
  const mobileVideoUrl = heroVideoMobile?.url?.trim();
53
53
  const hasDesktopVideo = !!desktopVideoUrl;
54
54
  const hasMobileVideo = !!mobileVideoUrl;
55
+ const normalizeImageUrl = (url?: string) =>
56
+ url?.startsWith("//") ? `https:${url}` : url;
57
+ const badgeImageUrl = normalizeImageUrl(badgeImage);
58
+ const heroImageUrl = normalizeImageUrl(carouselImages?.[0]);
55
59
  const bottomLinkLabel = bottomLink?.buttonLabel ?? bottomLink?.label;
56
60
  const baseTextColorClass = textColor === "dark" ? "text-text" : "text-white";
57
61
  const textColorClass =
58
62
  hasDesktopVideo && textColor === "dark"
59
63
  ? `${baseTextColorClass} xl:text-white`
60
64
  : baseTextColorClass;
65
+ const Badge = ({ className }: { className: string }) =>
66
+ badgeImageUrl ? (
67
+ <NextImage
68
+ src={badgeImageUrl}
69
+ alt="Badge"
70
+ className={className}
71
+ width={208}
72
+ height={208}
73
+ quality={90}
74
+ loading="eager"
75
+ />
76
+ ) : null;
61
77
  return (
62
78
  <div className="component-container relative overflow-hidden p-4 sm:px-6 sm:py-12 xl:flex xl:min-h-[724px] xl:items-center xl:px-10">
63
79
  {hasDesktopVideo ? (
@@ -267,23 +283,26 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
267
283
  <div className="w-full xl:hidden">
268
284
  <div className={"relative my-8"}>
269
285
  {hasMobileVideo ? (
270
- <video
271
- autoPlay={true}
272
- muted={true}
273
- loop={true}
274
- playsInline={true}
275
- preload="auto"
276
- aria-label={heroVideoMobile?.title || title || "Hero video"}
277
- className="h-[350px] w-full rounded-[40px] object-cover object-center sm:h-[420px] md:h-[520px]"
278
- data-testid="hero-video-mobile"
279
- >
280
- <source src={mobileVideoUrl} type="video/mp4" />
281
- </video>
286
+ <div className="relative">
287
+ <video
288
+ autoPlay={true}
289
+ muted={true}
290
+ loop={true}
291
+ playsInline={true}
292
+ preload="auto"
293
+ aria-label={heroVideoMobile?.title || title || "Hero video"}
294
+ className="h-[350px] w-full rounded-[40px] object-cover object-center sm:h-[420px] md:h-[520px]"
295
+ data-testid="hero-video-mobile"
296
+ >
297
+ <source src={mobileVideoUrl} type="video/mp4" />
298
+ </video>
299
+ <Badge className="absolute left-5 top-5 z-10 aspect-square w-26 object-cover object-center" />
300
+ </div>
282
301
  ) : (
283
302
  <>
284
303
  {badgeImage ? (
285
304
  <NextImage
286
- src={badgeImage}
305
+ src={badgeImageUrl || ""}
287
306
  alt={"Badge"}
288
307
  className="absolute left-5 top-5 aspect-square w-26 object-cover object-center"
289
308
  width={104}
@@ -292,9 +311,9 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
292
311
  loading="eager"
293
312
  />
294
313
  ) : null}
295
- {carouselImages && carouselImages[0] ? (
314
+ {heroImageUrl ? (
296
315
  <NextImage
297
- src={carouselImages[0]}
316
+ src={heroImageUrl}
298
317
  alt={"Hero"}
299
318
  className="h-[205px] w-full rounded-[40px] object-cover object-center sm:h-[420px]"
300
319
  width={1024}
@@ -339,32 +358,25 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
339
358
  </div>
340
359
 
341
360
  {/* Right column: bright blue background, diagonal top edge, hero image */}
342
- {!hasDesktopVideo ? (
343
- <div className={cx("relative hidden xl:block")}>
344
- {badgeImage ? (
345
- <NextImage
346
- src={badgeImage}
347
- alt={"Badge"}
348
- className="absolute -left-26 top-18 aspect-square w-52 object-cover object-center"
349
- width={208}
350
- height={208}
351
- quality={90}
352
- loading="eager"
353
- />
354
- ) : null}
355
- {carouselImages && carouselImages[0] ? (
356
- <NextImage
357
- src={carouselImages[0]}
358
- alt={"Hero"}
359
- className="aspect-square rounded-[40px] object-cover object-center"
360
- width={600}
361
- height={600}
362
- quality={90}
363
- loading="eager"
364
- />
365
- ) : null}
366
- </div>
367
- ) : null}
361
+ <div
362
+ className={cx(
363
+ "relative hidden xl:block",
364
+ hasDesktopVideo && "h-[600px] w-[600px]"
365
+ )}
366
+ >
367
+ <Badge className="absolute -left-26 top-18 z-10 aspect-square w-52 object-cover object-center" />
368
+ {!hasDesktopVideo && heroImageUrl ? (
369
+ <NextImage
370
+ src={heroImageUrl}
371
+ alt={"Hero"}
372
+ className="aspect-square rounded-[40px] object-cover object-center"
373
+ width={600}
374
+ height={600}
375
+ quality={90}
376
+ loading="eager"
377
+ />
378
+ ) : null}
379
+ </div>
368
380
  </div>
369
381
  </div>
370
382
  );
@@ -549,9 +549,10 @@ describe("useContentfulRichText", () => {
549
549
  const { container } = render(<>{result.current}</>);
550
550
  expect(container.querySelector("h1")).toHaveTextContent("H1");
551
551
  expect(container.querySelector("h2")).toHaveTextContent("H2");
552
- // h3-h6 all render as h3 per defaultOptions
553
- const h3s = container.querySelectorAll("h3");
554
- expect(h3s.length).toBe(4);
552
+ expect(container.querySelector("h3")).toHaveTextContent("H3");
553
+ expect(container.querySelector("h4")).toHaveTextContent("H4");
554
+ expect(container.querySelector("h5")).toHaveTextContent("H5");
555
+ expect(container.querySelector("h6")).toHaveTextContent("H6");
555
556
  });
556
557
 
557
558
  it("renders unordered list", () => {
@@ -20,10 +20,66 @@ const isEmptyParagraph = (node: any): boolean =>
20
20
  Array.isArray(node?.content) &&
21
21
  node.content.every(
22
22
  (child: any) =>
23
- child?.nodeType === "text" &&
24
- (!child.value || child.value.trim() === "")
23
+ child?.nodeType === "text" && (!child.value || child.value.trim() === "")
25
24
  );
26
25
 
26
+ // A link is "external" only when it belongs to a different app than the one
27
+ // currently rendering this shared component (business vs. main/www are separate
28
+ // apps). Matching is environment-agnostic so a prod link like
29
+ // business.gokinetic.com is still "internal" while running on business-dev.
30
+ const hostFromUrl = (value?: string): string => {
31
+ if (!value) return "";
32
+ try {
33
+ const withProtocol = /^https?:\/\//i.test(value)
34
+ ? value
35
+ : `https://${value}`;
36
+ return new URL(withProtocol).hostname.toLowerCase();
37
+ } catch {
38
+ return "";
39
+ }
40
+ };
41
+
42
+ // e.g. "business-dev.gokinetic.com" -> "gokinetic.com|business",
43
+ // "kinetic-dev.gokinetic.com" / "www.gokinetic.com" -> "gokinetic.com|main"
44
+ const appIdentity = (host: string): string => {
45
+ const clean = host.toLowerCase().replace(/\.$/, "");
46
+ const base = clean.split(".").slice(-2).join(".");
47
+ const subdomain = clean
48
+ .slice(0, clean.length - base.length)
49
+ .replace(/\.$/, "");
50
+ const firstLabel = (subdomain.split(".")[0] || "").replace(
51
+ /-(dev|development|staging|stage|qa|test|uat|prod|production)$/,
52
+ ""
53
+ );
54
+ const app =
55
+ firstLabel === "" || firstLabel === "www" || firstLabel === "kinetic"
56
+ ? "main"
57
+ : firstLabel;
58
+ return `${base}|${app}`;
59
+ };
60
+
61
+ const getCurrentHost = (): string => {
62
+ const envHost =
63
+ hostFromUrl(process.env.NEXT_PUBLIC_SITE_URL) ||
64
+ hostFromUrl(process.env.NEXT_PUBLIC_MAIN_SITE_URL);
65
+ if (envHost) return envHost;
66
+ if (typeof window !== "undefined")
67
+ return window.location.hostname.toLowerCase();
68
+ return "";
69
+ };
70
+
71
+ const isExternalUrl = (url: string): boolean => {
72
+ if (!/^https?:\/\//i.test(url)) return false; // relative links are always internal
73
+ try {
74
+ const linkHost = new URL(url).hostname.toLowerCase();
75
+ const currentHost = getCurrentHost();
76
+ if (!currentHost) return true; // host unknown (SSR without env) → treat as external
77
+ return appIdentity(linkHost) !== appIdentity(currentHost);
78
+ } catch {
79
+ return false;
80
+ }
81
+ };
82
+
27
83
  const defaultOptions: Options = {
28
84
  renderMark: {
29
85
  [MARKS.BOLD]: text => <strong className="font-black">{text}</strong>,
@@ -80,12 +136,12 @@ const defaultOptions: Options = {
80
136
 
81
137
  [INLINES.HYPERLINK]: (node, children) => {
82
138
  const url = (node as any)?.data?.uri as string;
83
- const external = /^https?:\/\//.test(url);
139
+ const external = isExternalUrl(url);
84
140
  return (
85
141
  <a
86
142
  href={url}
87
143
  target={external ? "_blank" : undefined}
88
- rel={external ? "noopener noreferrer" : undefined}
144
+ rel={external ? "noopener" : undefined}
89
145
  >
90
146
  {children}
91
147
  </a>
@@ -167,22 +223,26 @@ export function renderContentfulRichText(
167
223
  ...options?.renderNode,
168
224
  // Logic for links based on the isTargetBlank flag
169
225
  [BLOCKS.PARAGRAPH]: (node, children) => (
170
- <div className={[className, "whitespace-pre-line"].filter(Boolean).join(" ")}>
226
+ <div
227
+ className={[className, "whitespace-pre-line"]
228
+ .filter(Boolean)
229
+ .join(" ")}
230
+ >
171
231
  {isEmptyParagraph(node) ? "\n" : children}
172
232
  </div>
173
233
  ),
174
234
  [INLINES.HYPERLINK]: (node, children) => {
175
235
  const url = (node as any)?.data?.uri as string;
176
- const external = /^https?:\/\//.test(url);
236
+ const external = isExternalUrl(url);
177
237
 
178
- // Priority: 1. Manual flag from Contentful, 2. Regex check for external URLs
238
+ // Priority: 1. Manual flag from Contentful, 2. Same-domain URLs are internal
179
239
  const shouldOpenBlank = target ?? external;
180
240
 
181
241
  return (
182
242
  <Link
183
243
  href={url}
184
- target={shouldOpenBlank ? "_blank" : "_self"}
185
- rel={shouldOpenBlank ? "noopener noreferrer" : undefined}
244
+ target={shouldOpenBlank ? "_blank" : undefined}
245
+ rel={shouldOpenBlank ? "noopener" : undefined}
186
246
  variant="default"
187
247
  className={linkClassName}
188
248
  >