@windstream/react-shared-components 0.0.84 → 0.0.86

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