@windstream/react-shared-components 0.1.94 → 0.1.96

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