@windstream/react-shared-components 0.2.14 → 0.2.16
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.
- package/dist/contentful/index.d.ts +5 -1
- package/dist/contentful/index.esm.js +2 -2
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +3 -3
- package/dist/contentful/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/contentful/blocks/address-input-banner/index.tsx +1 -1
- package/src/contentful/blocks/button/index.tsx +21 -2
- package/src/contentful/blocks/button/types.ts +3 -0
- package/src/contentful/blocks/carousel/helper.tsx +118 -26
- package/src/contentful/blocks/cart-retention-banner/index.tsx +4 -0
- package/src/contentful/blocks/cart-retention-banner/types.ts +8 -1
- package/src/contentful/blocks/primary-hero/index.tsx +5 -5
- package/src/hooks/use-carousel-swipe.test.ts +39 -0
- package/src/hooks/use-carousel-swipe.ts +3 -1
|
@@ -29,20 +29,96 @@ function ProductCardPanel({
|
|
|
29
29
|
Record<number, boolean>
|
|
30
30
|
>({});
|
|
31
31
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
32
|
+
const [maxIndex, setMaxIndex] = useState(0);
|
|
33
|
+
const [swipeOffset, setSwipeOffset] = useState(0);
|
|
34
|
+
const [isSwiping, setIsSwiping] = useState(false);
|
|
32
35
|
const items = fields?.items?.items || [];
|
|
33
36
|
const isCarousel = items.length > 2;
|
|
34
37
|
const showArrows = fields?.showArrows !== false && isCarousel;
|
|
35
38
|
const cardsRef = useRef<(HTMLDivElement | null)[]>([]);
|
|
39
|
+
const windowRef = useRef<HTMLDivElement | null>(null);
|
|
40
|
+
const touchStartX = useRef<number>(0);
|
|
41
|
+
|
|
42
|
+
// Determine how many cards fit so we never scroll past the last card
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!isCarousel || !isVisible) return;
|
|
45
|
+
|
|
46
|
+
const gap = 16; // track gap-4 (16px)
|
|
47
|
+
const cardWidth = 392 + gap; // card width + gap
|
|
48
|
+
const trackMargin = 32; // inner track ml-8 (32px)
|
|
49
|
+
const computeMaxIndex = () => {
|
|
50
|
+
const containerWidth = windowRef.current?.offsetWidth || 0;
|
|
51
|
+
// Skip while hidden (offsetWidth is 0) to avoid overestimating maxIndex
|
|
52
|
+
if (containerWidth === 0) return;
|
|
53
|
+
const usableWidth = containerWidth - trackMargin;
|
|
54
|
+
const visibleCards = Math.max(
|
|
55
|
+
1,
|
|
56
|
+
Math.floor((usableWidth + gap) / cardWidth)
|
|
57
|
+
);
|
|
58
|
+
setMaxIndex(Math.max(0, items.length - visibleCards));
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
computeMaxIndex();
|
|
62
|
+
window.addEventListener("resize", computeMaxIndex);
|
|
63
|
+
return () => window.removeEventListener("resize", computeMaxIndex);
|
|
64
|
+
}, [isCarousel, isVisible, items.length]);
|
|
65
|
+
|
|
66
|
+
// Keep the active index within the available range
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
setCurrentIndex(prev => Math.min(prev, maxIndex));
|
|
69
|
+
}, [maxIndex]);
|
|
36
70
|
|
|
37
71
|
const prevSlide = useCallback(() => {
|
|
38
72
|
if (!isCarousel) return;
|
|
39
|
-
setCurrentIndex(prev => (prev - 1
|
|
40
|
-
}, [isCarousel
|
|
73
|
+
setCurrentIndex(prev => Math.max(0, prev - 1));
|
|
74
|
+
}, [isCarousel]);
|
|
41
75
|
|
|
42
76
|
const nextSlide = useCallback(() => {
|
|
43
77
|
if (!isCarousel) return;
|
|
44
|
-
setCurrentIndex(prev => (prev + 1)
|
|
45
|
-
}, [isCarousel,
|
|
78
|
+
setCurrentIndex(prev => Math.min(maxIndex, prev + 1));
|
|
79
|
+
}, [isCarousel, maxIndex]);
|
|
80
|
+
|
|
81
|
+
// Touch/swipe handlers for finger-following drag (touch devices)
|
|
82
|
+
const handleTouchStart = useCallback(
|
|
83
|
+
(e: React.TouchEvent) => {
|
|
84
|
+
if (!isCarousel) return;
|
|
85
|
+
touchStartX.current = e.touches[0].clientX;
|
|
86
|
+
setIsSwiping(true);
|
|
87
|
+
},
|
|
88
|
+
[isCarousel]
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const handleTouchMove = useCallback(
|
|
92
|
+
(e: React.TouchEvent) => {
|
|
93
|
+
if (!isSwiping) return;
|
|
94
|
+
const diff = e.touches[0].clientX - touchStartX.current;
|
|
95
|
+
// Apply resistance when dragging past the start/end boundaries
|
|
96
|
+
const atStart = currentIndex === 0;
|
|
97
|
+
const atEnd = currentIndex >= maxIndex;
|
|
98
|
+
if ((atStart && diff > 0) || (atEnd && diff < 0)) {
|
|
99
|
+
setSwipeOffset(diff * 0.3);
|
|
100
|
+
} else {
|
|
101
|
+
setSwipeOffset(diff);
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
[isSwiping, currentIndex, maxIndex]
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const handleTouchEnd = useCallback(() => {
|
|
108
|
+
if (!isSwiping) return;
|
|
109
|
+
setIsSwiping(false);
|
|
110
|
+
|
|
111
|
+
const containerWidth = windowRef.current?.offsetWidth || 0;
|
|
112
|
+
const threshold = containerWidth * 0.15;
|
|
113
|
+
|
|
114
|
+
if (swipeOffset > threshold) {
|
|
115
|
+
prevSlide();
|
|
116
|
+
} else if (swipeOffset < -threshold) {
|
|
117
|
+
nextSlide();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
setSwipeOffset(0);
|
|
121
|
+
}, [isSwiping, swipeOffset, prevSlide, nextSlide]);
|
|
46
122
|
|
|
47
123
|
// Equalize card heights
|
|
48
124
|
useEffect(() => {
|
|
@@ -162,10 +238,6 @@ function ProductCardPanel({
|
|
|
162
238
|
);
|
|
163
239
|
};
|
|
164
240
|
|
|
165
|
-
// For infinite scroll with peeking, starting in the middle of the repeated array
|
|
166
|
-
const displayItems = [...items, ...items, ...items, ...items, ...items];
|
|
167
|
-
const offset = items.length * 2;
|
|
168
|
-
|
|
169
241
|
return !isCarousel ? (
|
|
170
242
|
<div className="flex w-full max-w-[1280px] flex-wrap justify-center gap-4">
|
|
171
243
|
{items.map((item, index) => renderCard(item, index))}
|
|
@@ -182,33 +254,53 @@ function ProductCardPanel({
|
|
|
182
254
|
{/* Navigation Arrows */}
|
|
183
255
|
{showArrows && (
|
|
184
256
|
<div className="pointer-events-none absolute -left-16 -right-16 top-[50%] z-30 flex -translate-y-1/2 justify-between px-4 md:px-10">
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
257
|
+
{currentIndex > 0 ? (
|
|
258
|
+
<Button
|
|
259
|
+
onClick={prevSlide}
|
|
260
|
+
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"
|
|
261
|
+
aria-label="Previous"
|
|
262
|
+
>
|
|
263
|
+
<MaterialIcon name="arrow_back" size={24} />
|
|
264
|
+
</Button>
|
|
265
|
+
) : (
|
|
266
|
+
<span className="h-12 w-12" aria-hidden="true" />
|
|
267
|
+
)}
|
|
192
268
|
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
269
|
+
{currentIndex < maxIndex ? (
|
|
270
|
+
<Button
|
|
271
|
+
onClick={nextSlide}
|
|
272
|
+
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"
|
|
273
|
+
aria-label="Next"
|
|
274
|
+
>
|
|
275
|
+
<MaterialIcon name="arrow_forward" size={24} />
|
|
276
|
+
</Button>
|
|
277
|
+
) : (
|
|
278
|
+
<span className="h-12 w-12" aria-hidden="true" />
|
|
279
|
+
)}
|
|
200
280
|
</div>
|
|
201
281
|
)}
|
|
202
282
|
|
|
203
283
|
{/* Carousel Window */}
|
|
204
|
-
<div
|
|
284
|
+
<div
|
|
285
|
+
ref={windowRef}
|
|
286
|
+
className="mx-auto max-w-[1280px] select-none overflow-hidden"
|
|
287
|
+
onTouchStart={handleTouchStart}
|
|
288
|
+
onTouchMove={handleTouchMove}
|
|
289
|
+
onTouchEnd={handleTouchEnd}
|
|
290
|
+
onTouchCancel={handleTouchEnd}
|
|
291
|
+
>
|
|
205
292
|
<div
|
|
206
|
-
className=
|
|
293
|
+
className={cx(
|
|
294
|
+
"ml-8 flex items-stretch gap-4 pb-4",
|
|
295
|
+
isSwiping
|
|
296
|
+
? "transition-none"
|
|
297
|
+
: "transition-transform duration-500 ease-in-out"
|
|
298
|
+
)}
|
|
207
299
|
style={{
|
|
208
|
-
transform: `translateX(calc(-${
|
|
300
|
+
transform: `translateX(calc(-${currentIndex} * (392px + 16px) + ${swipeOffset}px))`,
|
|
209
301
|
}}
|
|
210
302
|
>
|
|
211
|
-
{
|
|
303
|
+
{items.map((item, index) => renderCard(item, index, false))}
|
|
212
304
|
</div>
|
|
213
305
|
</div>
|
|
214
306
|
</div>
|
|
@@ -70,6 +70,8 @@ const CartRetentionBanner: React.FC<{
|
|
|
70
70
|
<Button
|
|
71
71
|
onClick={continueCheckout}
|
|
72
72
|
buttonLabel={cta?.buttonLabel ?? "Continue checkout"}
|
|
73
|
+
disableAnimation={cta?.disableAnimation ?? true}
|
|
74
|
+
animationType={cta?.animationType}
|
|
73
75
|
/>
|
|
74
76
|
</div>
|
|
75
77
|
</div>
|
|
@@ -98,6 +100,8 @@ const CartRetentionBanner: React.FC<{
|
|
|
98
100
|
<Button
|
|
99
101
|
onClick={continueCheckout}
|
|
100
102
|
buttonLabel={cta?.buttonLabel ?? "Continue checkout"}
|
|
103
|
+
disableAnimation={cta?.disableAnimation ?? true}
|
|
104
|
+
animationType={cta?.animationType}
|
|
101
105
|
/>
|
|
102
106
|
</div>
|
|
103
107
|
</div>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AnimationType } from "@shared/components/animation-wrapper";
|
|
2
|
+
|
|
1
3
|
export interface TypeCartRetentionBannerFields {
|
|
2
4
|
entryTitle?: string;
|
|
3
5
|
anchorId?: string;
|
|
@@ -5,7 +7,12 @@ export interface TypeCartRetentionBannerFields {
|
|
|
5
7
|
description?: {
|
|
6
8
|
json: any;
|
|
7
9
|
};
|
|
8
|
-
cta?: {
|
|
10
|
+
cta?: {
|
|
11
|
+
buttonLabel?: string;
|
|
12
|
+
disableAnimation?: boolean;
|
|
13
|
+
animationType?: AnimationType | AnimationType[];
|
|
14
|
+
[key: string]: any;
|
|
15
|
+
};
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
export type TypeCartRetentionBanner = TypeCartRetentionBannerFields;
|
|
@@ -30,10 +30,10 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
|
|
|
30
30
|
|
|
31
31
|
const bottomLinkLabel = bottomLink?.buttonLabel ?? bottomLink?.label;
|
|
32
32
|
return (
|
|
33
|
-
<div className="component-container p-5
|
|
33
|
+
<div className="component-container p-5 xl:flex xl:min-h-[724px] xl:items-center xl:p-0 xl:py-4">
|
|
34
34
|
<div
|
|
35
35
|
className={cx(
|
|
36
|
-
"primary-hero-container mx-auto flex w-full flex-col sm:items-center sm:text-center
|
|
36
|
+
"primary-hero-container mx-auto flex w-full flex-col sm:items-center sm:text-center xl:h-full xl:max-w-120 xl:flex-row xl:items-center xl:justify-between xl:gap-4 xl:text-left"
|
|
37
37
|
)}
|
|
38
38
|
>
|
|
39
39
|
{/* Left column: navy background, headline, body, address, button, link */}
|
|
@@ -84,7 +84,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
|
|
|
84
84
|
{/* checklist */}
|
|
85
85
|
{checklist && (
|
|
86
86
|
<Checklist
|
|
87
|
-
listItemClassName="text-white text-left"
|
|
87
|
+
listItemClassName="body2 text-white text-left"
|
|
88
88
|
items={checklist.map(item =>
|
|
89
89
|
item.iconUrl
|
|
90
90
|
? { title: item.title, iconUrl: item.iconUrl }
|
|
@@ -145,7 +145,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
|
|
|
145
145
|
</div>
|
|
146
146
|
|
|
147
147
|
{/* mobile hero image and bottom link */}
|
|
148
|
-
<div className="
|
|
148
|
+
<div className="xl:hidden">
|
|
149
149
|
<div className={"relative my-8"}>
|
|
150
150
|
{badgeImage ? (
|
|
151
151
|
<NextImage
|
|
@@ -207,7 +207,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
|
|
|
207
207
|
</div>
|
|
208
208
|
|
|
209
209
|
{/* Right column: bright blue background, diagonal top edge, hero image */}
|
|
210
|
-
<div className={cx("relative hidden
|
|
210
|
+
<div className={cx("relative hidden xl:block")}>
|
|
211
211
|
{badgeImage ? (
|
|
212
212
|
<NextImage
|
|
213
213
|
src={badgeImage}
|
|
@@ -390,4 +390,43 @@ describe("useCarouselSwipe", () => {
|
|
|
390
390
|
removeEventListenerSpy.mockRestore();
|
|
391
391
|
});
|
|
392
392
|
});
|
|
393
|
+
|
|
394
|
+
describe("SSR safety (window undefined)", () => {
|
|
395
|
+
it("can be created without throwing when window is undefined and uses fallback containerWidth", () => {
|
|
396
|
+
const origWindow = globalThis.window;
|
|
397
|
+
// @ts-expect-error - intentionally deleting window to simulate SSR
|
|
398
|
+
delete globalThis.window;
|
|
399
|
+
|
|
400
|
+
// Polyfill TextEncoder/TextDecoder required by react-dom/server in jest
|
|
401
|
+
const { TextEncoder, TextDecoder } = require("util");
|
|
402
|
+
const origTE = globalThis.TextEncoder;
|
|
403
|
+
const origTD = globalThis.TextDecoder;
|
|
404
|
+
globalThis.TextEncoder = TextEncoder;
|
|
405
|
+
globalThis.TextDecoder = TextDecoder;
|
|
406
|
+
|
|
407
|
+
let hookResult: ReturnType<typeof useCarouselSwipe> | undefined;
|
|
408
|
+
|
|
409
|
+
function TestComponent() {
|
|
410
|
+
hookResult = useCarouselSwipe(defaultConfig);
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
416
|
+
const { renderToString } = require("react-dom/server");
|
|
417
|
+
// renderToString simulates real SSR: no window, no effects run
|
|
418
|
+
renderToString(React.createElement(TestComponent));
|
|
419
|
+
|
|
420
|
+
// containerWidth should use the fallback value of 1 since window is absent
|
|
421
|
+
expect(hookResult!.containerWidth).toBe(1);
|
|
422
|
+
expect(hookResult!.isSwiping).toBe(false);
|
|
423
|
+
expect(hookResult!.currentIndex).toBe(0);
|
|
424
|
+
} finally {
|
|
425
|
+
// Always restore globals so subsequent tests aren't affected
|
|
426
|
+
globalThis.window = origWindow;
|
|
427
|
+
globalThis.TextEncoder = origTE;
|
|
428
|
+
globalThis.TextDecoder = origTD;
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
});
|
|
393
432
|
});
|
|
@@ -115,7 +115,9 @@ export function useCarouselSwipe(
|
|
|
115
115
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
116
116
|
const [swipeOffset, setSwipeOffset] = useState(0);
|
|
117
117
|
const [isSwiping, setIsSwiping] = useState(false);
|
|
118
|
-
const [containerWidth, setContainerWidth] = useState(
|
|
118
|
+
const [containerWidth, setContainerWidth] = useState(
|
|
119
|
+
typeof window !== "undefined" ? window.innerWidth : 1,
|
|
120
|
+
);
|
|
119
121
|
// Performance: Store mobile state to avoid repeated window.innerWidth checks on every render
|
|
120
122
|
// This prevents expensive DOM queries during swipe operations
|
|
121
123
|
const [isMobile, setIsMobile] = useState(false);
|