@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.
- package/dist/contentful/index.d.ts +104 -28
- package/dist/contentful/index.esm.js +3 -3
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +3 -3
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +2 -2
- package/dist/index.d.ts +4 -4
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/next/index.esm.js.map +1 -1
- package/dist/next/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/utils/index.d.ts +12 -1
- 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/components/next-image/index.tsx +3 -1
- package/src/contentful/blocks/accordion/Accordion.stories.mocks.tsx +8 -8
- package/src/contentful/blocks/accordion/Accordion.stories.tsx +5 -13
- package/src/contentful/blocks/address-input-banner/index.tsx +5 -5
- package/src/contentful/blocks/anchored-bottom-banner/index.tsx +114 -3
- package/src/contentful/blocks/anchored-bottom-banner/types.ts +4 -1
- package/src/contentful/blocks/blogs-grid-base/types.ts +1 -0
- package/src/contentful/blocks/callout/index.tsx +201 -37
- package/src/contentful/blocks/callout/types.ts +56 -3
- package/src/contentful/blocks/cards/floating-image-card/index.tsx +119 -0
- package/src/contentful/blocks/cards/floating-image-card/types.ts +30 -0
- package/src/contentful/blocks/cards/full-image-card/index.tsx +130 -0
- package/src/contentful/blocks/cards/full-image-card/types.ts +29 -0
- package/src/contentful/blocks/cards/simple-card/index.tsx +294 -58
- package/src/contentful/blocks/cards/simple-card/types.ts +47 -4
- package/src/contentful/blocks/cart-retention-banner/types.ts +2 -2
- package/src/contentful/blocks/comparison-table/index.tsx +5 -3
- package/src/contentful/blocks/email-input-block/index.tsx +1 -2
- package/src/contentful/blocks/footer/Footer.stories.tsx +145 -32
- package/src/contentful/index.ts +1 -2
- package/src/hooks/contentful/use-contentful-rich-text.tsx +9 -10
- package/src/utils/index.ts +3 -0
- package/src/utils/speed-card-bg.ts +24 -0
package/package.json
CHANGED
|
@@ -24,7 +24,9 @@ const resolveDefaultExport = (mod: unknown): unknown => {
|
|
|
24
24
|
}
|
|
25
25
|
return current;
|
|
26
26
|
};
|
|
27
|
-
const NextJsImage = resolveDefaultExport(
|
|
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
|
|
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
|
|
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
|
|
82
|
-
for online gaming, music and video streaming, uploading pictures
|
|
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
|
|
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 {
|
|
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: "
|
|
10
|
-
navy: { bg: "bg-
|
|
11
|
-
green: { bg: "bg-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
30
|
-
|
|
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,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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
<
|
|
188
|
+
<section
|
|
189
|
+
id={anchorId}
|
|
190
|
+
className={cx("component-container", sectionBgClass, containerClassName)}
|
|
191
|
+
style={sectionStyle}
|
|
192
|
+
>
|
|
57
193
|
<div
|
|
58
|
-
className={
|
|
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
|
|
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
|
-
</
|
|
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
|
-
|
|
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:
|
|
42
|
+
items: CalloutItem[];
|
|
13
43
|
maxWidth?: boolean;
|
|
14
|
-
|
|
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
|
};
|