@windstream/react-shared-components 0.0.91 → 0.0.93
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/README.md +635 -635
- package/dist/contentful/index.esm.js +1 -1
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +1 -1
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +3 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.esm.js +4 -4
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +177 -177
- package/src/components/accordion/Accordion.stories.tsx +230 -230
- package/src/components/accordion/index.tsx +2 -2
- package/src/components/accordion/types.ts +10 -10
- package/src/components/alert-card/AlertCard.stories.tsx +171 -171
- package/src/components/alert-card/index.tsx +33 -32
- package/src/components/alert-card/types.ts +11 -9
- package/src/components/brand-button/BrandButton.stories.tsx +223 -223
- package/src/components/brand-button/helpers.ts +35 -35
- package/src/components/brand-button/index.tsx +115 -115
- package/src/components/brand-button/types.ts +37 -37
- package/src/components/button/Button.stories.tsx +108 -108
- package/src/components/button/index.tsx +27 -27
- package/src/components/button/types.ts +14 -14
- package/src/components/call-button/CallButton.stories.tsx +324 -324
- package/src/components/call-button/index.tsx +86 -86
- package/src/components/call-button/types.ts +11 -11
- package/src/components/checkbox/Checkbox.stories.tsx +247 -247
- package/src/components/checkbox/index.tsx +197 -197
- package/src/components/checkbox/types.ts +27 -27
- package/src/components/checklist/Checklist.stories.tsx +150 -150
- package/src/components/checklist/index.tsx +55 -55
- package/src/components/checklist/types.ts +14 -14
- package/src/components/collapse/Collapse.stories.tsx +255 -255
- package/src/components/collapse/index.tsx +46 -46
- package/src/components/collapse/types.ts +6 -6
- package/src/components/divider/Divider.stories.tsx +205 -205
- package/src/components/divider/index.tsx +22 -22
- package/src/components/divider/type.ts +3 -3
- package/src/components/image/Image.stories.tsx +113 -113
- package/src/components/image/index.tsx +25 -25
- package/src/components/image/types.ts +40 -40
- package/src/components/input/Input.stories.tsx +325 -325
- package/src/components/input/index.tsx +177 -177
- package/src/components/input/types.ts +37 -37
- package/src/components/link/Link.stories.tsx +163 -163
- package/src/components/link/types.ts +25 -25
- package/src/components/list/List.stories.tsx +272 -272
- package/src/components/list/index.tsx +88 -88
- package/src/components/list/list-item/index.tsx +38 -38
- package/src/components/list/list-item/types.ts +13 -13
- package/src/components/list/types.ts +29 -29
- package/src/components/material-icon/MaterialIcon.stories.tsx +322 -330
- package/src/components/material-icon/constants.ts +98 -98
- package/src/components/material-icon/index.tsx +47 -44
- package/src/components/material-icon/types.ts +31 -31
- package/src/components/modal/Modal.stories.tsx +171 -171
- package/src/components/modal/index.tsx +164 -164
- package/src/components/modal/types.ts +24 -24
- package/src/components/next-image/index.tsx +32 -32
- package/src/components/next-image/types.ts +1 -1
- package/src/components/radio-button/RadioButton.stories.tsx +307 -307
- package/src/components/radio-button/index.tsx +75 -75
- package/src/components/radio-button/types.ts +21 -21
- package/src/components/see-more/SeeMore.stories.tsx +181 -181
- package/src/components/see-more/index.tsx +44 -44
- package/src/components/see-more/types.ts +4 -4
- package/src/components/select/Select.stories.tsx +411 -411
- package/src/components/select/index.tsx +150 -150
- package/src/components/select/types.ts +35 -35
- package/src/components/select-plan-button/SelectPlanButton.stories.tsx +184 -184
- package/src/components/select-plan-button/index.tsx +31 -31
- package/src/components/select-plan-button/types.ts +5 -5
- package/src/components/skeleton/Skeleton.stories.tsx +179 -179
- package/src/components/skeleton/index.tsx +61 -61
- package/src/components/skeleton/types.ts +4 -4
- package/src/components/spinner/Spinner.stories.tsx +335 -335
- package/src/components/spinner/index.tsx +44 -44
- package/src/components/spinner/types.ts +5 -5
- package/src/components/text/Text.stories.tsx +321 -321
- package/src/components/text/index.tsx +25 -25
- package/src/components/text/types.ts +45 -45
- package/src/components/tooltip/Tooltip.stories.tsx +219 -219
- package/src/components/tooltip/index.tsx +74 -74
- package/src/components/tooltip/types.ts +7 -7
- package/src/components/view-cart-button/ViewCartButton.stories.tsx +252 -252
- package/src/components/view-cart-button/index.tsx +42 -42
- package/src/components/view-cart-button/types.ts +5 -5
- package/src/contentful/blocks/button/Button.stories.tsx +40 -40
- package/src/contentful/blocks/button/index.tsx +99 -99
- package/src/contentful/blocks/button/types.ts +31 -31
- package/src/contentful/blocks/callout/Callout.stories.tsx +23 -23
- package/src/contentful/blocks/callout/index.tsx +66 -66
- package/src/contentful/blocks/cards/Cards.stories.tsx +23 -23
- package/src/contentful/blocks/cards/index.tsx +13 -13
- package/src/contentful/blocks/cards/product-card/index.tsx +199 -199
- package/src/contentful/blocks/cards/product-card/types.ts +21 -21
- package/src/contentful/blocks/cards/simple-card/index.tsx +77 -77
- package/src/contentful/blocks/cards/simple-card/types.ts +31 -31
- package/src/contentful/blocks/cards/testimonial-card/index.tsx +88 -88
- package/src/contentful/blocks/cards/testimonial-card/types.tsx +12 -12
- package/src/contentful/blocks/cards/types.ts +1 -1
- package/src/contentful/blocks/carousel/Carousel.stories.tsx +23 -23
- package/src/contentful/blocks/carousel/helper.tsx +349 -349
- package/src/contentful/blocks/carousel/index.tsx +68 -68
- package/src/contentful/blocks/carousel/types.ts +139 -139
- package/src/contentful/blocks/cta-callout/CtaCallout.stories.tsx +46 -46
- package/src/contentful/blocks/cta-callout/index.tsx +54 -54
- package/src/contentful/blocks/cta-callout/types.ts +22 -22
- package/src/contentful/blocks/find-kinetic/index.tsx +124 -124
- package/src/contentful/blocks/floating-banner/FloatingBanner.stories.tsx +34 -34
- package/src/contentful/blocks/floating-banner/types.ts +22 -22
- package/src/contentful/blocks/footer/Footer.stories.tsx +30 -30
- package/src/contentful/blocks/image-promo-bar/ImagePromoBar.stories.tsx +23 -23
- package/src/contentful/blocks/image-promo-bar/helper.tsx +28 -28
- package/src/contentful/blocks/image-promo-bar/index.tsx +231 -231
- package/src/contentful/blocks/image-promo-bar/types.ts +44 -44
- package/src/contentful/blocks/image-promo-bar/vimeo-embed.tsx +93 -93
- package/src/contentful/blocks/image-promo-bar/youtube-embed.tsx +46 -46
- package/src/contentful/blocks/modal/constants.ts +53 -53
- package/src/contentful/blocks/modal/index.tsx +91 -91
- package/src/contentful/blocks/modal/types.ts +12 -12
- package/src/contentful/blocks/navigation/desktop-link-groups.tsx/index.tsx +111 -111
- package/src/contentful/blocks/navigation/index.tsx +385 -385
- package/src/contentful/blocks/navigation/mobile-link-groups.tsx/index.tsx +80 -80
- package/src/contentful/blocks/navigation/types.ts +41 -41
- package/src/contentful/blocks/primary-hero/PrimaryHero.stories.tsx +23 -23
- package/src/contentful/blocks/primary-hero/index.tsx +228 -228
- package/src/contentful/blocks/primary-hero/types.ts +35 -35
- package/src/contentful/blocks/shape-background-wrapper/ShapeBackgroundWrapper.stories.tsx +26 -26
- package/src/contentful/blocks/shape-background-wrapper/index.tsx +124 -124
- package/src/contentful/blocks/shape-background-wrapper/types.ts +36 -36
- package/src/contentful/blocks/text/Text.stories.tsx +23 -23
- package/src/contentful/blocks/text/index.tsx +12 -12
- package/src/contentful/blocks/text/types.ts +1 -1
- package/src/contentful/index.ts +57 -57
- package/src/hooks/use-body-scroll-lock.ts +34 -34
- package/src/hooks/use-outside-click.ts +17 -17
- package/src/index.ts +96 -96
- package/src/next/index.ts +5 -5
- package/src/setupTests.ts +46 -46
- package/src/stories/DocsTemplate.tsx +24 -24
- package/src/styles/globals.css +343 -307
- package/src/types/global.d.ts +9 -9
- package/src/types/micro-components.ts +89 -89
- package/src/utils/index.ts +49 -49
- package/tailwind.config.cjs +37 -24
|
@@ -1,349 +1,349 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
-
import {
|
|
5
|
-
CarouselWithProductCards,
|
|
6
|
-
CarouselWithTestimonialCards,
|
|
7
|
-
TabSwitchProps,
|
|
8
|
-
} from "./types";
|
|
9
|
-
|
|
10
|
-
import { Button } from "@shared/components/button";
|
|
11
|
-
import { MaterialIcon } from "@shared/components/material-icon";
|
|
12
|
-
import { ProductCard } from "@shared/contentful/blocks/cards/product-card";
|
|
13
|
-
import { TestimonialCard } from "@shared/contentful/blocks/cards/testimonial-card";
|
|
14
|
-
import { cx } from "@shared/utils";
|
|
15
|
-
|
|
16
|
-
export function ProductCardCarousel({
|
|
17
|
-
fields,
|
|
18
|
-
}: {
|
|
19
|
-
fields: CarouselWithProductCards;
|
|
20
|
-
}) {
|
|
21
|
-
const itemsExpanded = fields?.items?.items?.[0]?.benefitsExpanded || false;
|
|
22
|
-
const [desktopExpanded, setDesktopExpanded] = useState(itemsExpanded);
|
|
23
|
-
const [mobileExpandedStates, setMobileExpandedStates] = useState<
|
|
24
|
-
Record<number, boolean>
|
|
25
|
-
>({});
|
|
26
|
-
const [currentIndex, setCurrentIndex] = useState(0);
|
|
27
|
-
const items = fields?.items?.items || [];
|
|
28
|
-
const isCarousel = items.length > 2;
|
|
29
|
-
const cardsRef = useRef<(HTMLDivElement | null)[]>([]);
|
|
30
|
-
|
|
31
|
-
const prevSlide = useCallback(() => {
|
|
32
|
-
if (!isCarousel) return;
|
|
33
|
-
setCurrentIndex(prev => (prev + 3) % items.length);
|
|
34
|
-
}, [isCarousel, items.length]);
|
|
35
|
-
|
|
36
|
-
const nextSlide = useCallback(() => {
|
|
37
|
-
if (!isCarousel) return;
|
|
38
|
-
setCurrentIndex(prev => (prev - 3 + items.length) % items.length);
|
|
39
|
-
}, [isCarousel, items.length]);
|
|
40
|
-
|
|
41
|
-
// Equalize card heights
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
if (!isCarousel) return;
|
|
44
|
-
|
|
45
|
-
const equalizeHeights = () => {
|
|
46
|
-
const cards = cardsRef.current.filter(Boolean) as HTMLDivElement[];
|
|
47
|
-
if (cards.length === 0) return;
|
|
48
|
-
|
|
49
|
-
// Reset heights first
|
|
50
|
-
cards.forEach(card => {
|
|
51
|
-
card.style.height = "auto";
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
// Find the tallest card
|
|
55
|
-
const maxHeight = Math.max(...cards.map(card => card.offsetHeight));
|
|
56
|
-
|
|
57
|
-
// Set all cards to the max height
|
|
58
|
-
cards.forEach(card => {
|
|
59
|
-
card.style.height = `${maxHeight}px`;
|
|
60
|
-
});
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
// Run on mount and when expanded state changes
|
|
64
|
-
equalizeHeights();
|
|
65
|
-
|
|
66
|
-
// Re-run after a short delay due to dynamic content
|
|
67
|
-
const timeoutId = setTimeout(equalizeHeights, 100);
|
|
68
|
-
|
|
69
|
-
return () => clearTimeout(timeoutId);
|
|
70
|
-
}, [isCarousel, desktopExpanded, items.length]);
|
|
71
|
-
|
|
72
|
-
if (!items.length && !fields?.title) {
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const renderCard = (
|
|
77
|
-
item: any,
|
|
78
|
-
globalIndex: number,
|
|
79
|
-
isMobileView: boolean = false
|
|
80
|
-
) => {
|
|
81
|
-
const planContent = item?.speed?.split("|");
|
|
82
|
-
const techPrefix = item?.techType?.startsWith("FIBER") ? "fiber " : "";
|
|
83
|
-
const planName = `${techPrefix}${planContent?.[0] || ""}`;
|
|
84
|
-
const planSubtext = planContent?.[1] || "";
|
|
85
|
-
const priceCents = item?.priceSuffix?.split("/")?.[0] || "00";
|
|
86
|
-
const formattedPrice = `${item?.price || "0"}.${priceCents}`;
|
|
87
|
-
const features = item?.benefits?.items || [];
|
|
88
|
-
const bestValue = item?.highlighted || false;
|
|
89
|
-
const rawBadge = item?.giftRewards?.list?.items || [];
|
|
90
|
-
const innerBadge = {
|
|
91
|
-
badgeText: item.innerBadge || "",
|
|
92
|
-
badgeIcon: item.innerBadgeIcon?.url || "",
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
// Mobile: individual card state, Desktop: shared state
|
|
96
|
-
const isExpanded = isMobileView
|
|
97
|
-
? mobileExpandedStates[globalIndex] || false
|
|
98
|
-
: desktopExpanded;
|
|
99
|
-
|
|
100
|
-
const handleToggle = () => {
|
|
101
|
-
if (isMobileView) {
|
|
102
|
-
setMobileExpandedStates(prev => ({
|
|
103
|
-
...prev,
|
|
104
|
-
[globalIndex]: !prev[globalIndex],
|
|
105
|
-
}));
|
|
106
|
-
} else {
|
|
107
|
-
setDesktopExpanded(!desktopExpanded);
|
|
108
|
-
}
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
const handleCtaClick = () => {
|
|
112
|
-
return;
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
return (
|
|
116
|
-
<div
|
|
117
|
-
ref={
|
|
118
|
-
!isMobileView
|
|
119
|
-
? el => {
|
|
120
|
-
if (el && !cardsRef.current.includes(el)) {
|
|
121
|
-
cardsRef.current[globalIndex] = el;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
: undefined
|
|
125
|
-
}
|
|
126
|
-
key={globalIndex}
|
|
127
|
-
className={cx(
|
|
128
|
-
isMobileView
|
|
129
|
-
? "mx-auto w-full max-w-[392px]"
|
|
130
|
-
: "w-[392px] flex-shrink-0"
|
|
131
|
-
)}
|
|
132
|
-
>
|
|
133
|
-
<ProductCard
|
|
134
|
-
planName={planName}
|
|
135
|
-
planSubtext={planSubtext}
|
|
136
|
-
speed={item?.speed || ""}
|
|
137
|
-
price={formattedPrice}
|
|
138
|
-
description={item?.productCardDescription || ""}
|
|
139
|
-
giftBadge={rawBadge}
|
|
140
|
-
innerBadge={innerBadge}
|
|
141
|
-
theme={item?.highlighted ? "dark" : "light"}
|
|
142
|
-
bestValue={bestValue}
|
|
143
|
-
bestValueText={item?.topBadgeText || "best value"}
|
|
144
|
-
featuresTitle={item?.benefitsTitle}
|
|
145
|
-
features={features}
|
|
146
|
-
isExpanded={isExpanded}
|
|
147
|
-
onToggleExpand={handleToggle}
|
|
148
|
-
ctaText={item?.cta?.buttonLabel || "Select plan"}
|
|
149
|
-
onCtaClick={handleCtaClick}
|
|
150
|
-
hostType="smb"
|
|
151
|
-
/>
|
|
152
|
-
</div>
|
|
153
|
-
);
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
// For infinite scroll with peeking, starting in the middle of the repeated array
|
|
157
|
-
const displayItems = [...items, ...items, ...items, ...items, ...items];
|
|
158
|
-
const offset = items.length * 2;
|
|
159
|
-
|
|
160
|
-
return !isCarousel ? (
|
|
161
|
-
<div className="flex w-full max-w-[1280px] flex-wrap justify-center gap-8 px-4">
|
|
162
|
-
{items.map((item, index) => renderCard(item, index))}
|
|
163
|
-
</div>
|
|
164
|
-
) : (
|
|
165
|
-
<>
|
|
166
|
-
{/* Mobile/Tablet View: Vertical Stack */}
|
|
167
|
-
<div className="flex flex-col gap-6 md:hidden">
|
|
168
|
-
{items.map((item, index) => renderCard(item, index, true))}
|
|
169
|
-
</div>
|
|
170
|
-
|
|
171
|
-
{/* Desktop View: Horizontal Carousel */}
|
|
172
|
-
<div className="relative hidden w-full md:block">
|
|
173
|
-
{/* Navigation Arrows */}
|
|
174
|
-
<div className="pointer-events-none absolute -left-16 -right-16 top-[50%] z-30 flex -translate-y-1/2 justify-between px-4 md:px-10">
|
|
175
|
-
<Button
|
|
176
|
-
onClick={prevSlide}
|
|
177
|
-
className="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full border border-gray-100 bg-white p-2 text-text shadow-cardDrop transition-all hover:bg-gray-50"
|
|
178
|
-
aria-label="Previous"
|
|
179
|
-
>
|
|
180
|
-
<MaterialIcon name="arrow_back" size={24} />
|
|
181
|
-
</Button>
|
|
182
|
-
|
|
183
|
-
<Button
|
|
184
|
-
onClick={nextSlide}
|
|
185
|
-
className="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full border border-gray-100 bg-white p-2 text-text shadow-cardDrop transition-all hover:bg-gray-50"
|
|
186
|
-
aria-label="Next"
|
|
187
|
-
>
|
|
188
|
-
<MaterialIcon name="arrow_forward" size={24} />
|
|
189
|
-
</Button>
|
|
190
|
-
</div>
|
|
191
|
-
|
|
192
|
-
{/* Carousel Window */}
|
|
193
|
-
<div className="mx-auto max-w-[1280px] overflow-hidden">
|
|
194
|
-
<div
|
|
195
|
-
className="flex items-stretch gap-4 pb-4 transition-transform duration-500 ease-in-out"
|
|
196
|
-
style={{
|
|
197
|
-
transform: `translateX(calc(-${offset + currentIndex} * (392px + 12px)))`,
|
|
198
|
-
}}
|
|
199
|
-
>
|
|
200
|
-
{displayItems.map((item, index) => renderCard(item, index, false))}
|
|
201
|
-
</div>
|
|
202
|
-
</div>
|
|
203
|
-
</div>
|
|
204
|
-
</>
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
export const TestimonialCarousel: React.FC<{
|
|
209
|
-
fields: CarouselWithTestimonialCards;
|
|
210
|
-
}> = ({ fields }) => {
|
|
211
|
-
const testimonials = fields?.items?.items || [];
|
|
212
|
-
const [currentIndex, setCurrentIndex] = useState(0);
|
|
213
|
-
const timeoutRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
214
|
-
|
|
215
|
-
const nextSlide = useCallback(() => {
|
|
216
|
-
if (testimonials.length === 0) return;
|
|
217
|
-
setCurrentIndex(prev => (prev + 1) % testimonials.length);
|
|
218
|
-
}, [testimonials.length]);
|
|
219
|
-
|
|
220
|
-
const prevSlide = useCallback(() => {
|
|
221
|
-
if (testimonials.length === 0) return;
|
|
222
|
-
setCurrentIndex(prev => (prev === 0 ? testimonials.length - 1 : prev - 1));
|
|
223
|
-
}, [testimonials.length]);
|
|
224
|
-
|
|
225
|
-
// Auto-scroll logic
|
|
226
|
-
useEffect(() => {
|
|
227
|
-
if (testimonials.length === 0) return;
|
|
228
|
-
|
|
229
|
-
timeoutRef.current = setInterval(() => {
|
|
230
|
-
nextSlide();
|
|
231
|
-
}, 8000); // 5 seconds per slide
|
|
232
|
-
|
|
233
|
-
return () => {
|
|
234
|
-
if (timeoutRef.current) clearInterval(timeoutRef.current);
|
|
235
|
-
};
|
|
236
|
-
}, [nextSlide, testimonials.length]);
|
|
237
|
-
|
|
238
|
-
if (!testimonials || testimonials.length === 0) {
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
return (
|
|
243
|
-
<div className="relative mx-auto max-w-[1280px] overflow-hidden md:px-24">
|
|
244
|
-
{/* Navigation Overlay - Left (Hidden on mobile) */}
|
|
245
|
-
<Button
|
|
246
|
-
onClick={prevSlide}
|
|
247
|
-
className="group absolute left-4 top-1/2 z-20 hidden h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white p-2 text-text shadow-md transition-colors hover:bg-gray-100 md:flex"
|
|
248
|
-
aria-label="Previous"
|
|
249
|
-
>
|
|
250
|
-
<MaterialIcon name="arrow_back" size={24} />
|
|
251
|
-
</Button>
|
|
252
|
-
|
|
253
|
-
{/* Slider Track Wrapper */}
|
|
254
|
-
<div className="grid grid-cols-1 justify-items-center">
|
|
255
|
-
{/* Slides */}
|
|
256
|
-
{testimonials.map((item, index) => {
|
|
257
|
-
let offset = index - currentIndex;
|
|
258
|
-
const len = testimonials.length;
|
|
259
|
-
if (offset > len / 2) offset -= len;
|
|
260
|
-
if (offset < -len / 2) offset += len;
|
|
261
|
-
|
|
262
|
-
const isActive = offset === 0;
|
|
263
|
-
|
|
264
|
-
return (
|
|
265
|
-
<div
|
|
266
|
-
key={index}
|
|
267
|
-
className={cx(
|
|
268
|
-
"col-start-1 row-start-1 w-full md:max-w-[815px]",
|
|
269
|
-
// Mobile: only show active card
|
|
270
|
-
isActive ? "opacity-100" : "opacity-0",
|
|
271
|
-
// Desktop: show active and peeking cards
|
|
272
|
-
"md:opacity-100"
|
|
273
|
-
)}
|
|
274
|
-
style={{
|
|
275
|
-
transform: `translateX(${isActive ? "0%" : offset * 105 + "%"})`,
|
|
276
|
-
zIndex: isActive ? 10 : 5,
|
|
277
|
-
visibility: Math.abs(offset) > 1 ? "hidden" : "visible",
|
|
278
|
-
transition: "all 0.5s ease-in-out",
|
|
279
|
-
}}
|
|
280
|
-
>
|
|
281
|
-
<TestimonialCard
|
|
282
|
-
{...item}
|
|
283
|
-
isActive={isActive}
|
|
284
|
-
className="h-full"
|
|
285
|
-
/>
|
|
286
|
-
</div>
|
|
287
|
-
);
|
|
288
|
-
})}
|
|
289
|
-
</div>
|
|
290
|
-
|
|
291
|
-
{/* Navigation Overlay - Right (Hidden on mobile) */}
|
|
292
|
-
<Button
|
|
293
|
-
onClick={nextSlide}
|
|
294
|
-
className="group absolute right-4 top-1/2 z-20 hidden h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white p-2 text-text shadow-md transition-colors hover:bg-gray-100 md:flex"
|
|
295
|
-
aria-label="Next"
|
|
296
|
-
>
|
|
297
|
-
<MaterialIcon name="arrow_forward" size={24} />
|
|
298
|
-
</Button>
|
|
299
|
-
|
|
300
|
-
{/* Dots (Hidden on mobile) */}
|
|
301
|
-
<div className="mt-8 hidden justify-center gap-2 md:flex">
|
|
302
|
-
{testimonials.map((_, idx) => (
|
|
303
|
-
<button
|
|
304
|
-
key={idx}
|
|
305
|
-
onClick={() => setCurrentIndex(idx)}
|
|
306
|
-
className={cx(
|
|
307
|
-
"h-2 w-2 rounded-full transition-all duration-300",
|
|
308
|
-
currentIndex === idx
|
|
309
|
-
? "w-6 bg-white"
|
|
310
|
-
: "bg-white/40 hover:bg-white/60"
|
|
311
|
-
)}
|
|
312
|
-
aria-label={`Go to slide ${idx + 1}`}
|
|
313
|
-
/>
|
|
314
|
-
))}
|
|
315
|
-
</div>
|
|
316
|
-
</div>
|
|
317
|
-
);
|
|
318
|
-
};
|
|
319
|
-
|
|
320
|
-
export const TabSwitch: React.FC<TabSwitchProps> = ({
|
|
321
|
-
tabs,
|
|
322
|
-
activeTab,
|
|
323
|
-
onChange,
|
|
324
|
-
className,
|
|
325
|
-
}) => {
|
|
326
|
-
return (
|
|
327
|
-
<div
|
|
328
|
-
className={cx(
|
|
329
|
-
"mx-auto my-6 flex w-fit rounded-2xl bg-white p-1",
|
|
330
|
-
className
|
|
331
|
-
)}
|
|
332
|
-
>
|
|
333
|
-
{tabs.map(tab => (
|
|
334
|
-
<button
|
|
335
|
-
key={tab}
|
|
336
|
-
onClick={() => onChange(tab)}
|
|
337
|
-
className={cx(
|
|
338
|
-
"min-w-[160px] whitespace-nowrap rounded-2xl px-4 py-2.5 text-label1 font-black transition-all duration-300",
|
|
339
|
-
activeTab === tab
|
|
340
|
-
? "bg-bg-fill-brand text-white shadow-md"
|
|
341
|
-
: "text-text-info hover:bg-[#E5E5E7]"
|
|
342
|
-
)}
|
|
343
|
-
>
|
|
344
|
-
{tab}
|
|
345
|
-
</button>
|
|
346
|
-
))}
|
|
347
|
-
</div>
|
|
348
|
-
);
|
|
349
|
-
};
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
CarouselWithProductCards,
|
|
6
|
+
CarouselWithTestimonialCards,
|
|
7
|
+
TabSwitchProps,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
import { Button } from "@shared/components/button";
|
|
11
|
+
import { MaterialIcon } from "@shared/components/material-icon";
|
|
12
|
+
import { ProductCard } from "@shared/contentful/blocks/cards/product-card";
|
|
13
|
+
import { TestimonialCard } from "@shared/contentful/blocks/cards/testimonial-card";
|
|
14
|
+
import { cx } from "@shared/utils";
|
|
15
|
+
|
|
16
|
+
export function ProductCardCarousel({
|
|
17
|
+
fields,
|
|
18
|
+
}: {
|
|
19
|
+
fields: CarouselWithProductCards;
|
|
20
|
+
}) {
|
|
21
|
+
const itemsExpanded = fields?.items?.items?.[0]?.benefitsExpanded || false;
|
|
22
|
+
const [desktopExpanded, setDesktopExpanded] = useState(itemsExpanded);
|
|
23
|
+
const [mobileExpandedStates, setMobileExpandedStates] = useState<
|
|
24
|
+
Record<number, boolean>
|
|
25
|
+
>({});
|
|
26
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
27
|
+
const items = fields?.items?.items || [];
|
|
28
|
+
const isCarousel = items.length > 2;
|
|
29
|
+
const cardsRef = useRef<(HTMLDivElement | null)[]>([]);
|
|
30
|
+
|
|
31
|
+
const prevSlide = useCallback(() => {
|
|
32
|
+
if (!isCarousel) return;
|
|
33
|
+
setCurrentIndex(prev => (prev + 3) % items.length);
|
|
34
|
+
}, [isCarousel, items.length]);
|
|
35
|
+
|
|
36
|
+
const nextSlide = useCallback(() => {
|
|
37
|
+
if (!isCarousel) return;
|
|
38
|
+
setCurrentIndex(prev => (prev - 3 + items.length) % items.length);
|
|
39
|
+
}, [isCarousel, items.length]);
|
|
40
|
+
|
|
41
|
+
// Equalize card heights
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!isCarousel) return;
|
|
44
|
+
|
|
45
|
+
const equalizeHeights = () => {
|
|
46
|
+
const cards = cardsRef.current.filter(Boolean) as HTMLDivElement[];
|
|
47
|
+
if (cards.length === 0) return;
|
|
48
|
+
|
|
49
|
+
// Reset heights first
|
|
50
|
+
cards.forEach(card => {
|
|
51
|
+
card.style.height = "auto";
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Find the tallest card
|
|
55
|
+
const maxHeight = Math.max(...cards.map(card => card.offsetHeight));
|
|
56
|
+
|
|
57
|
+
// Set all cards to the max height
|
|
58
|
+
cards.forEach(card => {
|
|
59
|
+
card.style.height = `${maxHeight}px`;
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Run on mount and when expanded state changes
|
|
64
|
+
equalizeHeights();
|
|
65
|
+
|
|
66
|
+
// Re-run after a short delay due to dynamic content
|
|
67
|
+
const timeoutId = setTimeout(equalizeHeights, 100);
|
|
68
|
+
|
|
69
|
+
return () => clearTimeout(timeoutId);
|
|
70
|
+
}, [isCarousel, desktopExpanded, items.length]);
|
|
71
|
+
|
|
72
|
+
if (!items.length && !fields?.title) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const renderCard = (
|
|
77
|
+
item: any,
|
|
78
|
+
globalIndex: number,
|
|
79
|
+
isMobileView: boolean = false
|
|
80
|
+
) => {
|
|
81
|
+
const planContent = item?.speed?.split("|");
|
|
82
|
+
const techPrefix = item?.techType?.startsWith("FIBER") ? "fiber " : "";
|
|
83
|
+
const planName = `${techPrefix}${planContent?.[0] || ""}`;
|
|
84
|
+
const planSubtext = planContent?.[1] || "";
|
|
85
|
+
const priceCents = item?.priceSuffix?.split("/")?.[0] || "00";
|
|
86
|
+
const formattedPrice = `${item?.price || "0"}.${priceCents}`;
|
|
87
|
+
const features = item?.benefits?.items || [];
|
|
88
|
+
const bestValue = item?.highlighted || false;
|
|
89
|
+
const rawBadge = item?.giftRewards?.list?.items || [];
|
|
90
|
+
const innerBadge = {
|
|
91
|
+
badgeText: item.innerBadge || "",
|
|
92
|
+
badgeIcon: item.innerBadgeIcon?.url || "",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Mobile: individual card state, Desktop: shared state
|
|
96
|
+
const isExpanded = isMobileView
|
|
97
|
+
? mobileExpandedStates[globalIndex] || false
|
|
98
|
+
: desktopExpanded;
|
|
99
|
+
|
|
100
|
+
const handleToggle = () => {
|
|
101
|
+
if (isMobileView) {
|
|
102
|
+
setMobileExpandedStates(prev => ({
|
|
103
|
+
...prev,
|
|
104
|
+
[globalIndex]: !prev[globalIndex],
|
|
105
|
+
}));
|
|
106
|
+
} else {
|
|
107
|
+
setDesktopExpanded(!desktopExpanded);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const handleCtaClick = () => {
|
|
112
|
+
return;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
ref={
|
|
118
|
+
!isMobileView
|
|
119
|
+
? el => {
|
|
120
|
+
if (el && !cardsRef.current.includes(el)) {
|
|
121
|
+
cardsRef.current[globalIndex] = el;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
: undefined
|
|
125
|
+
}
|
|
126
|
+
key={globalIndex}
|
|
127
|
+
className={cx(
|
|
128
|
+
isMobileView
|
|
129
|
+
? "mx-auto w-full max-w-[392px]"
|
|
130
|
+
: "w-[392px] flex-shrink-0"
|
|
131
|
+
)}
|
|
132
|
+
>
|
|
133
|
+
<ProductCard
|
|
134
|
+
planName={planName}
|
|
135
|
+
planSubtext={planSubtext}
|
|
136
|
+
speed={item?.speed || ""}
|
|
137
|
+
price={formattedPrice}
|
|
138
|
+
description={item?.productCardDescription || ""}
|
|
139
|
+
giftBadge={rawBadge}
|
|
140
|
+
innerBadge={innerBadge}
|
|
141
|
+
theme={item?.highlighted ? "dark" : "light"}
|
|
142
|
+
bestValue={bestValue}
|
|
143
|
+
bestValueText={item?.topBadgeText || "best value"}
|
|
144
|
+
featuresTitle={item?.benefitsTitle}
|
|
145
|
+
features={features}
|
|
146
|
+
isExpanded={isExpanded}
|
|
147
|
+
onToggleExpand={handleToggle}
|
|
148
|
+
ctaText={item?.cta?.buttonLabel || "Select plan"}
|
|
149
|
+
onCtaClick={handleCtaClick}
|
|
150
|
+
hostType="smb"
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// For infinite scroll with peeking, starting in the middle of the repeated array
|
|
157
|
+
const displayItems = [...items, ...items, ...items, ...items, ...items];
|
|
158
|
+
const offset = items.length * 2;
|
|
159
|
+
|
|
160
|
+
return !isCarousel ? (
|
|
161
|
+
<div className="flex w-full max-w-[1280px] flex-wrap justify-center gap-8 px-4">
|
|
162
|
+
{items.map((item, index) => renderCard(item, index))}
|
|
163
|
+
</div>
|
|
164
|
+
) : (
|
|
165
|
+
<>
|
|
166
|
+
{/* Mobile/Tablet View: Vertical Stack */}
|
|
167
|
+
<div className="flex flex-col gap-6 md:hidden">
|
|
168
|
+
{items.map((item, index) => renderCard(item, index, true))}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{/* Desktop View: Horizontal Carousel */}
|
|
172
|
+
<div className="relative hidden w-full md:block">
|
|
173
|
+
{/* Navigation Arrows */}
|
|
174
|
+
<div className="pointer-events-none absolute -left-16 -right-16 top-[50%] z-30 flex -translate-y-1/2 justify-between px-4 md:px-10">
|
|
175
|
+
<Button
|
|
176
|
+
onClick={prevSlide}
|
|
177
|
+
className="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full border border-gray-100 bg-white p-2 text-text shadow-cardDrop transition-all hover:bg-gray-50"
|
|
178
|
+
aria-label="Previous"
|
|
179
|
+
>
|
|
180
|
+
<MaterialIcon name="arrow_back" size={24} />
|
|
181
|
+
</Button>
|
|
182
|
+
|
|
183
|
+
<Button
|
|
184
|
+
onClick={nextSlide}
|
|
185
|
+
className="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full border border-gray-100 bg-white p-2 text-text shadow-cardDrop transition-all hover:bg-gray-50"
|
|
186
|
+
aria-label="Next"
|
|
187
|
+
>
|
|
188
|
+
<MaterialIcon name="arrow_forward" size={24} />
|
|
189
|
+
</Button>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
{/* Carousel Window */}
|
|
193
|
+
<div className="mx-auto max-w-[1280px] overflow-hidden">
|
|
194
|
+
<div
|
|
195
|
+
className="flex items-stretch gap-4 pb-4 transition-transform duration-500 ease-in-out"
|
|
196
|
+
style={{
|
|
197
|
+
transform: `translateX(calc(-${offset + currentIndex} * (392px + 12px)))`,
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
{displayItems.map((item, index) => renderCard(item, index, false))}
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const TestimonialCarousel: React.FC<{
|
|
209
|
+
fields: CarouselWithTestimonialCards;
|
|
210
|
+
}> = ({ fields }) => {
|
|
211
|
+
const testimonials = fields?.items?.items || [];
|
|
212
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
213
|
+
const timeoutRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
214
|
+
|
|
215
|
+
const nextSlide = useCallback(() => {
|
|
216
|
+
if (testimonials.length === 0) return;
|
|
217
|
+
setCurrentIndex(prev => (prev + 1) % testimonials.length);
|
|
218
|
+
}, [testimonials.length]);
|
|
219
|
+
|
|
220
|
+
const prevSlide = useCallback(() => {
|
|
221
|
+
if (testimonials.length === 0) return;
|
|
222
|
+
setCurrentIndex(prev => (prev === 0 ? testimonials.length - 1 : prev - 1));
|
|
223
|
+
}, [testimonials.length]);
|
|
224
|
+
|
|
225
|
+
// Auto-scroll logic
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
if (testimonials.length === 0) return;
|
|
228
|
+
|
|
229
|
+
timeoutRef.current = setInterval(() => {
|
|
230
|
+
nextSlide();
|
|
231
|
+
}, 8000); // 5 seconds per slide
|
|
232
|
+
|
|
233
|
+
return () => {
|
|
234
|
+
if (timeoutRef.current) clearInterval(timeoutRef.current);
|
|
235
|
+
};
|
|
236
|
+
}, [nextSlide, testimonials.length]);
|
|
237
|
+
|
|
238
|
+
if (!testimonials || testimonials.length === 0) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<div className="relative mx-auto max-w-[1280px] overflow-hidden md:px-24">
|
|
244
|
+
{/* Navigation Overlay - Left (Hidden on mobile) */}
|
|
245
|
+
<Button
|
|
246
|
+
onClick={prevSlide}
|
|
247
|
+
className="group absolute left-4 top-1/2 z-20 hidden h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white p-2 text-text shadow-md transition-colors hover:bg-gray-100 md:flex"
|
|
248
|
+
aria-label="Previous"
|
|
249
|
+
>
|
|
250
|
+
<MaterialIcon name="arrow_back" size={24} />
|
|
251
|
+
</Button>
|
|
252
|
+
|
|
253
|
+
{/* Slider Track Wrapper */}
|
|
254
|
+
<div className="grid grid-cols-1 justify-items-center">
|
|
255
|
+
{/* Slides */}
|
|
256
|
+
{testimonials.map((item, index) => {
|
|
257
|
+
let offset = index - currentIndex;
|
|
258
|
+
const len = testimonials.length;
|
|
259
|
+
if (offset > len / 2) offset -= len;
|
|
260
|
+
if (offset < -len / 2) offset += len;
|
|
261
|
+
|
|
262
|
+
const isActive = offset === 0;
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<div
|
|
266
|
+
key={index}
|
|
267
|
+
className={cx(
|
|
268
|
+
"col-start-1 row-start-1 w-full md:max-w-[815px]",
|
|
269
|
+
// Mobile: only show active card
|
|
270
|
+
isActive ? "opacity-100" : "opacity-0",
|
|
271
|
+
// Desktop: show active and peeking cards
|
|
272
|
+
"md:opacity-100"
|
|
273
|
+
)}
|
|
274
|
+
style={{
|
|
275
|
+
transform: `translateX(${isActive ? "0%" : offset * 105 + "%"})`,
|
|
276
|
+
zIndex: isActive ? 10 : 5,
|
|
277
|
+
visibility: Math.abs(offset) > 1 ? "hidden" : "visible",
|
|
278
|
+
transition: "all 0.5s ease-in-out",
|
|
279
|
+
}}
|
|
280
|
+
>
|
|
281
|
+
<TestimonialCard
|
|
282
|
+
{...item}
|
|
283
|
+
isActive={isActive}
|
|
284
|
+
className="h-full"
|
|
285
|
+
/>
|
|
286
|
+
</div>
|
|
287
|
+
);
|
|
288
|
+
})}
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
{/* Navigation Overlay - Right (Hidden on mobile) */}
|
|
292
|
+
<Button
|
|
293
|
+
onClick={nextSlide}
|
|
294
|
+
className="group absolute right-4 top-1/2 z-20 hidden h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white p-2 text-text shadow-md transition-colors hover:bg-gray-100 md:flex"
|
|
295
|
+
aria-label="Next"
|
|
296
|
+
>
|
|
297
|
+
<MaterialIcon name="arrow_forward" size={24} />
|
|
298
|
+
</Button>
|
|
299
|
+
|
|
300
|
+
{/* Dots (Hidden on mobile) */}
|
|
301
|
+
<div className="mt-8 hidden justify-center gap-2 md:flex">
|
|
302
|
+
{testimonials.map((_, idx) => (
|
|
303
|
+
<button
|
|
304
|
+
key={idx}
|
|
305
|
+
onClick={() => setCurrentIndex(idx)}
|
|
306
|
+
className={cx(
|
|
307
|
+
"h-2 w-2 rounded-full transition-all duration-300",
|
|
308
|
+
currentIndex === idx
|
|
309
|
+
? "w-6 bg-white"
|
|
310
|
+
: "bg-white/40 hover:bg-white/60"
|
|
311
|
+
)}
|
|
312
|
+
aria-label={`Go to slide ${idx + 1}`}
|
|
313
|
+
/>
|
|
314
|
+
))}
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
export const TabSwitch: React.FC<TabSwitchProps> = ({
|
|
321
|
+
tabs,
|
|
322
|
+
activeTab,
|
|
323
|
+
onChange,
|
|
324
|
+
className,
|
|
325
|
+
}) => {
|
|
326
|
+
return (
|
|
327
|
+
<div
|
|
328
|
+
className={cx(
|
|
329
|
+
"mx-auto my-6 flex w-fit rounded-2xl bg-white p-1",
|
|
330
|
+
className
|
|
331
|
+
)}
|
|
332
|
+
>
|
|
333
|
+
{tabs.map(tab => (
|
|
334
|
+
<button
|
|
335
|
+
key={tab}
|
|
336
|
+
onClick={() => onChange(tab)}
|
|
337
|
+
className={cx(
|
|
338
|
+
"min-w-[160px] whitespace-nowrap rounded-2xl px-4 py-2.5 text-label1 font-black transition-all duration-300",
|
|
339
|
+
activeTab === tab
|
|
340
|
+
? "bg-bg-fill-brand text-white shadow-md"
|
|
341
|
+
: "text-text-info hover:bg-[#E5E5E7]"
|
|
342
|
+
)}
|
|
343
|
+
>
|
|
344
|
+
{tab}
|
|
345
|
+
</button>
|
|
346
|
+
))}
|
|
347
|
+
</div>
|
|
348
|
+
);
|
|
349
|
+
};
|