@windstream/react-shared-components 0.2.60 → 0.2.61
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 +27 -1
- package/dist/contentful/index.esm.js +2 -2
- 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 +1 -1
- 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/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/brand-button/index.tsx +1 -1
- package/src/contentful/blocks/cards/product-card/index.test.tsx +14 -1
- package/src/contentful/blocks/cards/product-card/index.tsx +189 -136
- package/src/contentful/blocks/cards/product-card/types.ts +1 -1
- package/src/contentful/blocks/carousel/Carousel.stories.tsx +59 -0
- package/src/contentful/blocks/carousel/helper.test.tsx +23 -0
- package/src/contentful/blocks/carousel/helper.tsx +18 -5
- package/src/contentful/blocks/carousel/types.ts +3 -0
- package/src/contentful/blocks/dynamic-form/conditional-fields.test.tsx +3 -0
- package/src/contentful/blocks/dynamic-form/conditional-fields.tsx +30 -4
- package/src/contentful/blocks/dynamic-form/context.ts +2 -0
- package/src/contentful/blocks/dynamic-form/field-map.test.tsx +21 -0
- package/src/contentful/blocks/dynamic-form/field-map.tsx +15 -0
- package/src/contentful/blocks/dynamic-form/form-field.tsx +2 -0
- package/src/contentful/blocks/dynamic-form/index.tsx +48 -16
- package/src/contentful/blocks/dynamic-form/render.tsx +1 -0
- package/src/contentful/blocks/dynamic-form/types.ts +26 -0
- package/src/contentful/blocks/dynamic-form/utils.test.ts +86 -0
- package/src/contentful/blocks/dynamic-form/utils.ts +57 -0
|
@@ -11,6 +11,20 @@ import { SelectPlanButton } from "@shared/components/select-plan-button";
|
|
|
11
11
|
import { Text } from "@shared/components/text";
|
|
12
12
|
import { cx } from "@shared/utils";
|
|
13
13
|
|
|
14
|
+
/** Renders text where `|...|`-wrapped segments are bold (pipes are stripped). */
|
|
15
|
+
const renderPipeBold = (value: React.ReactNode): React.ReactNode => {
|
|
16
|
+
if (typeof value !== "string" || !value.includes("|")) return value;
|
|
17
|
+
return value.split("|").map((segment, index) =>
|
|
18
|
+
index % 2 === 1 ? (
|
|
19
|
+
<span key={index} className="font-bold">
|
|
20
|
+
{segment}
|
|
21
|
+
</span>
|
|
22
|
+
) : (
|
|
23
|
+
<React.Fragment key={index}>{segment}</React.Fragment>
|
|
24
|
+
)
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
|
|
14
28
|
export const ProductCard: React.FC<ProductCardProps> = ({
|
|
15
29
|
planName,
|
|
16
30
|
planSubtext,
|
|
@@ -31,6 +45,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|
|
31
45
|
cta,
|
|
32
46
|
techType,
|
|
33
47
|
ismaxSpeed,
|
|
48
|
+
priceSuffixBottom,
|
|
34
49
|
}) => {
|
|
35
50
|
const [internalExpanded, setInternalExpanded] = useState(false);
|
|
36
51
|
// Use parent state if provided, otherwise use internal state
|
|
@@ -77,7 +92,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|
|
77
92
|
<Text
|
|
78
93
|
as="h3"
|
|
79
94
|
className={cx(
|
|
80
|
-
"subheading2",
|
|
95
|
+
"subheading2 lowercase",
|
|
81
96
|
isDark ? "text-text-accent-on-bg-inverse" : "text-text-brand"
|
|
82
97
|
)}
|
|
83
98
|
>
|
|
@@ -85,46 +100,65 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|
|
85
100
|
</Text>
|
|
86
101
|
<Text
|
|
87
102
|
as="p"
|
|
88
|
-
className={`label3 text-wrap ${isDark ? "text-text-accent-on-bg-inverse" : "text-text-brand"}`}
|
|
89
|
-
>
|
|
90
|
-
{planSubtext}
|
|
91
|
-
</Text>
|
|
92
|
-
</div>
|
|
93
|
-
<div className="flex items-start">
|
|
94
|
-
<Text
|
|
95
|
-
as="span"
|
|
96
|
-
className={cx(
|
|
97
|
-
"label2 mr-1 mt-[2px]",
|
|
98
|
-
isDark ? "text-text-inverse" : "text-text"
|
|
99
|
-
)}
|
|
100
|
-
>
|
|
101
|
-
$
|
|
102
|
-
</Text>
|
|
103
|
-
<Text
|
|
104
|
-
as="span"
|
|
105
103
|
className={cx(
|
|
106
|
-
"
|
|
107
|
-
isDark ? "text-text-inverse" : "text-text"
|
|
108
|
-
)}
|
|
109
|
-
>
|
|
110
|
-
{price.split(".")[0]}
|
|
111
|
-
</Text>
|
|
112
|
-
<Text
|
|
113
|
-
as="span"
|
|
114
|
-
className={cx(
|
|
115
|
-
"label2",
|
|
116
|
-
isDark ? "text-text-inverse" : "text-text"
|
|
104
|
+
"label3 text-wrap lowercase",
|
|
105
|
+
isDark ? "text-text-accent-on-bg-inverse" : "text-text-brand"
|
|
117
106
|
)}
|
|
118
107
|
>
|
|
119
|
-
{
|
|
108
|
+
{planSubtext}
|
|
120
109
|
</Text>
|
|
121
110
|
</div>
|
|
111
|
+
<div className="flex flex-col items-end">
|
|
112
|
+
<div className="flex items-start">
|
|
113
|
+
<Text
|
|
114
|
+
as="span"
|
|
115
|
+
className={cx(
|
|
116
|
+
"label2 mr-1 mt-[2px]",
|
|
117
|
+
isDark ? "text-text-inverse" : "text-text"
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
$
|
|
121
|
+
</Text>
|
|
122
|
+
<Text
|
|
123
|
+
as="span"
|
|
124
|
+
className={cx(
|
|
125
|
+
"subheading6",
|
|
126
|
+
isDark ? "text-text-inverse" : "text-text"
|
|
127
|
+
)}
|
|
128
|
+
>
|
|
129
|
+
{price.split(".")[0]}
|
|
130
|
+
</Text>
|
|
131
|
+
<Text
|
|
132
|
+
as="span"
|
|
133
|
+
className={cx(
|
|
134
|
+
"label2",
|
|
135
|
+
isDark ? "text-text-inverse" : "text-text"
|
|
136
|
+
)}
|
|
137
|
+
>
|
|
138
|
+
{price.split(".")[1]}/mo
|
|
139
|
+
</Text>
|
|
140
|
+
</div>
|
|
141
|
+
{priceSuffixBottom ? (
|
|
142
|
+
<Text
|
|
143
|
+
as="span"
|
|
144
|
+
className={cx(
|
|
145
|
+
"footnote text-nowrap",
|
|
146
|
+
isDark ? "text-text-inverse" : "text-text"
|
|
147
|
+
)}
|
|
148
|
+
>
|
|
149
|
+
{priceSuffixBottom}
|
|
150
|
+
</Text>
|
|
151
|
+
) : null}
|
|
152
|
+
</div>
|
|
122
153
|
</header>
|
|
123
154
|
|
|
124
155
|
{/* Description */}
|
|
125
156
|
<section>
|
|
126
157
|
<Text
|
|
127
|
-
className={cx(
|
|
158
|
+
className={cx(
|
|
159
|
+
"body2 h-[56px]",
|
|
160
|
+
isDark ? "text-text-inverse" : "text-text"
|
|
161
|
+
)}
|
|
128
162
|
>
|
|
129
163
|
{description}
|
|
130
164
|
</Text>
|
|
@@ -132,118 +166,137 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|
|
132
166
|
|
|
133
167
|
{/* Gift Badge */}
|
|
134
168
|
{giftBadge?.length ? (
|
|
135
|
-
<div
|
|
136
|
-
className={cx(
|
|
137
|
-
"flex-col items-center rounded-surface-sm p-2",
|
|
138
|
-
isDark ? "bg-bg-surface-inverse" : "bg-bg-surface-info"
|
|
139
|
-
)}
|
|
140
|
-
>
|
|
141
|
-
{giftBadge?.map((badge: any) => {
|
|
142
|
-
return (
|
|
143
|
-
<div key={badge.title} className="flex items-center gap-2">
|
|
144
|
-
<Image
|
|
145
|
-
src={badge.icon}
|
|
146
|
-
alt={badge.title}
|
|
147
|
-
width={24}
|
|
148
|
-
height={24}
|
|
149
|
-
/>
|
|
150
|
-
<Text
|
|
151
|
-
as="div"
|
|
152
|
-
className={cx(
|
|
153
|
-
"body3",
|
|
154
|
-
isDark ? "text-text-inverse" : "text-text"
|
|
155
|
-
)}
|
|
156
|
-
>
|
|
157
|
-
{badge.title}
|
|
158
|
-
</Text>
|
|
159
|
-
</div>
|
|
160
|
-
);
|
|
161
|
-
})}
|
|
162
|
-
</div>
|
|
163
|
-
) : null}
|
|
164
|
-
|
|
165
|
-
{/* CTA */}
|
|
166
|
-
<SelectPlanButton
|
|
167
|
-
onSelect={onCtaClick}
|
|
168
|
-
speed={speed}
|
|
169
|
-
isSelected={isDark}
|
|
170
|
-
renderCheckPlans={renderCheckPlans}
|
|
171
|
-
cta={cta}
|
|
172
|
-
iconSize={40}
|
|
173
|
-
techType={techType}
|
|
174
|
-
isMax={ismaxSpeed}
|
|
175
|
-
/>
|
|
176
|
-
{/* Features */}
|
|
177
|
-
{features.length > 0 && (
|
|
178
|
-
<section className="flex flex-col gap-3">
|
|
179
|
-
<Button
|
|
180
|
-
onClick={handleToggle}
|
|
181
|
-
className="group flex w-full items-center gap-2 text-left"
|
|
182
|
-
>
|
|
183
|
-
<Text
|
|
184
|
-
as="h4"
|
|
185
|
-
className={cx(
|
|
186
|
-
"label3",
|
|
187
|
-
isDark ? "text-text-inverse" : "text-text"
|
|
188
|
-
)}
|
|
189
|
-
>
|
|
190
|
-
{featuresTitle}
|
|
191
|
-
</Text>
|
|
192
|
-
<MaterialIcon
|
|
193
|
-
name="keyboard_arrow_down"
|
|
194
|
-
fill={1}
|
|
195
|
-
size={24}
|
|
196
|
-
className={cx(
|
|
197
|
-
"transition-transform duration-300",
|
|
198
|
-
isExpanded && "rotate-180",
|
|
199
|
-
isDark ? "text-text-inverse" : "text-text"
|
|
200
|
-
)}
|
|
201
|
-
/>
|
|
202
|
-
</Button>
|
|
169
|
+
<div className="min-h-[76px]">
|
|
203
170
|
<div
|
|
204
171
|
className={cx(
|
|
205
|
-
"
|
|
206
|
-
|
|
172
|
+
"flex-col items-center rounded-surface-sm p-2",
|
|
173
|
+
isDark ? "bg-bg-surface-inverse" : "bg-bg-surface-info"
|
|
207
174
|
)}
|
|
208
175
|
>
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
176
|
+
{giftBadge?.map((badge: any) => {
|
|
177
|
+
return (
|
|
178
|
+
<div key={badge.title} className="flex items-center gap-2">
|
|
179
|
+
<Image
|
|
180
|
+
src={badge.icon}
|
|
181
|
+
alt={badge.title}
|
|
182
|
+
width={24}
|
|
183
|
+
height={24}
|
|
184
|
+
/>
|
|
185
|
+
<Text
|
|
186
|
+
as="div"
|
|
187
|
+
className={cx(
|
|
188
|
+
"body3",
|
|
189
|
+
isDark ? "text-text-inverse" : "text-text"
|
|
190
|
+
)}
|
|
191
|
+
>
|
|
192
|
+
{renderPipeBold(badge.title)}
|
|
193
|
+
</Text>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
})}
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
) : (
|
|
200
|
+
<div className="invisible min-h-[76px] p-2" aria-hidden="true">
|
|
201
|
+
<Text
|
|
202
|
+
as="div"
|
|
203
|
+
className={cx(
|
|
204
|
+
"body3",
|
|
205
|
+
isDark ? "text-text-inverse" : "text-text"
|
|
206
|
+
)}
|
|
207
|
+
>
|
|
208
|
+
|
|
209
|
+
</Text>
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{/* CTA + features pinned to the bottom so they align across cards of
|
|
214
|
+
differing content height (e.g. with/without a gift badge). */}
|
|
215
|
+
<div className="flex flex-col gap-5">
|
|
216
|
+
<SelectPlanButton
|
|
217
|
+
onSelect={onCtaClick}
|
|
218
|
+
speed={speed}
|
|
219
|
+
isSelected={isDark}
|
|
220
|
+
renderCheckPlans={renderCheckPlans}
|
|
221
|
+
cta={cta}
|
|
222
|
+
iconSize={40}
|
|
223
|
+
techType={techType}
|
|
224
|
+
isMax={ismaxSpeed}
|
|
225
|
+
/>
|
|
226
|
+
{/* Features */}
|
|
227
|
+
{features.length > 0 && (
|
|
228
|
+
<section className="flex flex-col gap-3">
|
|
229
|
+
<Button
|
|
230
|
+
onClick={handleToggle}
|
|
231
|
+
className="group flex w-full items-center gap-2 text-left"
|
|
232
|
+
>
|
|
233
|
+
<Text
|
|
234
|
+
as="h4"
|
|
235
|
+
className={cx(
|
|
236
|
+
"label3",
|
|
237
|
+
isDark ? "text-text-inverse" : "text-text"
|
|
238
|
+
)}
|
|
239
|
+
>
|
|
240
|
+
{featuresTitle}
|
|
241
|
+
</Text>
|
|
242
|
+
<MaterialIcon
|
|
243
|
+
name="keyboard_arrow_down"
|
|
244
|
+
fill={1}
|
|
245
|
+
size={24}
|
|
246
|
+
className={cx(
|
|
247
|
+
"transition-transform duration-300",
|
|
248
|
+
isExpanded && "rotate-180",
|
|
249
|
+
isDark ? "text-text-inverse" : "text-text"
|
|
250
|
+
)}
|
|
218
251
|
/>
|
|
219
|
-
</
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
252
|
+
</Button>
|
|
253
|
+
<div
|
|
254
|
+
className={cx(
|
|
255
|
+
"overflow-hidden transition-all duration-200 ease-in-out",
|
|
256
|
+
isExpanded ? "opacity-100" : "max-h-0 opacity-0"
|
|
257
|
+
)}
|
|
258
|
+
>
|
|
259
|
+
<div className="flex flex-col">
|
|
260
|
+
<Checklist
|
|
261
|
+
items={features}
|
|
262
|
+
iconSize={24}
|
|
263
|
+
iconPosition="top"
|
|
264
|
+
iconClassName="mt-0"
|
|
265
|
+
iconColor={isDark ? "yellow" : "green"}
|
|
266
|
+
listContainerClassName="mt-0 space-y-0 flex flex-col gap-3"
|
|
267
|
+
listItemClassName={`${isDark ? "text-text-inverse" : "text-text"}`}
|
|
227
268
|
/>
|
|
228
|
-
<span className="footnote text-text">
|
|
229
|
-
{innerBadge.badgeText.includes("|") ? (
|
|
230
|
-
<>
|
|
231
|
-
<span className="font-bold">
|
|
232
|
-
{innerBadge.badgeText.split("|")[0]}
|
|
233
|
-
</span>
|
|
234
|
-
<span className="font-normal">
|
|
235
|
-
{innerBadge.badgeText.split("|")[1]}
|
|
236
|
-
</span>
|
|
237
|
-
</>
|
|
238
|
-
) : (
|
|
239
|
-
<Text className="font-bold">{innerBadge.badgeText}</Text>
|
|
240
|
-
)}
|
|
241
|
-
</span>
|
|
242
269
|
</div>
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
270
|
+
{innerBadge?.badgeIcon ? (
|
|
271
|
+
<div className="mt-3 flex items-center gap-2 text-center">
|
|
272
|
+
<Image
|
|
273
|
+
src={innerBadge.badgeIcon}
|
|
274
|
+
alt="Inner Badge"
|
|
275
|
+
width={12}
|
|
276
|
+
height={12}
|
|
277
|
+
/>
|
|
278
|
+
<span className="footnote text-text">
|
|
279
|
+
{innerBadge.badgeText.includes("|") ? (
|
|
280
|
+
<>
|
|
281
|
+
<span className="font-bold">
|
|
282
|
+
{innerBadge.badgeText.split("|")[0]}
|
|
283
|
+
</span>
|
|
284
|
+
<span className="font-normal">
|
|
285
|
+
{innerBadge.badgeText.split("|")[1]}
|
|
286
|
+
</span>
|
|
287
|
+
</>
|
|
288
|
+
) : (
|
|
289
|
+
<Text className="font-bold">
|
|
290
|
+
{innerBadge.badgeText}
|
|
291
|
+
</Text>
|
|
292
|
+
)}
|
|
293
|
+
</span>
|
|
294
|
+
</div>
|
|
295
|
+
) : null}
|
|
296
|
+
</div>
|
|
297
|
+
</section>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
247
300
|
</div>
|
|
248
301
|
</article>
|
|
249
302
|
);
|
|
@@ -10,6 +10,7 @@ export interface ProductCardProps {
|
|
|
10
10
|
techType?: string;
|
|
11
11
|
ismaxSpeed?: boolean;
|
|
12
12
|
price: string;
|
|
13
|
+
priceSuffixBottom?: string;
|
|
13
14
|
description: string;
|
|
14
15
|
bestValue?: boolean;
|
|
15
16
|
bestValueText?: string;
|
|
@@ -23,6 +24,5 @@ export interface ProductCardProps {
|
|
|
23
24
|
ctaText?: string;
|
|
24
25
|
cta?: ButtonProps;
|
|
25
26
|
onCtaClick: () => void;
|
|
26
|
-
hostType: "residential" | "smb";
|
|
27
27
|
renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
|
|
28
28
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Carousel } from "./index";
|
|
2
|
+
import type { carouselFieldsInterface } from "./types";
|
|
2
3
|
|
|
3
4
|
import { DocsPage } from "@shared/stories/DocsTemplate";
|
|
4
5
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
@@ -21,3 +22,61 @@ const meta: Meta<typeof Carousel> = {
|
|
|
21
22
|
export default meta;
|
|
22
23
|
type Story = StoryObj<typeof meta>;
|
|
23
24
|
export const Default: Story = {};
|
|
25
|
+
|
|
26
|
+
const sampleProduct = (
|
|
27
|
+
id: string,
|
|
28
|
+
planName: string,
|
|
29
|
+
price: string,
|
|
30
|
+
highlighted = false
|
|
31
|
+
) => ({
|
|
32
|
+
__typename: "ComponentProductCard" as const,
|
|
33
|
+
sys: { id },
|
|
34
|
+
speed: `${planName}|Fiber internet`,
|
|
35
|
+
price,
|
|
36
|
+
priceSuffix: "99/mo",
|
|
37
|
+
techType: "Fiber",
|
|
38
|
+
highlighted,
|
|
39
|
+
productCardDescription: "Reliable internet for your home.",
|
|
40
|
+
benefitsTitle: "Plan benefits",
|
|
41
|
+
benefitsExpanded: false,
|
|
42
|
+
cta: { buttonLabel: "Select plan" },
|
|
43
|
+
benefits: {
|
|
44
|
+
items: [{ text: "No data caps" }, { text: "Free installation" }],
|
|
45
|
+
},
|
|
46
|
+
giftRewards: { list: { items: [] } },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const twoColumnFields: carouselFieldsInterface = {
|
|
50
|
+
__typename: "ComponentCarousel",
|
|
51
|
+
sys: { id: "carousel-two-column" },
|
|
52
|
+
title: "Two-column product cards",
|
|
53
|
+
subTitle: "Renders as a two-column grid on mobile (resize below 768px).",
|
|
54
|
+
twoColumn: true,
|
|
55
|
+
backgroundColor: "white",
|
|
56
|
+
items: {
|
|
57
|
+
items: [
|
|
58
|
+
sampleProduct("plan-1", "300 Mbps", "49"),
|
|
59
|
+
sampleProduct("plan-2", "600 Mbps", "59", true),
|
|
60
|
+
sampleProduct("plan-3", "1 Gig", "69"),
|
|
61
|
+
sampleProduct("plan-4", "2 Gig", "89"),
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Two-column variant: on mobile (below 768px) product cards render in a
|
|
68
|
+
* two-column grid instead of a vertical stack. Resize the canvas to see it.
|
|
69
|
+
*/
|
|
70
|
+
export const TwoColumnProductCards: Story = {
|
|
71
|
+
args: {
|
|
72
|
+
fields: twoColumnFields,
|
|
73
|
+
hasProductCards: true,
|
|
74
|
+
hasTestimonialCards: false,
|
|
75
|
+
disclaimerText: null,
|
|
76
|
+
backgroundColor: "bg-white",
|
|
77
|
+
activeTab: "",
|
|
78
|
+
setActiveTab: () => {},
|
|
79
|
+
tabs: [],
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
@@ -162,6 +162,29 @@ describe("ProductCardCarousel", () => {
|
|
|
162
162
|
expect(container.querySelector(".flex-col")).toBeInTheDocument();
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
+
it("renders a two-column grid on mobile in carousel mode when twoColumn is set", () => {
|
|
166
|
+
const items = Array.from({ length: 4 }, (_, i) => ({
|
|
167
|
+
...sampleProduct,
|
|
168
|
+
sys: { id: `prod-${i}` },
|
|
169
|
+
}));
|
|
170
|
+
const fields = createProductFields(items);
|
|
171
|
+
fields.twoColumn = true;
|
|
172
|
+
const { container } = render(<ProductCardCarousel fields={fields} />);
|
|
173
|
+
// Mobile view uses a two-column grid instead of a vertical stack
|
|
174
|
+
expect(container.querySelector(".grid.grid-cols-2")).toBeInTheDocument();
|
|
175
|
+
expect(container.querySelector(".flex-col")).not.toBeInTheDocument();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("renders a two-column grid on mobile in non-carousel mode when twoColumn is set", () => {
|
|
179
|
+
const fields = createProductFields([
|
|
180
|
+
sampleProduct,
|
|
181
|
+
{ ...sampleProduct, sys: { id: "prod-2" } },
|
|
182
|
+
]);
|
|
183
|
+
fields.twoColumn = true;
|
|
184
|
+
const { container } = render(<ProductCardCarousel fields={fields} />);
|
|
185
|
+
expect(container.querySelector(".grid.grid-cols-2")).toBeInTheDocument();
|
|
186
|
+
});
|
|
187
|
+
|
|
165
188
|
it("parses speed field correctly (planName | planSubtext)", () => {
|
|
166
189
|
const fields = createProductFields([sampleProduct]);
|
|
167
190
|
render(<ProductCardCarousel fields={fields} />);
|
|
@@ -33,6 +33,7 @@ function ProductCardPanel({
|
|
|
33
33
|
const [swipeOffset, setSwipeOffset] = useState(0);
|
|
34
34
|
const [isSwiping, setIsSwiping] = useState(false);
|
|
35
35
|
const items = fields?.items?.items || [];
|
|
36
|
+
const twoColumn = fields?.twoColumn || false;
|
|
36
37
|
const isCarousel = items.length > 2;
|
|
37
38
|
const showArrows = fields?.showArrows !== false && isCarousel;
|
|
38
39
|
const cardsRef = useRef<(HTMLDivElement | null)[]>([]);
|
|
@@ -232,20 +233,32 @@ function ProductCardPanel({
|
|
|
232
233
|
onCtaClick={handleCtaClick}
|
|
233
234
|
renderCheckPlans={renderCheckPlans}
|
|
234
235
|
cta={item.cta}
|
|
235
|
-
|
|
236
|
+
priceSuffixBottom={item?.priceSuffixBottom}
|
|
236
237
|
/>
|
|
237
238
|
</div>
|
|
238
239
|
);
|
|
239
240
|
};
|
|
240
241
|
|
|
241
242
|
return !isCarousel ? (
|
|
242
|
-
<div
|
|
243
|
-
{
|
|
243
|
+
<div
|
|
244
|
+
className={cx(
|
|
245
|
+
"w-full max-w-[1280px] gap-4",
|
|
246
|
+
twoColumn
|
|
247
|
+
? "grid grid-cols-2 md:flex md:flex-wrap md:justify-center"
|
|
248
|
+
: "flex flex-wrap justify-center"
|
|
249
|
+
)}
|
|
250
|
+
>
|
|
251
|
+
{items.map((item, index) => renderCard(item, index, twoColumn))}
|
|
244
252
|
</div>
|
|
245
253
|
) : (
|
|
246
254
|
<>
|
|
247
|
-
{/* Mobile/Tablet View: Vertical Stack */}
|
|
248
|
-
<div
|
|
255
|
+
{/* Mobile/Tablet View: Vertical Stack (or two-column grid) */}
|
|
256
|
+
<div
|
|
257
|
+
className={cx(
|
|
258
|
+
"md:hidden",
|
|
259
|
+
twoColumn ? "grid grid-cols-2 gap-4" : "flex flex-col gap-6"
|
|
260
|
+
)}
|
|
261
|
+
>
|
|
249
262
|
{items.map((item, index) => renderCard(item, index, true))}
|
|
250
263
|
</div>
|
|
251
264
|
|
|
@@ -17,6 +17,8 @@ export interface carouselFieldsInterface {
|
|
|
17
17
|
initialSlideIndex?: number;
|
|
18
18
|
infiniteScroll?: boolean;
|
|
19
19
|
mobileNavigationType?: boolean;
|
|
20
|
+
// When true, product cards render in a two-column grid on mobile instead of a vertical stack.
|
|
21
|
+
twoColumn?: boolean;
|
|
20
22
|
disclaimerText?: {
|
|
21
23
|
json: any;
|
|
22
24
|
};
|
|
@@ -58,6 +60,7 @@ export interface ProductCardFields {
|
|
|
58
60
|
speed?: string;
|
|
59
61
|
price?: string;
|
|
60
62
|
priceSuffix?: string;
|
|
63
|
+
priceSuffixBottom?: string;
|
|
61
64
|
techType?: string;
|
|
62
65
|
highlighted?: boolean;
|
|
63
66
|
productCardDescription?: string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import ConditionalFormFields from "./conditional-fields";
|
|
3
3
|
import { FormContext, FormContextType } from "./context";
|
|
4
|
+
import { dynamicRenderingWithGroupings } from "./render";
|
|
4
5
|
import { DynamicFormItem } from "./types";
|
|
5
6
|
|
|
6
7
|
import { fireEvent, render, screen } from "@testing-library/react";
|
|
@@ -46,6 +47,7 @@ const renderConditional = (
|
|
|
46
47
|
formData={{}}
|
|
47
48
|
setFormData={setFormData}
|
|
48
49
|
submitErrorFields={[]}
|
|
50
|
+
renderConditionalFields={dynamicRenderingWithGroupings}
|
|
49
51
|
{...props}
|
|
50
52
|
/>
|
|
51
53
|
</FormContext.Provider>
|
|
@@ -99,6 +101,7 @@ describe("ConditionalFormFields", () => {
|
|
|
99
101
|
formData={{}}
|
|
100
102
|
setFormData={jest.fn()}
|
|
101
103
|
submitErrorFields={[]}
|
|
104
|
+
renderConditionalFields={dynamicRenderingWithGroupings}
|
|
102
105
|
/>
|
|
103
106
|
</FormContext.Provider>
|
|
104
107
|
);
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import React, { useContext, useState } from "react";
|
|
4
|
+
import { constants } from "./constants";
|
|
4
5
|
import { FormContext } from "./context";
|
|
5
6
|
import { FormFieldTypeMap, labelCreator } from "./field-map";
|
|
6
|
-
import {
|
|
7
|
-
import { ConditionalFieldConfig } from "./types";
|
|
7
|
+
import { ConditionalFieldConfig, DynamicFormItem } from "./types";
|
|
8
8
|
|
|
9
9
|
import { Text } from "@shared/components/text";
|
|
10
|
+
import { cx } from "@shared/utils";
|
|
10
11
|
|
|
11
12
|
type FieldError = { fieldId?: string; errorMessage: string };
|
|
12
13
|
type FormValues = Record<string, any>;
|
|
@@ -15,6 +16,13 @@ export interface ConditionalFormFieldsProps extends ConditionalFieldConfig {
|
|
|
15
16
|
formData: FormValues;
|
|
16
17
|
setFormData: (updater: (prev: FormValues) => FormValues) => void;
|
|
17
18
|
submitErrorFields: string[];
|
|
19
|
+
renderConditionalFields: (
|
|
20
|
+
isLoading: boolean,
|
|
21
|
+
fields: DynamicFormItem[],
|
|
22
|
+
formData: FormValues,
|
|
23
|
+
setFormData: (updater: (prev: FormValues) => FormValues) => void,
|
|
24
|
+
submitErrorFields: string[]
|
|
25
|
+
) => React.ReactNode[];
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
const ConditionalFormFields: React.FC<ConditionalFormFieldsProps> = props => {
|
|
@@ -29,6 +37,8 @@ const ConditionalFormFields: React.FC<ConditionalFormFieldsProps> = props => {
|
|
|
29
37
|
formFieldsToShow = [],
|
|
30
38
|
dataSource,
|
|
31
39
|
note,
|
|
40
|
+
grouped = false,
|
|
41
|
+
renderConditionalFields,
|
|
32
42
|
} = props;
|
|
33
43
|
|
|
34
44
|
const [showElements, setShowElements] = useState(false);
|
|
@@ -37,6 +47,11 @@ const ConditionalFormFields: React.FC<ConditionalFormFieldsProps> = props => {
|
|
|
37
47
|
errorMessage: "",
|
|
38
48
|
});
|
|
39
49
|
|
|
50
|
+
const isExigentGroupedField =
|
|
51
|
+
grouped &&
|
|
52
|
+
(fieldId === constants.TARGET_EMAIL ||
|
|
53
|
+
fieldId === constants.IP_PHONE_NUMBER);
|
|
54
|
+
|
|
40
55
|
const Component = FormFieldTypeMap["Radio"];
|
|
41
56
|
if (!Component) return null;
|
|
42
57
|
|
|
@@ -57,7 +72,18 @@ const ConditionalFormFields: React.FC<ConditionalFormFieldsProps> = props => {
|
|
|
57
72
|
};
|
|
58
73
|
|
|
59
74
|
return (
|
|
60
|
-
<div
|
|
75
|
+
<div
|
|
76
|
+
className={cx(
|
|
77
|
+
"w-full",
|
|
78
|
+
isExigentGroupedField && "bg-bg-surface-secondary px-5 pt-1",
|
|
79
|
+
isExigentGroupedField &&
|
|
80
|
+
fieldId === constants.IP_PHONE_NUMBER &&
|
|
81
|
+
"rounded-t-xl border-x border-b border-border-secondary",
|
|
82
|
+
isExigentGroupedField &&
|
|
83
|
+
fieldId === constants.TARGET_EMAIL &&
|
|
84
|
+
"rounded-b-xl border-x border-t border-border-secondary pb-5"
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
61
87
|
<div className="mb-4">
|
|
62
88
|
{labelCreator(displayName, requiredField)}
|
|
63
89
|
<Component
|
|
@@ -88,7 +114,7 @@ const ConditionalFormFields: React.FC<ConditionalFormFieldsProps> = props => {
|
|
|
88
114
|
}`}
|
|
89
115
|
>
|
|
90
116
|
{showElements &&
|
|
91
|
-
|
|
117
|
+
renderConditionalFields(
|
|
92
118
|
false,
|
|
93
119
|
formFieldsToShow,
|
|
94
120
|
formData,
|
|
@@ -22,6 +22,8 @@ export type FormContextType = {
|
|
|
22
22
|
fieldComponentOverrides?: Partial<
|
|
23
23
|
Record<DynamicFormFieldType, React.FC<FieldRenderProps>>
|
|
24
24
|
>;
|
|
25
|
+
/** App-injected renderer for address-autocomplete Text fields. */
|
|
26
|
+
renderAddressAutocomplete?: React.FC<FieldRenderProps>;
|
|
25
27
|
/** Optional error reporter (logger) injected by the app. */
|
|
26
28
|
onError?: (info: {
|
|
27
29
|
type: string;
|