@windstream/react-shared-components 0.1.31 → 0.1.32
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 +2 -2
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +2 -2
- 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 +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 +182 -182
- package/src/components/accordion/Accordion.stories.tsx +230 -230
- package/src/components/accordion/index.tsx +70 -70
- package/src/components/accordion/types.ts +12 -12
- package/src/components/alert-card/AlertCard.stories.tsx +171 -171
- package/src/components/alert-card/index.tsx +41 -41
- package/src/components/alert-card/types.ts +13 -13
- 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 +61 -61
- package/src/components/checklist/types.ts +17 -17
- 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 -322
- package/src/components/material-icon/constants.ts +98 -98
- package/src/components/material-icon/index.tsx +47 -47
- 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 +54 -54
- package/src/components/next-image/types.ts +1 -1
- package/src/components/pagination/index.tsx +100 -100
- package/src/components/pagination/types.ts +6 -6
- 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 +155 -155
- package/src/components/select/types.ts +36 -36
- package/src/components/select-plan-button/SelectPlanButton.stories.tsx +184 -184
- package/src/components/select-plan-button/index.tsx +59 -59
- package/src/components/select-plan-button/types.ts +17 -17
- 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/accordion/index.tsx +62 -62
- package/src/contentful/blocks/address-input-banner/index.tsx +52 -52
- package/src/contentful/blocks/address-input-banner/types.ts +14 -14
- package/src/contentful/blocks/anchored-bottom-banner/index.tsx +65 -65
- package/src/contentful/blocks/anchored-bottom-banner/types.ts +9 -9
- package/src/contentful/blocks/blogs-grid/index.tsx +134 -134
- package/src/contentful/blocks/blogs-grid/types.ts +26 -26
- package/src/contentful/blocks/button/Button.stories.tsx +40 -40
- package/src/contentful/blocks/button/index.tsx +129 -129
- package/src/contentful/blocks/button/types.ts +39 -39
- package/src/contentful/blocks/callout/Callout.stories.tsx +23 -23
- package/src/contentful/blocks/callout/index.tsx +88 -88
- package/src/contentful/blocks/callout/types.ts +15 -15
- package/src/contentful/blocks/cards/Cards.stories.tsx +23 -23
- package/src/contentful/blocks/cards/blog-card/index.tsx +110 -110
- package/src/contentful/blocks/cards/blog-card/types.ts +18 -18
- package/src/contentful/blocks/cards/index.tsx +13 -13
- package/src/contentful/blocks/cards/product-card/index.tsx +252 -252
- package/src/contentful/blocks/cards/product-card/types.ts +28 -28
- package/src/contentful/blocks/cards/simple-card/index.tsx +89 -89
- package/src/contentful/blocks/cards/testimonial-card/index.tsx +90 -90
- 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 +440 -440
- package/src/contentful/blocks/carousel/index.tsx +85 -85
- package/src/contentful/blocks/carousel/types.ts +144 -144
- package/src/contentful/blocks/cta-callout/CtaCallout.stories.tsx +46 -46
- package/src/contentful/blocks/cta-callout/index.tsx +60 -60
- package/src/contentful/blocks/cta-callout/types.ts +26 -26
- package/src/contentful/blocks/dynamic-tabs/index.tsx +204 -204
- package/src/contentful/blocks/dynamic-tabs/types.ts +21 -21
- package/src/contentful/blocks/find-kinetic/index.tsx +130 -130
- package/src/contentful/blocks/floating-banner/FloatingBanner.stories.tsx +34 -34
- package/src/contentful/blocks/floating-banner/index.tsx +97 -97
- package/src/contentful/blocks/floating-banner/types.ts +22 -22
- package/src/contentful/blocks/footer/Footer.stories.tsx +30 -30
- package/src/contentful/blocks/footer/index.tsx +90 -90
- 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/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/types.ts +12 -12
- package/src/contentful/blocks/navigation/desktop-link-groups.tsx/index.tsx +113 -113
- package/src/contentful/blocks/navigation/index.tsx +394 -394
- package/src/contentful/blocks/navigation/mobile-link-groups.tsx/index.tsx +82 -82
- 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 +234 -234
- package/src/contentful/blocks/primary-hero/types.ts +35 -35
- package/src/contentful/blocks/search-block/index.tsx +90 -90
- 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 +78 -78
- package/src/hooks/use-body-scroll-lock.ts +34 -34
- package/src/hooks/use-carousel-swipe.ts +264 -264
- package/src/hooks/use-outside-click.ts +17 -17
- package/src/index.ts +101 -101
- 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 -343
- package/src/types/global.d.ts +9 -9
- package/src/types/micro-components.ts +99 -99
- package/src/types/utm.ts +49 -49
- package/src/utils/cookie.ts +58 -58
- package/src/utils/index.ts +65 -65
- package/src/utils/utm.ts +221 -221
|
@@ -1,440 +1,440 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
-
import {
|
|
3
|
-
CarouselWithProductCards,
|
|
4
|
-
CarouselWithTestimonialCards,
|
|
5
|
-
TabSwitchProps,
|
|
6
|
-
} from "./types";
|
|
7
|
-
|
|
8
|
-
import { Button } from "@shared/components/button";
|
|
9
|
-
import { MaterialIcon } from "@shared/components/material-icon";
|
|
10
|
-
import { ProductCard } from "@shared/contentful/blocks/cards/product-card";
|
|
11
|
-
import { TestimonialCard } from "@shared/contentful/blocks/cards/testimonial-card";
|
|
12
|
-
import { useCarouselSwipe } from "@shared/hooks/use-carousel-swipe";
|
|
13
|
-
import { CheckPlansProps } from "@shared/types/micro-components";
|
|
14
|
-
import { cx } from "@shared/utils";
|
|
15
|
-
|
|
16
|
-
export function ProductCardCarousel({
|
|
17
|
-
fields,
|
|
18
|
-
renderCheckPlans,
|
|
19
|
-
}: {
|
|
20
|
-
fields: CarouselWithProductCards;
|
|
21
|
-
onModalButtonClick?: (id?: string) => void;
|
|
22
|
-
renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
|
|
23
|
-
}) {
|
|
24
|
-
const itemsExpanded = fields?.items?.items?.[0]?.benefitsExpanded || false;
|
|
25
|
-
const [desktopExpanded, setDesktopExpanded] = useState(itemsExpanded);
|
|
26
|
-
const [mobileExpandedStates, setMobileExpandedStates] = useState<
|
|
27
|
-
Record<number, boolean>
|
|
28
|
-
>({});
|
|
29
|
-
const [currentIndex, setCurrentIndex] = useState(0);
|
|
30
|
-
const items = fields?.items?.items || [];
|
|
31
|
-
const isCarousel = items.length > 2;
|
|
32
|
-
const cardsRef = useRef<(HTMLDivElement | null)[]>([]);
|
|
33
|
-
|
|
34
|
-
const prevSlide = useCallback(() => {
|
|
35
|
-
if (!isCarousel) return;
|
|
36
|
-
setCurrentIndex(prev => (prev + 3) % items.length);
|
|
37
|
-
}, [isCarousel, items.length]);
|
|
38
|
-
|
|
39
|
-
const nextSlide = useCallback(() => {
|
|
40
|
-
if (!isCarousel) return;
|
|
41
|
-
setCurrentIndex(prev => (prev - 3 + items.length) % items.length);
|
|
42
|
-
}, [isCarousel, items.length]);
|
|
43
|
-
|
|
44
|
-
// Equalize card heights
|
|
45
|
-
useEffect(() => {
|
|
46
|
-
if (!isCarousel) return;
|
|
47
|
-
|
|
48
|
-
const equalizeHeights = () => {
|
|
49
|
-
const cards = cardsRef.current.filter(Boolean) as HTMLDivElement[];
|
|
50
|
-
if (cards.length === 0) return;
|
|
51
|
-
|
|
52
|
-
// Reset heights first
|
|
53
|
-
cards.forEach(card => {
|
|
54
|
-
card.style.height = "auto";
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
// Find the tallest card
|
|
58
|
-
const maxHeight = Math.max(...cards.map(card => card.offsetHeight));
|
|
59
|
-
|
|
60
|
-
// Set all cards to the max height
|
|
61
|
-
cards.forEach(card => {
|
|
62
|
-
card.style.height = `${maxHeight}px`;
|
|
63
|
-
});
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
// Run on mount and when expanded state changes
|
|
67
|
-
equalizeHeights();
|
|
68
|
-
|
|
69
|
-
// Re-run after a short delay due to dynamic content
|
|
70
|
-
const timeoutId = setTimeout(equalizeHeights, 100);
|
|
71
|
-
|
|
72
|
-
return () => clearTimeout(timeoutId);
|
|
73
|
-
}, [isCarousel, desktopExpanded, items.length]);
|
|
74
|
-
|
|
75
|
-
if (!items.length && !fields?.title) {
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const renderCard = (
|
|
80
|
-
item: any,
|
|
81
|
-
globalIndex: number,
|
|
82
|
-
isMobileView: boolean = false
|
|
83
|
-
) => {
|
|
84
|
-
const planContent = item?.speed?.split("|");
|
|
85
|
-
const planName = planContent?.[0] || "";
|
|
86
|
-
const planSubtext = planContent?.[1] || "";
|
|
87
|
-
const priceCents = item?.priceSuffix?.split("/")?.[0] || "00";
|
|
88
|
-
const formattedPrice = `${item?.price || "0"}.${priceCents}`;
|
|
89
|
-
const features = item?.benefits?.items || [];
|
|
90
|
-
const bestValue = item?.highlighted || false;
|
|
91
|
-
const rawBadge = item?.giftRewards?.list?.items || [];
|
|
92
|
-
const innerBadge = {
|
|
93
|
-
badgeText: item.innerBadge || "",
|
|
94
|
-
badgeIcon: item.innerBadgeIcon?.url || "",
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
// Mobile: individual card state, Desktop: shared state
|
|
98
|
-
const isExpanded = isMobileView
|
|
99
|
-
? mobileExpandedStates[globalIndex] || false
|
|
100
|
-
: desktopExpanded;
|
|
101
|
-
|
|
102
|
-
const handleToggle = () => {
|
|
103
|
-
if (isMobileView) {
|
|
104
|
-
setMobileExpandedStates(prev => ({
|
|
105
|
-
...prev,
|
|
106
|
-
[globalIndex]: !prev[globalIndex],
|
|
107
|
-
}));
|
|
108
|
-
} else {
|
|
109
|
-
setDesktopExpanded(!desktopExpanded);
|
|
110
|
-
}
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
const handleCtaClick = () => {
|
|
114
|
-
return;
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
return (
|
|
118
|
-
<div
|
|
119
|
-
ref={
|
|
120
|
-
!isMobileView
|
|
121
|
-
? el => {
|
|
122
|
-
if (el && !cardsRef.current.includes(el)) {
|
|
123
|
-
cardsRef.current[globalIndex] = el;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
: undefined
|
|
127
|
-
}
|
|
128
|
-
key={globalIndex}
|
|
129
|
-
className={cx(
|
|
130
|
-
isMobileView
|
|
131
|
-
? "mx-auto w-full max-w-[392px]"
|
|
132
|
-
: "flex-shrink-1 w-[392px]"
|
|
133
|
-
)}
|
|
134
|
-
>
|
|
135
|
-
<ProductCard
|
|
136
|
-
planName={planName}
|
|
137
|
-
planSubtext={planSubtext}
|
|
138
|
-
speed={item?.speed || ""}
|
|
139
|
-
techType={item?.techType || ""}
|
|
140
|
-
ismaxSpeed={item?.ismaxSpeed || ""}
|
|
141
|
-
price={formattedPrice}
|
|
142
|
-
description={item?.productCardDescription || ""}
|
|
143
|
-
giftBadge={rawBadge}
|
|
144
|
-
innerBadge={innerBadge}
|
|
145
|
-
theme={item?.highlighted ? "dark" : "light"}
|
|
146
|
-
bestValue={bestValue}
|
|
147
|
-
bestValueText={item?.topBadgeText || "best value"}
|
|
148
|
-
featuresTitle={item?.benefitsTitle}
|
|
149
|
-
features={features}
|
|
150
|
-
isExpanded={isExpanded}
|
|
151
|
-
onToggleExpand={handleToggle}
|
|
152
|
-
ctaText={item?.cta?.buttonLabel || "Select plan"}
|
|
153
|
-
onCtaClick={handleCtaClick}
|
|
154
|
-
renderCheckPlans={renderCheckPlans}
|
|
155
|
-
cta={item.cta}
|
|
156
|
-
hostType="smb"
|
|
157
|
-
/>
|
|
158
|
-
</div>
|
|
159
|
-
);
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
// For infinite scroll with peeking, starting in the middle of the repeated array
|
|
163
|
-
const displayItems = [...items, ...items, ...items, ...items, ...items];
|
|
164
|
-
const offset = items.length * 2;
|
|
165
|
-
|
|
166
|
-
return !isCarousel ? (
|
|
167
|
-
<div className="flex w-full max-w-[1280px] flex-wrap justify-center gap-4">
|
|
168
|
-
{items.map((item, index) => renderCard(item, index))}
|
|
169
|
-
</div>
|
|
170
|
-
) : (
|
|
171
|
-
<>
|
|
172
|
-
{/* Mobile/Tablet View: Vertical Stack */}
|
|
173
|
-
<div className="flex flex-col gap-6 md:hidden">
|
|
174
|
-
{items.map((item, index) => renderCard(item, index, true))}
|
|
175
|
-
</div>
|
|
176
|
-
|
|
177
|
-
{/* Desktop View: Horizontal Carousel */}
|
|
178
|
-
<div className="relative hidden w-full md:block">
|
|
179
|
-
{/* Navigation Arrows */}
|
|
180
|
-
<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">
|
|
181
|
-
<Button
|
|
182
|
-
onClick={prevSlide}
|
|
183
|
-
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"
|
|
184
|
-
aria-label="Previous"
|
|
185
|
-
>
|
|
186
|
-
<MaterialIcon name="arrow_back" size={24} />
|
|
187
|
-
</Button>
|
|
188
|
-
|
|
189
|
-
<Button
|
|
190
|
-
onClick={nextSlide}
|
|
191
|
-
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"
|
|
192
|
-
aria-label="Next"
|
|
193
|
-
>
|
|
194
|
-
<MaterialIcon name="arrow_forward" size={24} />
|
|
195
|
-
</Button>
|
|
196
|
-
</div>
|
|
197
|
-
|
|
198
|
-
{/* Carousel Window */}
|
|
199
|
-
<div className="mx-auto max-w-[1280px] overflow-hidden">
|
|
200
|
-
<div
|
|
201
|
-
className="flex items-stretch gap-4 pb-4 transition-transform duration-500 ease-in-out"
|
|
202
|
-
style={{
|
|
203
|
-
transform: `translateX(calc(-${offset + currentIndex} * (392px + 12px)))`,
|
|
204
|
-
}}
|
|
205
|
-
>
|
|
206
|
-
{displayItems.map((item, index) => renderCard(item, index, false))}
|
|
207
|
-
</div>
|
|
208
|
-
</div>
|
|
209
|
-
</div>
|
|
210
|
-
</>
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Individual slide component for the testimonial carousel
|
|
216
|
-
* Memoized to prevent unnecessary re-renders of inactive slides
|
|
217
|
-
*/
|
|
218
|
-
const TestimonialCarouselSlide = React.memo<{
|
|
219
|
-
item: any;
|
|
220
|
-
index: number;
|
|
221
|
-
currentIndex: number;
|
|
222
|
-
totalItems: number;
|
|
223
|
-
swipeOffset: number;
|
|
224
|
-
isSwiping: boolean;
|
|
225
|
-
isMobile: boolean;
|
|
226
|
-
containerWidth: number;
|
|
227
|
-
cardOffsetPercentage: number;
|
|
228
|
-
}>(
|
|
229
|
-
({
|
|
230
|
-
item,
|
|
231
|
-
index,
|
|
232
|
-
currentIndex,
|
|
233
|
-
totalItems,
|
|
234
|
-
swipeOffset,
|
|
235
|
-
isSwiping,
|
|
236
|
-
isMobile,
|
|
237
|
-
containerWidth,
|
|
238
|
-
cardOffsetPercentage,
|
|
239
|
-
}) => {
|
|
240
|
-
// Calculate circular offset for infinite carousel feel
|
|
241
|
-
let offset = index - currentIndex;
|
|
242
|
-
const len = totalItems;
|
|
243
|
-
if (offset > len / 2) offset -= len;
|
|
244
|
-
if (offset < -len / 2) offset += len;
|
|
245
|
-
|
|
246
|
-
const isActive = offset === 0;
|
|
247
|
-
const isAdjacent = Math.abs(offset) === 1;
|
|
248
|
-
|
|
249
|
-
// Calculate card position with smooth swipe following
|
|
250
|
-
// Base position is determined by card index offset (105% spacing between cards)
|
|
251
|
-
const baseTransform = offset * cardOffsetPercentage;
|
|
252
|
-
// Convert swipe pixels to percentage for smooth finger-following during swipe
|
|
253
|
-
const swipePercentage = (swipeOffset / containerWidth) * 100;
|
|
254
|
-
const totalTransform = baseTransform + swipePercentage;
|
|
255
|
-
|
|
256
|
-
// Mobile: Show adjacent cards during swipe for preview effect
|
|
257
|
-
// Desktop: Cards always visible (handled by opacity check below)
|
|
258
|
-
const showOnMobile = isSwiping ? isActive || isAdjacent : isActive;
|
|
259
|
-
|
|
260
|
-
// Determine opacity visibility (mobile-specific behavior)
|
|
261
|
-
const isVisible = isMobile ? showOnMobile : true;
|
|
262
|
-
|
|
263
|
-
return (
|
|
264
|
-
<div
|
|
265
|
-
className={cx(
|
|
266
|
-
// Layout
|
|
267
|
-
"col-start-1 row-start-1 w-full md:max-w-[815px]",
|
|
268
|
-
// Performance: Hint browser to optimize transform and opacity animations
|
|
269
|
-
"will-change-[transform,opacity]",
|
|
270
|
-
// Z-index: Active slide on top
|
|
271
|
-
isActive ? "z-10" : "z-5",
|
|
272
|
-
// Visibility: Hide slides that are too far away (performance optimization)
|
|
273
|
-
Math.abs(offset) > 1 ? "invisible" : "visible",
|
|
274
|
-
// Opacity: Mobile fade effect for adjacent cards, desktop always visible
|
|
275
|
-
isVisible ? "opacity-100" : "opacity-0",
|
|
276
|
-
// Transitions: Faster during swipe, slower when snapping to position
|
|
277
|
-
isSwiping
|
|
278
|
-
? "transition-opacity duration-200"
|
|
279
|
-
: "transition-[transform,opacity] duration-500 ease-[cubic-bezier(0.25,0.46,0.45,0.94)]"
|
|
280
|
-
)}
|
|
281
|
-
style={{
|
|
282
|
-
// Use CSS variable for dynamic transform value (can't be done with Tailwind)
|
|
283
|
-
transform: `translateX(${totalTransform}%)`,
|
|
284
|
-
}}
|
|
285
|
-
>
|
|
286
|
-
<TestimonialCard {...item} isActive={isActive} className="h-full" />
|
|
287
|
-
</div>
|
|
288
|
-
);
|
|
289
|
-
}
|
|
290
|
-
);
|
|
291
|
-
|
|
292
|
-
TestimonialCarouselSlide.displayName = "TestimonialCarouselSlide";
|
|
293
|
-
|
|
294
|
-
export const TestimonialCarousel: React.FC<{
|
|
295
|
-
fields: CarouselWithTestimonialCards;
|
|
296
|
-
autoScroll?: boolean;
|
|
297
|
-
autoScrollInterval?: number;
|
|
298
|
-
}> = ({ fields, autoScroll = true, autoScrollInterval = 8000 }) => {
|
|
299
|
-
const testimonials = fields?.items?.items || [];
|
|
300
|
-
|
|
301
|
-
if (!testimonials || testimonials.length === 0) {
|
|
302
|
-
return null;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// Use the generic carousel swipe hook
|
|
306
|
-
const carousel = useCarouselSwipe({
|
|
307
|
-
itemCount: testimonials.length,
|
|
308
|
-
cardOffsetPercentage: 105, // How far each card is offset (100% width + 5% gap)
|
|
309
|
-
swipeThreshold: 0.15, // Need to swipe 15% of screen to change slide
|
|
310
|
-
mobileBreakpoint: 768, // Tailwind's md breakpoint, Mobile if width < 768px
|
|
311
|
-
autoScrollInterval: autoScrollInterval, // 8 seconds between slides transition
|
|
312
|
-
enableAutoScroll: autoScroll,
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
return (
|
|
316
|
-
<div>
|
|
317
|
-
<div className="relative max-w-[1280px] md:px-11">
|
|
318
|
-
{/* Navigation Overlay - Left (Hidden on mobile) */}
|
|
319
|
-
<Button
|
|
320
|
-
onClick={carousel.prevSlide}
|
|
321
|
-
className="group absolute left-5 top-1/2 z-50 hidden h-13 w-13 -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"
|
|
322
|
-
aria-label="Previous"
|
|
323
|
-
>
|
|
324
|
-
<MaterialIcon name="arrow_back" size={24} />
|
|
325
|
-
</Button>
|
|
326
|
-
|
|
327
|
-
{/* Navigation Overlay - Right (Hidden on mobile) */}
|
|
328
|
-
<Button
|
|
329
|
-
onClick={carousel.nextSlide}
|
|
330
|
-
className="group absolute right-5 top-1/2 z-50 hidden h-13 w-13 -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"
|
|
331
|
-
aria-label="Next"
|
|
332
|
-
>
|
|
333
|
-
<MaterialIcon name="arrow_forward" size={24} />
|
|
334
|
-
</Button>
|
|
335
|
-
|
|
336
|
-
{/* Slider Track Wrapper */}
|
|
337
|
-
<div
|
|
338
|
-
ref={carousel.containerRef}
|
|
339
|
-
className="select-none overflow-hidden rounded-card-sm will-change-transform"
|
|
340
|
-
onTouchStart={carousel.handleTouchStart}
|
|
341
|
-
onTouchMove={carousel.handleTouchMove}
|
|
342
|
-
onTouchEnd={carousel.handleTouchEnd}
|
|
343
|
-
>
|
|
344
|
-
<div className="grid grid-cols-1 justify-items-center">
|
|
345
|
-
{/* Slides */}
|
|
346
|
-
{testimonials.map((item, index) => (
|
|
347
|
-
<TestimonialCarouselSlide
|
|
348
|
-
key={index}
|
|
349
|
-
item={item}
|
|
350
|
-
index={index}
|
|
351
|
-
currentIndex={carousel.currentIndex}
|
|
352
|
-
totalItems={testimonials.length}
|
|
353
|
-
swipeOffset={carousel.swipeOffset}
|
|
354
|
-
isSwiping={carousel.isSwiping}
|
|
355
|
-
isMobile={carousel.isMobile}
|
|
356
|
-
containerWidth={carousel.containerWidth}
|
|
357
|
-
cardOffsetPercentage={carousel.constants.CARD_OFFSET_PERCENTAGE}
|
|
358
|
-
/>
|
|
359
|
-
))}
|
|
360
|
-
</div>
|
|
361
|
-
</div>
|
|
362
|
-
</div>
|
|
363
|
-
{/* Dots (Hidden on mobile) */}
|
|
364
|
-
<div className="mt-5 flex justify-center gap-2 md:mt-10">
|
|
365
|
-
{testimonials.map((_, idx) => (
|
|
366
|
-
<button
|
|
367
|
-
key={idx}
|
|
368
|
-
onClick={() => carousel.goToSlide(idx)}
|
|
369
|
-
className={cx(
|
|
370
|
-
"h-2 w-2 rounded-full transition-all duration-300",
|
|
371
|
-
carousel.currentIndex === idx
|
|
372
|
-
? "w-6 bg-white"
|
|
373
|
-
: "bg-bg-fill-inverse-disabled hover:bg-white/60"
|
|
374
|
-
)}
|
|
375
|
-
aria-label={`Go to slide ${idx + 1}`}
|
|
376
|
-
/>
|
|
377
|
-
))}
|
|
378
|
-
</div>
|
|
379
|
-
</div>
|
|
380
|
-
);
|
|
381
|
-
};
|
|
382
|
-
|
|
383
|
-
export const TabSwitch: React.FC<TabSwitchProps> = ({
|
|
384
|
-
tabs,
|
|
385
|
-
activeTab,
|
|
386
|
-
onChange,
|
|
387
|
-
className,
|
|
388
|
-
}) => {
|
|
389
|
-
const activeIndex = tabs.indexOf(activeTab);
|
|
390
|
-
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
391
|
-
const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0 });
|
|
392
|
-
|
|
393
|
-
useEffect(() => {
|
|
394
|
-
const activeButton = buttonRefs.current[activeIndex];
|
|
395
|
-
if (activeButton) {
|
|
396
|
-
setIndicatorStyle({
|
|
397
|
-
width: activeButton.offsetWidth,
|
|
398
|
-
left: activeButton.offsetLeft,
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
}, [activeIndex, tabs]);
|
|
402
|
-
|
|
403
|
-
return (
|
|
404
|
-
<div className="flex w-full justify-center">
|
|
405
|
-
<div
|
|
406
|
-
className={cx(
|
|
407
|
-
"relative flex w-fit gap-1 rounded-button-lg bg-bg-surface-active p-1 transition-all duration-200 ease-out",
|
|
408
|
-
className
|
|
409
|
-
)}
|
|
410
|
-
>
|
|
411
|
-
{/* Sliding background indicator */}
|
|
412
|
-
<div
|
|
413
|
-
className="absolute bottom-1 top-1 rounded-button bg-bg-fill-brand shadow-md transition-all duration-300 ease-out"
|
|
414
|
-
style={{
|
|
415
|
-
width: `${indicatorStyle.width}px`,
|
|
416
|
-
left: `${indicatorStyle.left}px`,
|
|
417
|
-
}}
|
|
418
|
-
/>
|
|
419
|
-
|
|
420
|
-
{tabs.map((tab, index) => (
|
|
421
|
-
<button
|
|
422
|
-
key={tab}
|
|
423
|
-
ref={el => {
|
|
424
|
-
buttonRefs.current[index] = el;
|
|
425
|
-
}}
|
|
426
|
-
onClick={() => onChange(tab)}
|
|
427
|
-
className={cx(
|
|
428
|
-
"label1 relative z-10 flex min-w-[160px] items-center justify-center whitespace-nowrap rounded-button px-4 py-2.5 transition-colors duration-200 focus:outline-none active:bg-transparent",
|
|
429
|
-
activeTab === tab
|
|
430
|
-
? "text-text-inverse"
|
|
431
|
-
: "text-text-disabled hover:bg-bg-surface-hover"
|
|
432
|
-
)}
|
|
433
|
-
>
|
|
434
|
-
{tab}
|
|
435
|
-
</button>
|
|
436
|
-
))}
|
|
437
|
-
</div>
|
|
438
|
-
</div>
|
|
439
|
-
);
|
|
440
|
-
};
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
CarouselWithProductCards,
|
|
4
|
+
CarouselWithTestimonialCards,
|
|
5
|
+
TabSwitchProps,
|
|
6
|
+
} from "./types";
|
|
7
|
+
|
|
8
|
+
import { Button } from "@shared/components/button";
|
|
9
|
+
import { MaterialIcon } from "@shared/components/material-icon";
|
|
10
|
+
import { ProductCard } from "@shared/contentful/blocks/cards/product-card";
|
|
11
|
+
import { TestimonialCard } from "@shared/contentful/blocks/cards/testimonial-card";
|
|
12
|
+
import { useCarouselSwipe } from "@shared/hooks/use-carousel-swipe";
|
|
13
|
+
import { CheckPlansProps } from "@shared/types/micro-components";
|
|
14
|
+
import { cx } from "@shared/utils";
|
|
15
|
+
|
|
16
|
+
export function ProductCardCarousel({
|
|
17
|
+
fields,
|
|
18
|
+
renderCheckPlans,
|
|
19
|
+
}: {
|
|
20
|
+
fields: CarouselWithProductCards;
|
|
21
|
+
onModalButtonClick?: (id?: string) => void;
|
|
22
|
+
renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
|
|
23
|
+
}) {
|
|
24
|
+
const itemsExpanded = fields?.items?.items?.[0]?.benefitsExpanded || false;
|
|
25
|
+
const [desktopExpanded, setDesktopExpanded] = useState(itemsExpanded);
|
|
26
|
+
const [mobileExpandedStates, setMobileExpandedStates] = useState<
|
|
27
|
+
Record<number, boolean>
|
|
28
|
+
>({});
|
|
29
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
30
|
+
const items = fields?.items?.items || [];
|
|
31
|
+
const isCarousel = items.length > 2;
|
|
32
|
+
const cardsRef = useRef<(HTMLDivElement | null)[]>([]);
|
|
33
|
+
|
|
34
|
+
const prevSlide = useCallback(() => {
|
|
35
|
+
if (!isCarousel) return;
|
|
36
|
+
setCurrentIndex(prev => (prev + 3) % items.length);
|
|
37
|
+
}, [isCarousel, items.length]);
|
|
38
|
+
|
|
39
|
+
const nextSlide = useCallback(() => {
|
|
40
|
+
if (!isCarousel) return;
|
|
41
|
+
setCurrentIndex(prev => (prev - 3 + items.length) % items.length);
|
|
42
|
+
}, [isCarousel, items.length]);
|
|
43
|
+
|
|
44
|
+
// Equalize card heights
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!isCarousel) return;
|
|
47
|
+
|
|
48
|
+
const equalizeHeights = () => {
|
|
49
|
+
const cards = cardsRef.current.filter(Boolean) as HTMLDivElement[];
|
|
50
|
+
if (cards.length === 0) return;
|
|
51
|
+
|
|
52
|
+
// Reset heights first
|
|
53
|
+
cards.forEach(card => {
|
|
54
|
+
card.style.height = "auto";
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Find the tallest card
|
|
58
|
+
const maxHeight = Math.max(...cards.map(card => card.offsetHeight));
|
|
59
|
+
|
|
60
|
+
// Set all cards to the max height
|
|
61
|
+
cards.forEach(card => {
|
|
62
|
+
card.style.height = `${maxHeight}px`;
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Run on mount and when expanded state changes
|
|
67
|
+
equalizeHeights();
|
|
68
|
+
|
|
69
|
+
// Re-run after a short delay due to dynamic content
|
|
70
|
+
const timeoutId = setTimeout(equalizeHeights, 100);
|
|
71
|
+
|
|
72
|
+
return () => clearTimeout(timeoutId);
|
|
73
|
+
}, [isCarousel, desktopExpanded, items.length]);
|
|
74
|
+
|
|
75
|
+
if (!items.length && !fields?.title) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const renderCard = (
|
|
80
|
+
item: any,
|
|
81
|
+
globalIndex: number,
|
|
82
|
+
isMobileView: boolean = false
|
|
83
|
+
) => {
|
|
84
|
+
const planContent = item?.speed?.split("|");
|
|
85
|
+
const planName = planContent?.[0] || "";
|
|
86
|
+
const planSubtext = planContent?.[1] || "";
|
|
87
|
+
const priceCents = item?.priceSuffix?.split("/")?.[0] || "00";
|
|
88
|
+
const formattedPrice = `${item?.price || "0"}.${priceCents}`;
|
|
89
|
+
const features = item?.benefits?.items || [];
|
|
90
|
+
const bestValue = item?.highlighted || false;
|
|
91
|
+
const rawBadge = item?.giftRewards?.list?.items || [];
|
|
92
|
+
const innerBadge = {
|
|
93
|
+
badgeText: item.innerBadge || "",
|
|
94
|
+
badgeIcon: item.innerBadgeIcon?.url || "",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Mobile: individual card state, Desktop: shared state
|
|
98
|
+
const isExpanded = isMobileView
|
|
99
|
+
? mobileExpandedStates[globalIndex] || false
|
|
100
|
+
: desktopExpanded;
|
|
101
|
+
|
|
102
|
+
const handleToggle = () => {
|
|
103
|
+
if (isMobileView) {
|
|
104
|
+
setMobileExpandedStates(prev => ({
|
|
105
|
+
...prev,
|
|
106
|
+
[globalIndex]: !prev[globalIndex],
|
|
107
|
+
}));
|
|
108
|
+
} else {
|
|
109
|
+
setDesktopExpanded(!desktopExpanded);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const handleCtaClick = () => {
|
|
114
|
+
return;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div
|
|
119
|
+
ref={
|
|
120
|
+
!isMobileView
|
|
121
|
+
? el => {
|
|
122
|
+
if (el && !cardsRef.current.includes(el)) {
|
|
123
|
+
cardsRef.current[globalIndex] = el;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
: undefined
|
|
127
|
+
}
|
|
128
|
+
key={globalIndex}
|
|
129
|
+
className={cx(
|
|
130
|
+
isMobileView
|
|
131
|
+
? "mx-auto w-full max-w-[392px]"
|
|
132
|
+
: "flex-shrink-1 w-[392px]"
|
|
133
|
+
)}
|
|
134
|
+
>
|
|
135
|
+
<ProductCard
|
|
136
|
+
planName={planName}
|
|
137
|
+
planSubtext={planSubtext}
|
|
138
|
+
speed={item?.speed || ""}
|
|
139
|
+
techType={item?.techType || ""}
|
|
140
|
+
ismaxSpeed={item?.ismaxSpeed || ""}
|
|
141
|
+
price={formattedPrice}
|
|
142
|
+
description={item?.productCardDescription || ""}
|
|
143
|
+
giftBadge={rawBadge}
|
|
144
|
+
innerBadge={innerBadge}
|
|
145
|
+
theme={item?.highlighted ? "dark" : "light"}
|
|
146
|
+
bestValue={bestValue}
|
|
147
|
+
bestValueText={item?.topBadgeText || "best value"}
|
|
148
|
+
featuresTitle={item?.benefitsTitle}
|
|
149
|
+
features={features}
|
|
150
|
+
isExpanded={isExpanded}
|
|
151
|
+
onToggleExpand={handleToggle}
|
|
152
|
+
ctaText={item?.cta?.buttonLabel || "Select plan"}
|
|
153
|
+
onCtaClick={handleCtaClick}
|
|
154
|
+
renderCheckPlans={renderCheckPlans}
|
|
155
|
+
cta={item.cta}
|
|
156
|
+
hostType="smb"
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// For infinite scroll with peeking, starting in the middle of the repeated array
|
|
163
|
+
const displayItems = [...items, ...items, ...items, ...items, ...items];
|
|
164
|
+
const offset = items.length * 2;
|
|
165
|
+
|
|
166
|
+
return !isCarousel ? (
|
|
167
|
+
<div className="flex w-full max-w-[1280px] flex-wrap justify-center gap-4">
|
|
168
|
+
{items.map((item, index) => renderCard(item, index))}
|
|
169
|
+
</div>
|
|
170
|
+
) : (
|
|
171
|
+
<>
|
|
172
|
+
{/* Mobile/Tablet View: Vertical Stack */}
|
|
173
|
+
<div className="flex flex-col gap-6 md:hidden">
|
|
174
|
+
{items.map((item, index) => renderCard(item, index, true))}
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{/* Desktop View: Horizontal Carousel */}
|
|
178
|
+
<div className="relative hidden w-full md:block">
|
|
179
|
+
{/* Navigation Arrows */}
|
|
180
|
+
<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">
|
|
181
|
+
<Button
|
|
182
|
+
onClick={prevSlide}
|
|
183
|
+
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"
|
|
184
|
+
aria-label="Previous"
|
|
185
|
+
>
|
|
186
|
+
<MaterialIcon name="arrow_back" size={24} />
|
|
187
|
+
</Button>
|
|
188
|
+
|
|
189
|
+
<Button
|
|
190
|
+
onClick={nextSlide}
|
|
191
|
+
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"
|
|
192
|
+
aria-label="Next"
|
|
193
|
+
>
|
|
194
|
+
<MaterialIcon name="arrow_forward" size={24} />
|
|
195
|
+
</Button>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
{/* Carousel Window */}
|
|
199
|
+
<div className="mx-auto max-w-[1280px] overflow-hidden">
|
|
200
|
+
<div
|
|
201
|
+
className="flex items-stretch gap-4 pb-4 transition-transform duration-500 ease-in-out"
|
|
202
|
+
style={{
|
|
203
|
+
transform: `translateX(calc(-${offset + currentIndex} * (392px + 12px)))`,
|
|
204
|
+
}}
|
|
205
|
+
>
|
|
206
|
+
{displayItems.map((item, index) => renderCard(item, index, false))}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Individual slide component for the testimonial carousel
|
|
216
|
+
* Memoized to prevent unnecessary re-renders of inactive slides
|
|
217
|
+
*/
|
|
218
|
+
const TestimonialCarouselSlide = React.memo<{
|
|
219
|
+
item: any;
|
|
220
|
+
index: number;
|
|
221
|
+
currentIndex: number;
|
|
222
|
+
totalItems: number;
|
|
223
|
+
swipeOffset: number;
|
|
224
|
+
isSwiping: boolean;
|
|
225
|
+
isMobile: boolean;
|
|
226
|
+
containerWidth: number;
|
|
227
|
+
cardOffsetPercentage: number;
|
|
228
|
+
}>(
|
|
229
|
+
({
|
|
230
|
+
item,
|
|
231
|
+
index,
|
|
232
|
+
currentIndex,
|
|
233
|
+
totalItems,
|
|
234
|
+
swipeOffset,
|
|
235
|
+
isSwiping,
|
|
236
|
+
isMobile,
|
|
237
|
+
containerWidth,
|
|
238
|
+
cardOffsetPercentage,
|
|
239
|
+
}) => {
|
|
240
|
+
// Calculate circular offset for infinite carousel feel
|
|
241
|
+
let offset = index - currentIndex;
|
|
242
|
+
const len = totalItems;
|
|
243
|
+
if (offset > len / 2) offset -= len;
|
|
244
|
+
if (offset < -len / 2) offset += len;
|
|
245
|
+
|
|
246
|
+
const isActive = offset === 0;
|
|
247
|
+
const isAdjacent = Math.abs(offset) === 1;
|
|
248
|
+
|
|
249
|
+
// Calculate card position with smooth swipe following
|
|
250
|
+
// Base position is determined by card index offset (105% spacing between cards)
|
|
251
|
+
const baseTransform = offset * cardOffsetPercentage;
|
|
252
|
+
// Convert swipe pixels to percentage for smooth finger-following during swipe
|
|
253
|
+
const swipePercentage = (swipeOffset / containerWidth) * 100;
|
|
254
|
+
const totalTransform = baseTransform + swipePercentage;
|
|
255
|
+
|
|
256
|
+
// Mobile: Show adjacent cards during swipe for preview effect
|
|
257
|
+
// Desktop: Cards always visible (handled by opacity check below)
|
|
258
|
+
const showOnMobile = isSwiping ? isActive || isAdjacent : isActive;
|
|
259
|
+
|
|
260
|
+
// Determine opacity visibility (mobile-specific behavior)
|
|
261
|
+
const isVisible = isMobile ? showOnMobile : true;
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<div
|
|
265
|
+
className={cx(
|
|
266
|
+
// Layout
|
|
267
|
+
"col-start-1 row-start-1 w-full md:max-w-[815px]",
|
|
268
|
+
// Performance: Hint browser to optimize transform and opacity animations
|
|
269
|
+
"will-change-[transform,opacity]",
|
|
270
|
+
// Z-index: Active slide on top
|
|
271
|
+
isActive ? "z-10" : "z-5",
|
|
272
|
+
// Visibility: Hide slides that are too far away (performance optimization)
|
|
273
|
+
Math.abs(offset) > 1 ? "invisible" : "visible",
|
|
274
|
+
// Opacity: Mobile fade effect for adjacent cards, desktop always visible
|
|
275
|
+
isVisible ? "opacity-100" : "opacity-0",
|
|
276
|
+
// Transitions: Faster during swipe, slower when snapping to position
|
|
277
|
+
isSwiping
|
|
278
|
+
? "transition-opacity duration-200"
|
|
279
|
+
: "transition-[transform,opacity] duration-500 ease-[cubic-bezier(0.25,0.46,0.45,0.94)]"
|
|
280
|
+
)}
|
|
281
|
+
style={{
|
|
282
|
+
// Use CSS variable for dynamic transform value (can't be done with Tailwind)
|
|
283
|
+
transform: `translateX(${totalTransform}%)`,
|
|
284
|
+
}}
|
|
285
|
+
>
|
|
286
|
+
<TestimonialCard {...item} isActive={isActive} className="h-full" />
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
TestimonialCarouselSlide.displayName = "TestimonialCarouselSlide";
|
|
293
|
+
|
|
294
|
+
export const TestimonialCarousel: React.FC<{
|
|
295
|
+
fields: CarouselWithTestimonialCards;
|
|
296
|
+
autoScroll?: boolean;
|
|
297
|
+
autoScrollInterval?: number;
|
|
298
|
+
}> = ({ fields, autoScroll = true, autoScrollInterval = 8000 }) => {
|
|
299
|
+
const testimonials = fields?.items?.items || [];
|
|
300
|
+
|
|
301
|
+
if (!testimonials || testimonials.length === 0) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Use the generic carousel swipe hook
|
|
306
|
+
const carousel = useCarouselSwipe({
|
|
307
|
+
itemCount: testimonials.length,
|
|
308
|
+
cardOffsetPercentage: 105, // How far each card is offset (100% width + 5% gap)
|
|
309
|
+
swipeThreshold: 0.15, // Need to swipe 15% of screen to change slide
|
|
310
|
+
mobileBreakpoint: 768, // Tailwind's md breakpoint, Mobile if width < 768px
|
|
311
|
+
autoScrollInterval: autoScrollInterval, // 8 seconds between slides transition
|
|
312
|
+
enableAutoScroll: autoScroll,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<div>
|
|
317
|
+
<div className="relative max-w-[1280px] md:px-11">
|
|
318
|
+
{/* Navigation Overlay - Left (Hidden on mobile) */}
|
|
319
|
+
<Button
|
|
320
|
+
onClick={carousel.prevSlide}
|
|
321
|
+
className="group absolute left-5 top-1/2 z-50 hidden h-13 w-13 -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"
|
|
322
|
+
aria-label="Previous"
|
|
323
|
+
>
|
|
324
|
+
<MaterialIcon name="arrow_back" size={24} />
|
|
325
|
+
</Button>
|
|
326
|
+
|
|
327
|
+
{/* Navigation Overlay - Right (Hidden on mobile) */}
|
|
328
|
+
<Button
|
|
329
|
+
onClick={carousel.nextSlide}
|
|
330
|
+
className="group absolute right-5 top-1/2 z-50 hidden h-13 w-13 -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"
|
|
331
|
+
aria-label="Next"
|
|
332
|
+
>
|
|
333
|
+
<MaterialIcon name="arrow_forward" size={24} />
|
|
334
|
+
</Button>
|
|
335
|
+
|
|
336
|
+
{/* Slider Track Wrapper */}
|
|
337
|
+
<div
|
|
338
|
+
ref={carousel.containerRef}
|
|
339
|
+
className="select-none overflow-hidden rounded-card-sm will-change-transform"
|
|
340
|
+
onTouchStart={carousel.handleTouchStart}
|
|
341
|
+
onTouchMove={carousel.handleTouchMove}
|
|
342
|
+
onTouchEnd={carousel.handleTouchEnd}
|
|
343
|
+
>
|
|
344
|
+
<div className="grid grid-cols-1 justify-items-center">
|
|
345
|
+
{/* Slides */}
|
|
346
|
+
{testimonials.map((item, index) => (
|
|
347
|
+
<TestimonialCarouselSlide
|
|
348
|
+
key={index}
|
|
349
|
+
item={item}
|
|
350
|
+
index={index}
|
|
351
|
+
currentIndex={carousel.currentIndex}
|
|
352
|
+
totalItems={testimonials.length}
|
|
353
|
+
swipeOffset={carousel.swipeOffset}
|
|
354
|
+
isSwiping={carousel.isSwiping}
|
|
355
|
+
isMobile={carousel.isMobile}
|
|
356
|
+
containerWidth={carousel.containerWidth}
|
|
357
|
+
cardOffsetPercentage={carousel.constants.CARD_OFFSET_PERCENTAGE}
|
|
358
|
+
/>
|
|
359
|
+
))}
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
{/* Dots (Hidden on mobile) */}
|
|
364
|
+
<div className="mt-5 flex justify-center gap-2 md:mt-10">
|
|
365
|
+
{testimonials.map((_, idx) => (
|
|
366
|
+
<button
|
|
367
|
+
key={idx}
|
|
368
|
+
onClick={() => carousel.goToSlide(idx)}
|
|
369
|
+
className={cx(
|
|
370
|
+
"h-2 w-2 rounded-full transition-all duration-300",
|
|
371
|
+
carousel.currentIndex === idx
|
|
372
|
+
? "w-6 bg-white"
|
|
373
|
+
: "bg-bg-fill-inverse-disabled hover:bg-white/60"
|
|
374
|
+
)}
|
|
375
|
+
aria-label={`Go to slide ${idx + 1}`}
|
|
376
|
+
/>
|
|
377
|
+
))}
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
);
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
export const TabSwitch: React.FC<TabSwitchProps> = ({
|
|
384
|
+
tabs,
|
|
385
|
+
activeTab,
|
|
386
|
+
onChange,
|
|
387
|
+
className,
|
|
388
|
+
}) => {
|
|
389
|
+
const activeIndex = tabs.indexOf(activeTab);
|
|
390
|
+
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
391
|
+
const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0 });
|
|
392
|
+
|
|
393
|
+
useEffect(() => {
|
|
394
|
+
const activeButton = buttonRefs.current[activeIndex];
|
|
395
|
+
if (activeButton) {
|
|
396
|
+
setIndicatorStyle({
|
|
397
|
+
width: activeButton.offsetWidth,
|
|
398
|
+
left: activeButton.offsetLeft,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}, [activeIndex, tabs]);
|
|
402
|
+
|
|
403
|
+
return (
|
|
404
|
+
<div className="flex w-full justify-center">
|
|
405
|
+
<div
|
|
406
|
+
className={cx(
|
|
407
|
+
"relative flex w-fit gap-1 rounded-button-lg bg-bg-surface-active p-1 transition-all duration-200 ease-out",
|
|
408
|
+
className
|
|
409
|
+
)}
|
|
410
|
+
>
|
|
411
|
+
{/* Sliding background indicator */}
|
|
412
|
+
<div
|
|
413
|
+
className="absolute bottom-1 top-1 rounded-button bg-bg-fill-brand shadow-md transition-all duration-300 ease-out"
|
|
414
|
+
style={{
|
|
415
|
+
width: `${indicatorStyle.width}px`,
|
|
416
|
+
left: `${indicatorStyle.left}px`,
|
|
417
|
+
}}
|
|
418
|
+
/>
|
|
419
|
+
|
|
420
|
+
{tabs.map((tab, index) => (
|
|
421
|
+
<button
|
|
422
|
+
key={tab}
|
|
423
|
+
ref={el => {
|
|
424
|
+
buttonRefs.current[index] = el;
|
|
425
|
+
}}
|
|
426
|
+
onClick={() => onChange(tab)}
|
|
427
|
+
className={cx(
|
|
428
|
+
"label1 relative z-10 flex min-w-[160px] items-center justify-center whitespace-nowrap rounded-button px-4 py-2.5 transition-colors duration-200 focus:outline-none active:bg-transparent",
|
|
429
|
+
activeTab === tab
|
|
430
|
+
? "text-text-inverse"
|
|
431
|
+
: "text-text-disabled hover:bg-bg-surface-hover"
|
|
432
|
+
)}
|
|
433
|
+
>
|
|
434
|
+
{tab}
|
|
435
|
+
</button>
|
|
436
|
+
))}
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
);
|
|
440
|
+
};
|