@windstream/react-shared-components 0.1.77 → 0.1.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/README.md +635 -635
  2. package/dist/contentful/index.d.ts +5 -1
  3. package/dist/contentful/index.esm.js +3 -3
  4. package/dist/contentful/index.esm.js.map +1 -1
  5. package/dist/contentful/index.js +2 -2
  6. package/dist/contentful/index.js.map +1 -1
  7. package/dist/core.d.ts +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.esm.js.map +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/next/index.esm.js.map +1 -1
  12. package/dist/next/index.js.map +1 -1
  13. package/dist/styles.css +1 -1
  14. package/dist/utils/index.esm.js.map +1 -1
  15. package/dist/utils/index.js.map +1 -1
  16. package/package.json +185 -185
  17. package/src/components/accordion/Accordion.stories.tsx +230 -230
  18. package/src/components/accordion/index.tsx +70 -70
  19. package/src/components/accordion/types.ts +12 -12
  20. package/src/components/alert-card/AlertCard.stories.tsx +171 -171
  21. package/src/components/alert-card/index.tsx +41 -41
  22. package/src/components/alert-card/types.ts +13 -13
  23. package/src/components/brand-button/BrandButton.stories.tsx +223 -223
  24. package/src/components/brand-button/helpers.ts +35 -35
  25. package/src/components/brand-button/index.tsx +120 -120
  26. package/src/components/brand-button/types.ts +38 -38
  27. package/src/components/button/Button.stories.tsx +108 -108
  28. package/src/components/button/index.tsx +27 -27
  29. package/src/components/button/types.ts +14 -14
  30. package/src/components/call-button/CallButton.stories.tsx +324 -324
  31. package/src/components/call-button/index.tsx +106 -106
  32. package/src/components/call-button/types.ts +16 -16
  33. package/src/components/checkbox/Checkbox.stories.tsx +247 -247
  34. package/src/components/checkbox/index.tsx +197 -197
  35. package/src/components/checkbox/types.ts +27 -27
  36. package/src/components/checklist/Checklist.stories.tsx +150 -150
  37. package/src/components/checklist/index.tsx +61 -61
  38. package/src/components/checklist/types.ts +17 -17
  39. package/src/components/collapse/Collapse.stories.tsx +255 -255
  40. package/src/components/collapse/index.tsx +46 -46
  41. package/src/components/collapse/types.ts +6 -6
  42. package/src/components/divider/Divider.stories.tsx +205 -205
  43. package/src/components/divider/index.tsx +22 -22
  44. package/src/components/divider/type.ts +3 -3
  45. package/src/components/image/Image.stories.tsx +113 -113
  46. package/src/components/image/index.tsx +25 -25
  47. package/src/components/image/types.ts +40 -40
  48. package/src/components/input/Input.stories.tsx +325 -325
  49. package/src/components/input/index.tsx +177 -177
  50. package/src/components/input/types.ts +37 -37
  51. package/src/components/link/Link.stories.tsx +163 -163
  52. package/src/components/link/index.tsx +116 -116
  53. package/src/components/link/types.ts +25 -25
  54. package/src/components/list/List.stories.tsx +272 -272
  55. package/src/components/list/index.tsx +88 -88
  56. package/src/components/list/list-item/index.tsx +38 -38
  57. package/src/components/list/list-item/types.ts +13 -13
  58. package/src/components/list/types.ts +29 -29
  59. package/src/components/material-icon/MaterialIcon.stories.tsx +322 -322
  60. package/src/components/material-icon/constants.ts +99 -99
  61. package/src/components/material-icon/index.tsx +47 -47
  62. package/src/components/material-icon/types.ts +31 -31
  63. package/src/components/modal/Modal.stories.tsx +171 -171
  64. package/src/components/modal/index.tsx +164 -164
  65. package/src/components/modal/types.ts +24 -24
  66. package/src/components/next-image/index.tsx +74 -74
  67. package/src/components/next-image/types.ts +1 -1
  68. package/src/components/pagination/index.tsx +91 -91
  69. package/src/components/pagination/types.ts +6 -6
  70. package/src/components/radio-button/RadioButton.stories.tsx +307 -307
  71. package/src/components/radio-button/index.tsx +75 -75
  72. package/src/components/radio-button/types.ts +21 -21
  73. package/src/components/see-more/SeeMore.stories.tsx +181 -181
  74. package/src/components/see-more/index.tsx +44 -44
  75. package/src/components/see-more/types.ts +4 -4
  76. package/src/components/select/Select.stories.tsx +411 -411
  77. package/src/components/select/index.tsx +155 -155
  78. package/src/components/select/types.ts +36 -36
  79. package/src/components/select-plan-button/SelectPlanButton.stories.tsx +184 -184
  80. package/src/components/select-plan-button/index.tsx +63 -63
  81. package/src/components/select-plan-button/types.ts +17 -17
  82. package/src/components/skeleton/Skeleton.stories.tsx +179 -179
  83. package/src/components/skeleton/index.tsx +61 -61
  84. package/src/components/skeleton/types.ts +4 -4
  85. package/src/components/spinner/Spinner.stories.tsx +335 -335
  86. package/src/components/spinner/index.tsx +44 -44
  87. package/src/components/spinner/types.ts +5 -5
  88. package/src/components/text/Text.stories.tsx +321 -321
  89. package/src/components/text/index.tsx +25 -25
  90. package/src/components/text/types.ts +45 -45
  91. package/src/components/tooltip/Tooltip.stories.tsx +219 -219
  92. package/src/components/tooltip/index.tsx +74 -74
  93. package/src/components/tooltip/types.ts +7 -7
  94. package/src/components/view-cart-button/ViewCartButton.stories.tsx +252 -252
  95. package/src/components/view-cart-button/index.tsx +42 -42
  96. package/src/components/view-cart-button/types.ts +5 -5
  97. package/src/contentful/blocks/accordion/Accordion.stories.mocks.tsx +128 -128
  98. package/src/contentful/blocks/accordion/Accordion.stories.tsx +98 -98
  99. package/src/contentful/blocks/accordion/index.tsx +112 -112
  100. package/src/contentful/blocks/accordion/types.ts +34 -34
  101. package/src/contentful/blocks/address-input-banner/index.tsx +52 -52
  102. package/src/contentful/blocks/address-input-banner/types.ts +14 -14
  103. package/src/contentful/blocks/anchored-bottom-banner/index.tsx +181 -181
  104. package/src/contentful/blocks/anchored-bottom-banner/types.ts +13 -13
  105. package/src/contentful/blocks/blogs-grid/index.tsx +134 -134
  106. package/src/contentful/blocks/blogs-grid/types.ts +26 -26
  107. package/src/contentful/blocks/blogs-grid-base/index.tsx +119 -119
  108. package/src/contentful/blocks/blogs-grid-base/types.ts +36 -36
  109. package/src/contentful/blocks/breadcrumbs/index.tsx +81 -81
  110. package/src/contentful/blocks/breadcrumbs/types.ts +6 -6
  111. package/src/contentful/blocks/button/Button.stories.tsx +40 -40
  112. package/src/contentful/blocks/button/index.tsx +131 -131
  113. package/src/contentful/blocks/button/types.ts +39 -39
  114. package/src/contentful/blocks/callout/Callout.stories.tsx +23 -23
  115. package/src/contentful/blocks/callout/index.tsx +265 -252
  116. package/src/contentful/blocks/callout/types.ts +72 -68
  117. package/src/contentful/blocks/cards/Cards.stories.tsx +23 -23
  118. package/src/contentful/blocks/cards/blog-card/index.tsx +129 -129
  119. package/src/contentful/blocks/cards/blog-card/types.ts +34 -34
  120. package/src/contentful/blocks/cards/floating-image-card/index.tsx +119 -119
  121. package/src/contentful/blocks/cards/floating-image-card/types.ts +30 -30
  122. package/src/contentful/blocks/cards/full-image-card/index.tsx +130 -130
  123. package/src/contentful/blocks/cards/full-image-card/types.ts +29 -29
  124. package/src/contentful/blocks/cards/index.tsx +13 -13
  125. package/src/contentful/blocks/cards/product-card/index.tsx +251 -251
  126. package/src/contentful/blocks/cards/product-card/types.ts +28 -28
  127. package/src/contentful/blocks/cards/simple-card/index.tsx +325 -325
  128. package/src/contentful/blocks/cards/simple-card/types.ts +71 -71
  129. package/src/contentful/blocks/cards/testimonial-card/index.tsx +90 -90
  130. package/src/contentful/blocks/cards/testimonial-card/types.tsx +12 -12
  131. package/src/contentful/blocks/cards/types.ts +1 -1
  132. package/src/contentful/blocks/carousel/Carousel.stories.tsx +23 -23
  133. package/src/contentful/blocks/carousel/helper.tsx +440 -440
  134. package/src/contentful/blocks/carousel/index.tsx +85 -85
  135. package/src/contentful/blocks/carousel/types.ts +144 -144
  136. package/src/contentful/blocks/cart-retention-banner/index.tsx +105 -105
  137. package/src/contentful/blocks/cart-retention-banner/types.ts +11 -11
  138. package/src/contentful/blocks/comparison-table/index.tsx +29 -29
  139. package/src/contentful/blocks/comparison-table/types.ts +6 -6
  140. package/src/contentful/blocks/cookiebanner/index.tsx +146 -146
  141. package/src/contentful/blocks/cookiebanner/type.ts +7 -7
  142. package/src/contentful/blocks/cta-callout/CtaCallout.stories.tsx +46 -46
  143. package/src/contentful/blocks/cta-callout/index.tsx +71 -71
  144. package/src/contentful/blocks/cta-callout/types.ts +26 -26
  145. package/src/contentful/blocks/dynamic-tabs/index.tsx +204 -204
  146. package/src/contentful/blocks/dynamic-tabs/types.ts +21 -21
  147. package/src/contentful/blocks/email-input-block/index.tsx +116 -116
  148. package/src/contentful/blocks/email-input-block/types.ts +16 -16
  149. package/src/contentful/blocks/find-kinetic/FindKinetic.stories.tsx +23 -23
  150. package/src/contentful/blocks/find-kinetic/index.tsx +130 -130
  151. package/src/contentful/blocks/find-kinetic/types.ts +19 -19
  152. package/src/contentful/blocks/floating-banner/FloatingBanner.stories.tsx +34 -34
  153. package/src/contentful/blocks/floating-banner/index.tsx +97 -97
  154. package/src/contentful/blocks/floating-banner/types.ts +22 -22
  155. package/src/contentful/blocks/footer/Footer.stories.tsx +317 -317
  156. package/src/contentful/blocks/footer/index.tsx +91 -91
  157. package/src/contentful/blocks/footer/types.ts +13 -13
  158. package/src/contentful/blocks/image-promo-bar/ImagePromoBar.stories.tsx +23 -23
  159. package/src/contentful/blocks/image-promo-bar/helper.tsx +28 -28
  160. package/src/contentful/blocks/image-promo-bar/index.tsx +246 -246
  161. package/src/contentful/blocks/image-promo-bar/types.ts +44 -44
  162. package/src/contentful/blocks/image-promo-bar/vimeo-embed.tsx +93 -93
  163. package/src/contentful/blocks/image-promo-bar/youtube-embed.tsx +46 -46
  164. package/src/contentful/blocks/modal/constants.ts +53 -53
  165. package/src/contentful/blocks/modal/index.tsx +107 -107
  166. package/src/contentful/blocks/modal/types.ts +12 -12
  167. package/src/contentful/blocks/navigation/desktop-link-groups.tsx/index.tsx +139 -139
  168. package/src/contentful/blocks/navigation/index.tsx +568 -567
  169. package/src/contentful/blocks/navigation/mobile-link-groups.tsx/index.tsx +82 -82
  170. package/src/contentful/blocks/navigation/types.ts +71 -71
  171. package/src/contentful/blocks/primary-hero/PrimaryHero.stories.tsx +23 -23
  172. package/src/contentful/blocks/primary-hero/index.tsx +236 -236
  173. package/src/contentful/blocks/primary-hero/types.ts +37 -37
  174. package/src/contentful/blocks/search-block/index.tsx +90 -90
  175. package/src/contentful/blocks/search-block/types.ts +15 -15
  176. package/src/contentful/blocks/shape-background-wrapper/ShapeBackgroundWrapper.stories.tsx +26 -26
  177. package/src/contentful/blocks/shape-background-wrapper/index.tsx +124 -124
  178. package/src/contentful/blocks/shape-background-wrapper/types.ts +36 -36
  179. package/src/contentful/blocks/text/Text.stories.tsx +23 -23
  180. package/src/contentful/blocks/text/index.tsx +12 -12
  181. package/src/contentful/blocks/text/types.ts +1 -1
  182. package/src/contentful/index.ts +105 -105
  183. package/src/hooks/contentful/use-contentful-rich-text.tsx +309 -309
  184. package/src/hooks/contentful/use-processed-check-list.ts +63 -63
  185. package/src/hooks/use-body-scroll-lock.ts +34 -34
  186. package/src/hooks/use-carousel-swipe.ts +264 -264
  187. package/src/hooks/use-outside-click.ts +17 -17
  188. package/src/index.ts +101 -101
  189. package/src/next/index.ts +5 -5
  190. package/src/setupTests.ts +46 -46
  191. package/src/stories/DocsTemplate.tsx +24 -24
  192. package/src/styles/globals.css +343 -343
  193. package/src/types/global.d.ts +9 -9
  194. package/src/types/micro-components.ts +99 -99
  195. package/src/types/utm.ts +49 -49
  196. package/src/utils/contentful/to-document.ts +24 -24
  197. package/src/utils/cookie.ts +84 -84
  198. package/src/utils/cx.ts +49 -49
  199. package/src/utils/index.ts +41 -41
  200. package/src/utils/speed-card-bg.ts +24 -24
  201. 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 - 1 + items.length) % items.length);
37
- }, [isCarousel, items.length]);
38
-
39
- const nextSlide = useCallback(() => {
40
- if (!isCarousel) return;
41
- setCurrentIndex(prev => (prev + 1) % 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
- : "w-[392px] flex-shrink-0"
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 - 1 + items.length) % items.length);
37
+ }, [isCarousel, items.length]);
38
+
39
+ const nextSlide = useCallback(() => {
40
+ if (!isCarousel) return;
41
+ setCurrentIndex(prev => (prev + 1) % 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
+ : "w-[392px] flex-shrink-0"
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
+ };