@windstream/react-shared-components 0.0.93 → 0.0.94

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