@windstream/react-shared-components 0.1.19 → 0.1.21

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