@windstream/react-shared-components 0.1.70 → 0.1.72

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 (41) hide show
  1. package/dist/contentful/index.d.ts +104 -28
  2. package/dist/contentful/index.esm.js +3 -3
  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/core.d.ts +2 -2
  7. package/dist/index.d.ts +4 -4
  8. package/dist/index.esm.js.map +1 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/next/index.esm.js.map +1 -1
  11. package/dist/next/index.js.map +1 -1
  12. package/dist/styles.css +1 -1
  13. package/dist/utils/index.d.ts +12 -1
  14. package/dist/utils/index.esm.js +1 -1
  15. package/dist/utils/index.esm.js.map +1 -1
  16. package/dist/utils/index.js +1 -1
  17. package/dist/utils/index.js.map +1 -1
  18. package/package.json +1 -1
  19. package/src/components/next-image/index.tsx +3 -1
  20. package/src/contentful/blocks/accordion/Accordion.stories.mocks.tsx +8 -8
  21. package/src/contentful/blocks/accordion/Accordion.stories.tsx +5 -13
  22. package/src/contentful/blocks/address-input-banner/index.tsx +5 -5
  23. package/src/contentful/blocks/anchored-bottom-banner/index.tsx +114 -3
  24. package/src/contentful/blocks/anchored-bottom-banner/types.ts +4 -1
  25. package/src/contentful/blocks/blogs-grid-base/types.ts +1 -0
  26. package/src/contentful/blocks/callout/index.tsx +201 -37
  27. package/src/contentful/blocks/callout/types.ts +56 -3
  28. package/src/contentful/blocks/cards/floating-image-card/index.tsx +119 -0
  29. package/src/contentful/blocks/cards/floating-image-card/types.ts +30 -0
  30. package/src/contentful/blocks/cards/full-image-card/index.tsx +130 -0
  31. package/src/contentful/blocks/cards/full-image-card/types.ts +29 -0
  32. package/src/contentful/blocks/cards/simple-card/index.tsx +294 -58
  33. package/src/contentful/blocks/cards/simple-card/types.ts +47 -4
  34. package/src/contentful/blocks/cart-retention-banner/types.ts +2 -2
  35. package/src/contentful/blocks/comparison-table/index.tsx +5 -3
  36. package/src/contentful/blocks/email-input-block/index.tsx +1 -2
  37. package/src/contentful/blocks/footer/Footer.stories.tsx +145 -32
  38. package/src/contentful/index.ts +1 -2
  39. package/src/hooks/contentful/use-contentful-rich-text.tsx +9 -10
  40. package/src/utils/index.ts +3 -0
  41. package/src/utils/speed-card-bg.ts +24 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windstream/react-shared-components",
3
- "version": "0.1.70",
3
+ "version": "0.1.72",
4
4
  "type": "module",
5
5
  "description": "Shared React components for Kinetic applications",
6
6
  "main": "dist/index.js",
@@ -24,7 +24,9 @@ const resolveDefaultExport = (mod: unknown): unknown => {
24
24
  }
25
25
  return current;
26
26
  };
27
- const NextJsImage = resolveDefaultExport(NextJsImageImport) as typeof NextJsImageImport;
27
+ const NextJsImage = resolveDefaultExport(
28
+ NextJsImageImport
29
+ ) as typeof NextJsImageImport;
28
30
 
29
31
  export interface NextImageComponentProps extends NextImageProps {
30
32
  className?: string;
@@ -37,7 +37,7 @@ export const RICH_FAQ_ITEMS: AccordionItem[] = [
37
37
 
38
38
  <p>You can check your windstream.net emails in the following ways:</p>
39
39
 
40
- <ol className="list-decimal ml-6">
40
+ <ol className="ml-6 list-decimal">
41
41
  <li>
42
42
  Go to <strong>www.windstream.net</strong> and click{" "}
43
43
  <strong>Email</strong> in the top‑right of the menu bar.
@@ -60,7 +60,7 @@ export const RICH_FAQ_ITEMS: AccordionItem[] = [
60
60
  <strong>Wanting to set up a new .net email account?</strong>
61
61
  </p>
62
62
 
63
- <ul className="list-disc ml-6">
63
+ <ul className="ml-6 list-disc">
64
64
  <li>
65
65
  If you do not already have a windstream.net email account, new
66
66
  windstream.net email addresses are no longer being created.
@@ -78,9 +78,9 @@ export const RICH_FAQ_ITEMS: AccordionItem[] = [
78
78
  description: (
79
79
  <>
80
80
  <p>
81
- For a household with <strong>4+ internet users</strong> who use the web
82
- for online gaming, music and video streaming, uploading pictures and
83
- videos, as well as email and browsing, a{" "}
81
+ For a household with <strong>4+ internet users</strong> who use the
82
+ web for online gaming, music and video streaming, uploading pictures
83
+ and videos, as well as email and browsing, a{" "}
84
84
  <strong>1‑Gigabit plan</strong> would be best suited.
85
85
  </p>
86
86
 
@@ -117,12 +117,12 @@ export const RICH_FAQ_ITEMS: AccordionItem[] = [
117
117
  description: (
118
118
  <>
119
119
  <p>
120
- Yes, of course! You are under no obligation. If a situation arises that
121
- requires a change, simply give us a call at{" "}
120
+ Yes, of course! You are under no obligation. If a situation arises
121
+ that requires a change, simply give us a call at{" "}
122
122
  <strong>855‑939‑2381</strong>.
123
123
  </p>
124
124
  </>
125
125
  ),
126
126
  },
127
127
  ];
128
- ``
128
+ ``;
@@ -1,9 +1,9 @@
1
+ import { FAQ_ITEMS, RICH_FAQ_ITEMS } from "./Accordion.stories.mocks";
1
2
  import { Accordion } from "./index";
2
3
  import type { AccordionProps } from "./types";
3
- import { DocsPage } from "@shared/stories/DocsTemplate";
4
- import type { Meta, StoryObj, ArgTypes } from "@storybook/react";
5
4
 
6
- import { FAQ_ITEMS, RICH_FAQ_ITEMS } from "./Accordion.stories.mocks";
5
+ import { DocsPage } from "@shared/stories/DocsTemplate";
6
+ import type { ArgTypes, Meta, StoryObj } from "@storybook/react";
7
7
 
8
8
  /* ------------
9
9
  ArgTypes
@@ -20,15 +20,7 @@ const argTypes: ArgTypes<AccordionProps> = {
20
20
  },
21
21
  background: {
22
22
  control: { type: "select" as const },
23
- options: [
24
- "blue",
25
- "green",
26
- "yellow",
27
- "purple",
28
- "white",
29
- "navy",
30
- "cream500",
31
- ],
23
+ options: ["blue", "green", "yellow", "purple", "white", "navy", "cream500"],
32
24
  description: "Background color of the accordion",
33
25
  },
34
26
  enableHeading: {
@@ -103,4 +95,4 @@ export const WithRichTextContent = {
103
95
  title: "FAQs - Rich Content",
104
96
  items: RICH_FAQ_ITEMS,
105
97
  },
106
- };
98
+ };
@@ -6,9 +6,9 @@ import { cx } from "@shared/utils";
6
6
 
7
7
  const variantStyles: Record<string, { bg: string; text: string }> = {
8
8
  yellow: { bg: "bg-fill-brand-accent", text: "text" },
9
- white: { bg: "bg-white", text: "text" },
10
- navy: { bg: "bg-bg-fill-inverse", text: "text-inverse" },
11
- green: { bg: "bg-border-success", text: "text-inverse" },
9
+ white: { bg: "white", text: "text" },
10
+ navy: { bg: "bg-fill-inverse", text: "text-inverse" },
11
+ green: { bg: "bg-fill-success", text: "text-inverse" },
12
12
  };
13
13
 
14
14
  export const AddressInputBanner: FC<AddressInputBannerProps> = props => {
@@ -39,11 +39,11 @@ export const AddressInputBanner: FC<AddressInputBannerProps> = props => {
39
39
  top: `${navHeight}px`,
40
40
  }}
41
41
  className={cx(
42
- `sticky left-0 right-0 z-[89] w-full shadow-drop transition-all duration-200 lg:fixed bg-${style.bg} text-${style.text}`,
42
+ `sticky left-0 right-0 z-[89] w-full shadow-drop transition-all duration-200 bg-${style.bg} text-${style.text}`,
43
43
  "flex flex-col items-center justify-center gap-3 p-[10px] lg:flex-row lg:gap-8 lg:px-6 lg:py-[10px]"
44
44
  )}
45
45
  >
46
- <Text className="label3 w-full text-center text-text lg:w-auto lg:text-left">
46
+ <Text className="label3 w-full text-center md:label1 lg:w-auto lg:text-left">
47
47
  {title}
48
48
  </Text>
49
49
  {renderedCheckPlans}
@@ -1,9 +1,34 @@
1
- import React from "react";
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from "react";
2
8
  import { AnchoredBottomBannerProps } from "./types";
3
9
  import Link from "next/link";
4
10
 
5
11
  import { MaterialIcon } from "@shared/components/material-icon";
6
12
 
13
+ function parseCountdownDateTime(value?: string): number | undefined {
14
+ if (!value) return undefined;
15
+ const parsed = Date.parse(value);
16
+ if (!Number.isFinite(parsed)) {
17
+ console.error("Invalid countdown datetime", { value });
18
+ return undefined;
19
+ }
20
+ return parsed;
21
+ }
22
+
23
+ function formatCountdown(totalSeconds: number) {
24
+ const safeSeconds = Math.max(0, Math.floor(totalSeconds));
25
+ const hours = Math.floor(safeSeconds / 3600);
26
+ const minutes = Math.floor((safeSeconds % 3600) / 60);
27
+ const seconds = safeSeconds % 60;
28
+
29
+ return `${String(hours).padStart(2, "0")}H : ${String(minutes).padStart(2, "0")}M : ${String(seconds).padStart(2, "0")}`;
30
+ }
31
+
7
32
  export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
8
33
  ctaSuffixText,
9
34
  backgroundColor,
@@ -13,6 +38,9 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
13
38
  ctaButtonLink,
14
39
  ctaButtonTarget,
15
40
  anchorId = "anchored-banner",
41
+ enableCountdownTimer,
42
+ countdownStartDateTime,
43
+ countdownEndDateTime,
16
44
  }) => {
17
45
  const backGroundColorClasses = {
18
46
  navy: "bg-bg-fill-inverse",
@@ -20,14 +48,91 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
20
48
  blue: "bg-bg-fill-brand-supporting",
21
49
  purple: "bg-bg-fill-brand-tertiary",
22
50
  yellow: "bg-bg-fill-brand-accent",
51
+ white: "bg-white",
23
52
  };
24
53
 
25
54
  const bgClass = backgroundColor
26
55
  ? backGroundColorClasses[backgroundColor]
27
56
  : "bg-bg-fill-brand-accent";
28
57
 
29
- const isYellow = backgroundColor === "yellow" || !backgroundColor;
30
- const textColorClass = isYellow ? "text-text-primary" : "text-white";
58
+ const isLightBackground =
59
+ backgroundColor === "yellow" ||
60
+ backgroundColor === "white" ||
61
+ !backgroundColor;
62
+ const textColorClass = isLightBackground ? "text-text-primary" : "text-white";
63
+
64
+ // Memoize parsed timestamps so they aren't re-parsed every second
65
+ const endMs = useMemo(
66
+ () => parseCountdownDateTime(countdownEndDateTime),
67
+ [countdownEndDateTime]
68
+ );
69
+ const startMs = useMemo(
70
+ () => parseCountdownDateTime(countdownStartDateTime),
71
+ [countdownStartDateTime]
72
+ );
73
+
74
+ const isTimerValid = useMemo(() => {
75
+ if (!enableCountdownTimer || endMs === undefined) return false;
76
+ if (countdownStartDateTime && startMs === undefined) return false;
77
+ if (startMs !== undefined && startMs >= endMs) {
78
+ console.error("Invalid countdown range: start must be before end", {
79
+ countdownStartDateTime,
80
+ countdownEndDateTime,
81
+ });
82
+ return false;
83
+ }
84
+ return true;
85
+ }, [
86
+ enableCountdownTimer,
87
+ endMs,
88
+ startMs,
89
+ countdownStartDateTime,
90
+ countdownEndDateTime,
91
+ ]);
92
+
93
+ const [nowMs, setNowMs] = useState(() => Date.now());
94
+ const intervalRef = useRef<number | null>(null);
95
+
96
+ const clearTimer = useCallback(() => {
97
+ if (intervalRef.current !== null) {
98
+ window.clearInterval(intervalRef.current);
99
+ intervalRef.current = null;
100
+ }
101
+ }, []);
102
+
103
+ useEffect(() => {
104
+ if (!isTimerValid) {
105
+ clearTimer();
106
+ return;
107
+ }
108
+
109
+ intervalRef.current = window.setInterval(() => {
110
+ const now = Date.now();
111
+ // Auto-clear interval once countdown expires
112
+ if (endMs !== undefined && now >= endMs) {
113
+ clearTimer();
114
+ }
115
+ setNowMs(now);
116
+ }, 1000);
117
+
118
+ return clearTimer;
119
+ }, [isTimerValid, endMs, clearTimer]);
120
+
121
+ const countdown = useMemo(() => {
122
+ if (!isTimerValid || endMs === undefined) {
123
+ return { shouldShow: false, text: "" };
124
+ }
125
+
126
+ const isBeforeStart = startMs !== undefined && nowMs < startMs;
127
+ const isAfterEnd = nowMs >= endMs;
128
+ if (isBeforeStart || isAfterEnd) return { shouldShow: false, text: "" };
129
+
130
+ const remainingSeconds = (endMs - nowMs) / 1000;
131
+ return {
132
+ shouldShow: remainingSeconds > 0,
133
+ text: formatCountdown(remainingSeconds),
134
+ };
135
+ }, [isTimerValid, endMs, startMs, nowMs]);
31
136
 
32
137
  return (
33
138
  <section id={anchorId}>
@@ -57,6 +162,12 @@ export const AnchoredBottomBanner: React.FC<AnchoredBottomBannerProps> = ({
57
162
  className={`${textColorClass} align-text-bottom`}
58
163
  />
59
164
  )}
165
+ {countdown.shouldShow && (
166
+ <span className="inline-block whitespace-nowrap rounded-lg bg-white px-1 tabular-nums text-text">
167
+ {countdown.text}
168
+ </span>
169
+ )}
170
+ {countdown.shouldShow ? " " : null}
60
171
  {ctaButtonLabel && ctaButtonLabel}{" "}
61
172
  {ctaSuffixText && <span className="ml-0.5">{ctaSuffixText}</span>}
62
173
  </div>
@@ -1,10 +1,13 @@
1
1
  export interface AnchoredBottomBannerProps {
2
2
  ctaSuffixText?: string;
3
- backgroundColor?: "navy" | "yellow" | "green" | "purple" | "blue";
3
+ backgroundColor?: "navy" | "yellow" | "green" | "purple" | "blue" | "white";
4
4
  iconName?: string;
5
5
  boxShadow?: boolean;
6
6
  ctaButtonLabel?: string;
7
7
  ctaButtonLink?: string;
8
8
  ctaButtonTarget?: string;
9
9
  anchorId?: string;
10
+ enableCountdownTimer?: boolean;
11
+ countdownStartDateTime?: string;
12
+ countdownEndDateTime?: string;
10
13
  }
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+
2
3
  import { BlogCardImageProps } from "@shared/contentful/blocks/cards/blog-card/types";
3
4
 
4
5
  export interface BlogCardProps {
@@ -1,64 +1,205 @@
1
1
  import React from "react";
2
+ import { Button } from "../button";
2
3
  import BlogCard from "../cards/blog-card";
4
+ import FloatingImageCard from "../cards/floating-image-card";
5
+ import FullImageCard from "../cards/full-image-card";
3
6
  import SimpleCard from "../cards/simple-card";
4
- import { CalloutProps } from "./types";
7
+ import { CalloutCardType, CalloutItem, CalloutProps } from "./types";
5
8
 
6
9
  import { Text } from "@shared/components/text";
10
+ import { cx } from "@shared/utils";
11
+
12
+ const backgroundClassMap: Record<string, string> = {
13
+ cream500: "bg-[#FFFEEF]",
14
+ gray100: "bg-fill-secondary",
15
+ white: "bg-white",
16
+ transparent: "",
17
+ blue: "bg-fill-brand",
18
+ green: "bg-fill-brand-accent",
19
+ navy: "bg-fill-inverse",
20
+ purple: "bg-fill-brand-tertiary",
21
+ yellow: "bg-[#F5FF1E]",
22
+ };
23
+
24
+ // Literal class strings (Tailwind JIT only picks up literal tokens; do not
25
+ // build these by concatenation at runtime).
26
+ const baseColMap: Record<number, string> = {
27
+ 1: "grid-cols-1",
28
+ 2: "grid-cols-2",
29
+ 3: "grid-cols-3",
30
+ 4: "grid-cols-4",
31
+ 5: "grid-cols-5",
32
+ 6: "grid-cols-6",
33
+ };
34
+ const lgColMap: Record<number, string> = {
35
+ 1: "lg:grid-cols-1",
36
+ 2: "lg:grid-cols-2",
37
+ 3: "lg:grid-cols-3",
38
+ 4: "lg:grid-cols-4",
39
+ 5: "lg:grid-cols-5",
40
+ 6: "lg:grid-cols-6",
41
+ };
42
+ const xlColMap: Record<number, string> = {
43
+ 1: "xl:grid-cols-1",
44
+ 2: "xl:grid-cols-2",
45
+ 3: "xl:grid-cols-3",
46
+ 4: "xl:grid-cols-4",
47
+ 5: "xl:grid-cols-5",
48
+ 6: "xl:grid-cols-6",
49
+ };
50
+
51
+ /**
52
+ * Mirrors the local @ui Callout `calculateOptimalColumns` logic:
53
+ * - ≤4 cards: one per column
54
+ * - divisible by 3: 3 cols
55
+ * - divisible by 4: 4 cols
56
+ * - >6 cards: 4 cols
57
+ * - else: 3 cols
58
+ */
59
+ const calculateOptimalColumns = (count: number): number => {
60
+ if (count <= 4) return count || 1;
61
+ if (count % 3 === 0) return 3;
62
+ if (count % 4 === 0) return 4;
63
+ if (count > 6) return 4;
64
+ return 3;
65
+ };
66
+
67
+ const clampCol = (n: number) => Math.max(1, Math.min(6, n));
7
68
 
8
69
  export const Callout: React.FC<CalloutProps> = ({
70
+ anchorId,
9
71
  title,
10
72
  items,
11
73
  enableHeading = false,
12
74
  subtitle,
75
+ description,
76
+ finePrint,
77
+ cta,
13
78
  color = "dark",
14
79
  maxWidth = true,
15
80
  maxCardsPerRow,
16
81
  cardType = "simple",
82
+ backgroundColor,
83
+ background,
84
+ textColor,
85
+ containerClassName,
86
+ innerClassName,
87
+ applyBoxShadow = false,
88
+ cardStackingMobile = true,
89
+ cardsWidth = true,
90
+ noGutter = false,
17
91
  }) => {
18
- const lgWidth =
19
- {
20
- 1: " lg:w-full",
21
- 2: " lg:w-[calc(50%-0.75rem)]",
22
- 3: " lg:w-[calc(33.3333%-1rem)]",
23
- 4: " lg:w-[calc(25%-1.125rem)]",
24
- }[maxCardsPerRow || 4] || " lg:w-[calc(25%-1.125rem)]";
92
+ const itemCount = items?.length ?? 0;
93
+ const desktopCols = clampCol(
94
+ maxCardsPerRow ?? calculateOptimalColumns(itemCount)
95
+ );
96
+ const lgCols = clampCol(Math.min(desktopCols, itemCount || desktopCols));
25
97
 
26
- const mdWidth = items.length === 1 ? " md:w-full" : " md:w-[calc(50%-1rem)]";
98
+ // Mobile / md: 1 col when stacking flag is on, else 2 (or 1 when single).
99
+ const mobileCols = clampCol(cardStackingMobile ? 1 : itemCount === 1 ? 1 : 2);
27
100
 
28
- const renderCard = (item: CalloutProps["items"][number], index: number) => {
29
- if (cardType === "blog") {
30
- const blogItem = item as any;
31
- return (
32
- <BlogCard
33
- title={blogItem.title}
34
- href={blogItem.slug}
35
- description={blogItem.shortDescription}
36
- date={blogItem.blogCreationDate}
37
- category={blogItem.category}
38
- image={blogItem.cover}
39
- asGrid={false}
40
- lgWidth={lgWidth}
41
- mdWidth={mdWidth}
42
- />
101
+ // When cardsWidth is false: full-width stacked layout (no grid).
102
+ const gridClass = cardsWidth
103
+ ? cx(
104
+ "grid items-stretch self-stretch",
105
+ noGutter ? "gap-0" : "gap-6",
106
+ baseColMap[mobileCols],
107
+ lgColMap[lgCols],
108
+ xlColMap[desktopCols]
109
+ )
110
+ : cx(
111
+ "flex flex-col items-stretch self-stretch",
112
+ noGutter ? "gap-0" : "gap-6"
43
113
  );
114
+
115
+ const renderCard = (item: CalloutItem, index: number) => {
116
+ const itemCardType: CalloutCardType = item.cardType ?? cardType;
117
+
118
+ // When cardsWidth is true we control widths via grid columns, so do
119
+ // NOT pass legacy lgWidth/mdWidth (which would force fixed pixel
120
+ // widths and break the responsive grid).
121
+ const widthProps = cardsWidth
122
+ ? {}
123
+ : { lgWidth: undefined, mdWidth: undefined };
124
+
125
+ switch (itemCardType) {
126
+ case "blog": {
127
+ const blogItem = item as any;
128
+ return (
129
+ <BlogCard
130
+ key={index}
131
+ title={blogItem.title}
132
+ href={blogItem.slug}
133
+ description={blogItem.shortDescription}
134
+ date={blogItem.blogCreationDate}
135
+ category={blogItem.category}
136
+ image={blogItem.cover}
137
+ asGrid={false}
138
+ {...widthProps}
139
+ />
140
+ );
141
+ }
142
+ case "fullImage":
143
+ return (
144
+ <FullImageCard
145
+ key={index}
146
+ card={{
147
+ ...(item as any),
148
+ shadow: (item as any).shadow ?? applyBoxShadow,
149
+ }}
150
+ {...widthProps}
151
+ />
152
+ );
153
+ case "floatingImage":
154
+ return (
155
+ <FloatingImageCard
156
+ key={index}
157
+ card={{
158
+ ...(item as any),
159
+ shadow: (item as any).shadow ?? applyBoxShadow,
160
+ }}
161
+ {...widthProps}
162
+ />
163
+ );
164
+ case "simple":
165
+ default:
166
+ return (
167
+ <SimpleCard
168
+ key={index}
169
+ card={{
170
+ ...(item as any),
171
+ shadow: (item as any).shadow ?? applyBoxShadow,
172
+ }}
173
+ {...widthProps}
174
+ />
175
+ );
44
176
  }
45
- return (
46
- <SimpleCard
47
- key={index}
48
- card={item as any}
49
- lgWidth={lgWidth}
50
- mdWidth={mdWidth}
51
- />
52
- );
53
177
  };
54
178
 
179
+ const sectionStyle = background ? { background } : undefined;
180
+ const headingStyle = textColor ? { color: textColor } : undefined;
181
+ const sectionBgClass = background
182
+ ? ""
183
+ : backgroundColor
184
+ ? (backgroundClassMap[backgroundColor] ?? "")
185
+ : "";
186
+
55
187
  return (
56
- <div className="component-container">
188
+ <section
189
+ id={anchorId}
190
+ className={cx("component-container", sectionBgClass, containerClassName)}
191
+ style={sectionStyle}
192
+ >
57
193
  <div
58
- className={`mx-5 mb-5 mt-12 ${maxWidth ? "max-w-120 xl:mx-auto" : ""} ${color == "dark" ? "text-text" : "text-white"}`}
194
+ className={cx(
195
+ noGutter ? "p-0" : "mx-5 mb-5 mt-12",
196
+ maxWidth && "max-w-120 xl:mx-auto",
197
+ color === "dark" ? "text-text" : "text-white",
198
+ innerClassName
199
+ )}
59
200
  >
60
201
  <div className="callout-container flex flex-col gap-10 md:gap-16">
61
- <div className="title-holder">
202
+ <div className="title-holder" style={headingStyle}>
62
203
  {title && (
63
204
  <Text
64
205
  as={enableHeading ? "h1" : "h2"}
@@ -75,13 +216,36 @@ export const Callout: React.FC<CalloutProps> = ({
75
216
  {subtitle}
76
217
  </Text>
77
218
  )}
219
+ {description && (
220
+ <Text as="p" className="body1 mt-4 text-center md:mt-6">
221
+ {description}
222
+ </Text>
223
+ )}
78
224
  </div>
79
- <div className="card-holder flex flex-wrap items-stretch justify-center gap-6 self-stretch md:gap-6">
225
+ <div className={cx("card-holder", gridClass)}>
80
226
  {items.map((item, index: number) => renderCard(item, index))}
81
227
  </div>
228
+ {(cta || finePrint) && (
229
+ <div className="flex flex-col items-center gap-4">
230
+ {cta ? (
231
+ <Button
232
+ linkClassName="label1"
233
+ buttonClassName="label1"
234
+ {...cta}
235
+ >
236
+ {cta.label ?? cta.buttonLabel}
237
+ </Button>
238
+ ) : null}
239
+ {finePrint ? (
240
+ <Text as="div" className="footnote text-center text-text">
241
+ {finePrint}
242
+ </Text>
243
+ ) : null}
244
+ </div>
245
+ )}
82
246
  </div>
83
247
  </div>
84
- </div>
248
+ </section>
85
249
  );
86
250
  };
87
251
 
@@ -1,15 +1,68 @@
1
+ import type { ReactNode } from "react";
2
+ import type { ButtonProps } from "../button/types";
3
+
4
+ export type CalloutCardType = "simple" | "blog" | "fullImage" | "floatingImage";
5
+
6
+ export type CalloutCtaProps = ButtonProps & {
7
+ label?: string;
8
+ };
9
+
10
+ export type CalloutItem = {
11
+ /**
12
+ * Optional per-item card type. When provided, overrides the top-level
13
+ * `cardType` for this single item — enables mixed card variants in one
14
+ * Callout (e.g. one full-image + two simple cards).
15
+ */
16
+ cardType?: CalloutCardType;
17
+ // Permissive shape — concrete card components validate their own subset.
18
+ [key: string]: any;
19
+ };
20
+
1
21
  export type CalloutProps = {
22
+ /** Outer `<section>` id — anchor link target. */
23
+ anchorId?: string;
2
24
  title?: string;
3
25
  enableHeading?: boolean;
4
26
  subtitle?: string;
27
+ description?: string;
28
+ finePrint?: ReactNode;
29
+ cta?: CalloutCtaProps;
5
30
  applyBoxShadow?: boolean;
6
31
  cardStackingMobile?: boolean;
7
32
  bottomText?: string;
8
33
  color?: "dark" | "light";
9
- cardsWidth?: string;
34
+ /**
35
+ * When `true` (default) cards are laid out in a responsive Tailwind
36
+ * grid sized by `maxCardsPerRow`. When `false` the cards stretch
37
+ * full-width and stack vertically (no inner widths).
38
+ */
39
+ cardsWidth?: boolean;
10
40
  maxCardsPerRow?: number;
11
41
  noGutter?: boolean;
12
- items: any[];
42
+ items: CalloutItem[];
13
43
  maxWidth?: boolean;
14
- cardType?: "simple" | "blog";
44
+ /** Top-level card type used when an item does not specify its own. */
45
+ cardType?: CalloutCardType;
46
+ /**
47
+ * Background color token. When omitted the section has no background
48
+ * (preserves prior behavior for existing 0.1.70 consumers).
49
+ */
50
+ backgroundColor?:
51
+ | "cream500"
52
+ | "gray100"
53
+ | "white"
54
+ | "transparent"
55
+ | "blue"
56
+ | "green"
57
+ | "navy"
58
+ | "purple"
59
+ | "yellow";
60
+ /** Raw background CSS value (overrides `backgroundColor` when present). */
61
+ background?: string;
62
+ /** Inline text color override applied to the heading region. */
63
+ textColor?: string;
64
+ /** Extra class names for the outer <section>. */
65
+ containerClassName?: string;
66
+ /** Extra class names for the inner content wrapper. */
67
+ innerClassName?: string;
15
68
  };