@windstream/react-shared-components 0.2.73 → 0.2.75

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.
@@ -32,22 +32,34 @@ jest.mock("@shared/contentful/blocks/cards/product-card", () => ({
32
32
  onToggleExpand,
33
33
  isExpanded,
34
34
  enableBusinessSettings,
35
+ giftBadge,
36
+ hasAnyGift,
35
37
  }: any) => (
36
- <div
38
+ <article
37
39
  data-testid="product-card"
38
40
  data-plan={planName}
39
41
  data-speed={speed}
40
42
  data-expanded={isExpanded}
41
43
  data-business={enableBusinessSettings ? "true" : "false"}
42
44
  >
43
- {planName}
45
+ <div data-pc-header={true}>{planName}</div>
46
+ <div data-pc-description={true}>{speed}</div>
47
+ {giftBadge?.length ? (
48
+ <div data-pc-gift={true} data-testid="gift-slot" />
49
+ ) : hasAnyGift ? (
50
+ <div
51
+ data-pc-gift={true}
52
+ data-pc-gift-empty={true}
53
+ data-testid="gift-slot"
54
+ />
55
+ ) : null}
44
56
  <button onClick={onCtaClick} data-testid="product-cta">
45
57
  CTA
46
58
  </button>
47
59
  <button onClick={onToggleExpand} data-testid="product-toggle">
48
60
  Toggle
49
61
  </button>
50
- </div>
62
+ </article>
51
63
  ),
52
64
  }));
53
65
 
@@ -591,6 +603,256 @@ describe("ProductCardCarousel", () => {
591
603
  removeSpy.mockRestore();
592
604
  });
593
605
  });
606
+
607
+ describe("swipe gestures (desktop carousel)", () => {
608
+ const makeCarousel = () => {
609
+ const items = Array.from({ length: 4 }, (_, i) => ({
610
+ ...sampleProduct,
611
+ sys: { id: `prod-${i}` },
612
+ speed: `${100 * (i + 1)} Mbps|Fiber`,
613
+ }));
614
+ const utils = render(
615
+ <ProductCardCarousel fields={createProductFields(items)} />
616
+ );
617
+ // computeMaxIndex relies on offsetWidth (mocked to 900); a resize makes
618
+ // sure maxIndex is computed so nextSlide/prevSlide can move.
619
+ fireEvent(window, new Event("resize"));
620
+ const windowEl = utils.container.querySelector(
621
+ ".select-none"
622
+ ) as HTMLElement;
623
+ const track = utils.container.querySelector(
624
+ '[style*="translateX"]'
625
+ ) as HTMLElement;
626
+ return { ...utils, windowEl, track };
627
+ };
628
+
629
+ it("advances to the next slide when swiped left past the threshold", () => {
630
+ const { windowEl, track } = makeCarousel();
631
+ expect(screen.queryByTestId("icon-arrow_back")).toBeNull();
632
+
633
+ fireEvent.touchStart(windowEl, { touches: [{ clientX: 500 }] });
634
+ fireEvent.touchMove(windowEl, { touches: [{ clientX: 300 }] });
635
+ // The track follows the finger while dragging (diff = -200).
636
+ expect(track.getAttribute("style")).toContain("-200px");
637
+
638
+ fireEvent.touchEnd(windowEl);
639
+ // nextSlide advanced the index, so the Previous arrow now shows.
640
+ expect(screen.queryByTestId("icon-arrow_back")).not.toBeNull();
641
+ });
642
+
643
+ it("returns to the previous slide when swiped right past the threshold", () => {
644
+ const { windowEl } = makeCarousel();
645
+ // Move forward first so there is a previous slide to return to.
646
+ fireEvent.touchStart(windowEl, { touches: [{ clientX: 500 }] });
647
+ fireEvent.touchMove(windowEl, { touches: [{ clientX: 300 }] });
648
+ fireEvent.touchEnd(windowEl);
649
+ expect(screen.queryByTestId("icon-arrow_back")).not.toBeNull();
650
+
651
+ fireEvent.touchStart(windowEl, { touches: [{ clientX: 300 }] });
652
+ fireEvent.touchMove(windowEl, { touches: [{ clientX: 600 }] });
653
+ fireEvent.touchEnd(windowEl);
654
+ // prevSlide returned to the first slide, hiding the Previous arrow.
655
+ expect(screen.queryByTestId("icon-arrow_back")).toBeNull();
656
+ });
657
+
658
+ it("snaps back without changing slides when the swipe is below the threshold", () => {
659
+ const { windowEl, track } = makeCarousel();
660
+ fireEvent.touchStart(windowEl, { touches: [{ clientX: 500 }] });
661
+ fireEvent.touchMove(windowEl, { touches: [{ clientX: 450 }] }); // diff -50
662
+ fireEvent.touchEnd(windowEl);
663
+ expect(screen.queryByTestId("icon-arrow_back")).toBeNull();
664
+ // Offset is reset to 0 after release.
665
+ expect(track.getAttribute("style")).toContain("+ 0px");
666
+ });
667
+
668
+ it("applies drag resistance at the first-slide boundary", () => {
669
+ const { windowEl, track } = makeCarousel();
670
+ // Dragging right while already at the first slide is damped (diff * 0.3).
671
+ fireEvent.touchStart(windowEl, { touches: [{ clientX: 100 }] });
672
+ fireEvent.touchMove(windowEl, { touches: [{ clientX: 300 }] }); // diff 200
673
+ expect(track.getAttribute("style")).toContain("60px");
674
+ });
675
+
676
+ it("ignores a touch move that is not preceded by a touch start", () => {
677
+ const { windowEl, track } = makeCarousel();
678
+ fireEvent.touchMove(windowEl, { touches: [{ clientX: 300 }] });
679
+ // No swipe offset is applied.
680
+ expect(track.getAttribute("style")).toContain("+ 0px");
681
+ });
682
+ });
683
+
684
+ describe("gift-slot per-row equalization", () => {
685
+ const mockRect = (el: Element, rect: { top?: number; height?: number }) => {
686
+ (el as HTMLElement).getBoundingClientRect = () =>
687
+ ({
688
+ top: rect.top ?? 0,
689
+ left: 0,
690
+ right: 0,
691
+ bottom: (rect.top ?? 0) + (rect.height ?? 0),
692
+ width: 0,
693
+ height: rect.height ?? 0,
694
+ x: 0,
695
+ y: 0,
696
+ toJSON: () => ({}),
697
+ }) as DOMRect;
698
+ };
699
+
700
+ const makeGrid = (
701
+ config: { gift: boolean; top: number; height: number }[]
702
+ ) => {
703
+ const items = config.map((c, i) => ({
704
+ ...sampleProduct,
705
+ sys: { id: `g-${i}` },
706
+ giftRewards: { list: { items: c.gift ? [{ title: "$100" }] : [] } },
707
+ }));
708
+ const { container } = render(
709
+ <ProductCardCarousel fields={createProductFields(items)} />
710
+ );
711
+ const grid = container.querySelector(".grid.grid-cols-2") as HTMLElement;
712
+ const articles = Array.from(grid.querySelectorAll("article"));
713
+ const slots = articles.map(
714
+ a => a.querySelector("[data-pc-gift]") as HTMLElement
715
+ );
716
+ articles.forEach((a, i) => mockRect(a, { top: config[i].top }));
717
+ slots.forEach((s, i) => {
718
+ mockRect(s, { height: config[i].height });
719
+ Object.defineProperty(s, "offsetParent", {
720
+ configurable: true,
721
+ get: () => document.body,
722
+ });
723
+ });
724
+ return { slots };
725
+ };
726
+
727
+ it("collapses a giftless row and never borrows another row's gift height", () => {
728
+ // Row 1 (both giftless) above Row 2 (both with a gift).
729
+ const { slots } = makeGrid([
730
+ { gift: false, top: 0, height: 20 },
731
+ { gift: false, top: 0, height: 20 },
732
+ { gift: true, top: 500, height: 76 },
733
+ { gift: true, top: 500, height: 76 },
734
+ ]);
735
+
736
+ fireEvent(window, new Event("resize"));
737
+
738
+ // Row 1 collapses instead of reserving the gift height.
739
+ expect(slots[0].style.display).toBe("none");
740
+ expect(slots[1].style.display).toBe("none");
741
+ expect(slots[0].style.minHeight).toBe("");
742
+ // Row 2 reserves only its own gift height.
743
+ expect(slots[2].style.minHeight).toBe("76px");
744
+ expect(slots[3].style.minHeight).toBe("76px");
745
+ });
746
+
747
+ it("reserves the tallest gift height within a mixed row so CTAs align", () => {
748
+ // Row 1 = gift + giftless; Row 2 = gift + gift.
749
+ const { slots } = makeGrid([
750
+ { gift: true, top: 0, height: 76 },
751
+ { gift: false, top: 0, height: 20 },
752
+ { gift: true, top: 500, height: 76 },
753
+ { gift: true, top: 500, height: 76 },
754
+ ]);
755
+
756
+ fireEvent(window, new Event("resize"));
757
+
758
+ // The giftless neighbour reserves the taller gift height (CTAs align).
759
+ expect(slots[0].style.minHeight).toBe("76px");
760
+ expect(slots[1].style.minHeight).toBe("76px");
761
+ expect(slots[1].style.display).not.toBe("none");
762
+ });
763
+ });
764
+
765
+ describe("two-column mobile section equalization", () => {
766
+ let originalMatchMedia: typeof window.matchMedia;
767
+
768
+ const setMatchMedia = (matches: boolean) => {
769
+ window.matchMedia = ((query: string) => ({
770
+ matches,
771
+ media: query,
772
+ onchange: null,
773
+ addListener: jest.fn(),
774
+ removeListener: jest.fn(),
775
+ addEventListener: jest.fn(),
776
+ removeEventListener: jest.fn(),
777
+ dispatchEvent: jest.fn(),
778
+ })) as any;
779
+ };
780
+
781
+ const mockHeight = (el: Element, height: number) => {
782
+ (el as HTMLElement).getBoundingClientRect = () =>
783
+ ({
784
+ top: 0,
785
+ left: 0,
786
+ right: 0,
787
+ bottom: height,
788
+ width: 0,
789
+ height,
790
+ x: 0,
791
+ y: 0,
792
+ toJSON: () => ({}),
793
+ }) as DOMRect;
794
+ };
795
+
796
+ beforeEach(() => {
797
+ originalMatchMedia = window.matchMedia;
798
+ });
799
+
800
+ afterEach(() => {
801
+ window.matchMedia = originalMatchMedia;
802
+ });
803
+
804
+ it("matches header and description heights within each row (pair)", () => {
805
+ setMatchMedia(true);
806
+ const items = Array.from({ length: 4 }, (_, i) => ({
807
+ ...sampleProduct,
808
+ sys: { id: `prod-${i}` },
809
+ }));
810
+ const { container } = render(
811
+ <ProductCardCarousel fields={createProductFields(items)} />
812
+ );
813
+ const grid = container.querySelector(".grid.grid-cols-2") as HTMLElement;
814
+ const headers = Array.from(
815
+ grid.querySelectorAll<HTMLElement>("[data-pc-header]")
816
+ );
817
+ const descriptions = Array.from(
818
+ grid.querySelectorAll<HTMLElement>("[data-pc-description]")
819
+ );
820
+ // Row 1 header heights 30/50 -> 50; Row 2 header heights 40/20 -> 40.
821
+ [30, 50, 40, 20].forEach((h, i) => mockHeight(headers[i], h));
822
+ // Row 1 descriptions 25/25 -> 25; Row 2 descriptions 10/60 -> 60.
823
+ [25, 25, 10, 60].forEach((h, i) => mockHeight(descriptions[i], h));
824
+
825
+ fireEvent(window, new Event("resize"));
826
+
827
+ expect(headers[0].style.minHeight).toBe("50px");
828
+ expect(headers[1].style.minHeight).toBe("50px");
829
+ expect(headers[2].style.minHeight).toBe("40px");
830
+ expect(headers[3].style.minHeight).toBe("40px");
831
+
832
+ expect(descriptions[0].style.minHeight).toBe("25px");
833
+ expect(descriptions[1].style.minHeight).toBe("25px");
834
+ expect(descriptions[2].style.minHeight).toBe("60px");
835
+ expect(descriptions[3].style.minHeight).toBe("60px");
836
+ });
837
+
838
+ it("clears section min-heights above the mobile breakpoint", () => {
839
+ setMatchMedia(false);
840
+ const items = Array.from({ length: 4 }, (_, i) => ({
841
+ ...sampleProduct,
842
+ sys: { id: `prod-${i}` },
843
+ }));
844
+ const { container } = render(
845
+ <ProductCardCarousel fields={createProductFields(items)} />
846
+ );
847
+ const grid = container.querySelector(".grid.grid-cols-2") as HTMLElement;
848
+ const header = grid.querySelector<HTMLElement>("[data-pc-header]")!;
849
+ header.style.setProperty("min-height", "123px");
850
+
851
+ fireEvent(window, new Event("resize"));
852
+
853
+ expect(header.style.minHeight).toBe("");
854
+ });
855
+ });
594
856
  });
595
857
 
596
858
  describe("TestimonialCarousel", () => {
@@ -46,10 +46,14 @@ function ProductCardPanel({
46
46
  // When any card is flagged best-value, every card reserves the banner height
47
47
  // so plan name/price stay aligned across a two-column row.
48
48
  const hasAnyBadge = items.some((item: any) => item?.highlighted);
49
+ const hasAnyGift = items.some(
50
+ (item: any) => (item?.giftRewards?.list?.items?.length ?? 0) > 0
51
+ );
49
52
  const showArrows = fields?.showArrows !== false && isCarousel;
50
53
  const cardsRef = useRef<(HTMLDivElement | null)[]>([]);
51
54
  const windowRef = useRef<HTMLDivElement | null>(null);
52
55
  const mobileGridRef = useRef<HTMLDivElement | null>(null);
56
+ const panelRef = useRef<HTMLDivElement | null>(null);
53
57
  const touchStartX = useRef<number>(0);
54
58
 
55
59
  // Determine how many cards fit so we never scroll past the last card
@@ -176,16 +180,13 @@ function ProductCardPanel({
176
180
  }, [isCarousel, isVisible, desktopExpanded, items.length]);
177
181
 
178
182
  // Two-column mobile: match each card's sections to the tallest card in the
179
- // same row (pair) so section spacing and the CTA align per row. Desktop keeps
183
+ // same row (pair) so section spacing aligns per row. The gift slot is handled
184
+ // separately (panel-wide) so it sizes to the real gift content. Desktop keeps
180
185
  // its fixed section heights and is left untouched.
181
186
  useEffect(() => {
182
187
  if (!twoColumn || !isVisible) return;
183
188
 
184
- const selectors = [
185
- "[data-pc-header]",
186
- "[data-pc-description]",
187
- "[data-pc-gift]",
188
- ];
189
+ const selectors = ["[data-pc-header]", "[data-pc-description]"];
189
190
 
190
191
  const clear = () => {
191
192
  const grid = mobileGridRef.current;
@@ -193,35 +194,20 @@ function ProductCardPanel({
193
194
  selectors.forEach(sel =>
194
195
  grid.querySelectorAll<HTMLElement>(sel).forEach(el => {
195
196
  el.style.removeProperty("min-height");
196
- el.style.removeProperty("display");
197
197
  })
198
198
  );
199
199
  };
200
200
 
201
201
  // `important` is required because Tailwind utilities are generated with
202
202
  // !important in this project (tailwind.config `important: true`).
203
- const equalizePairs = (sel: string, collapseEmpty = false) => {
203
+ const equalizePairs = (sel: string) => {
204
204
  const grid = mobileGridRef.current;
205
205
  if (!grid) return;
206
206
  const els = Array.from(grid.querySelectorAll<HTMLElement>(sel));
207
207
  // Reset before measuring so a previous pass doesn't inflate the result.
208
- els.forEach(el => {
209
- el.style.removeProperty("min-height");
210
- el.style.removeProperty("display");
211
- });
208
+ els.forEach(el => el.style.removeProperty("min-height"));
212
209
  for (let i = 0; i < els.length; i += 2) {
213
210
  const pair = els.slice(i, i + 2);
214
- // Collapse a row (or lone trailing card) whose gift slots are all empty
215
- // so no space is reserved when the row has no gift.
216
- if (
217
- collapseEmpty &&
218
- pair.every(el => el.hasAttribute("data-pc-gift-empty"))
219
- ) {
220
- pair.forEach(el =>
221
- el.style.setProperty("display", "none", "important")
222
- );
223
- continue;
224
- }
225
211
  if (pair.length < 2) break;
226
212
  const max = Math.max(
227
213
  ...pair.map(el => el.getBoundingClientRect().height)
@@ -240,7 +226,6 @@ function ProductCardPanel({
240
226
  }
241
227
  equalizePairs("[data-pc-header]");
242
228
  equalizePairs("[data-pc-description]");
243
- equalizePairs("[data-pc-gift]", true);
244
229
  };
245
230
 
246
231
  const rafId = requestAnimationFrame(equalize);
@@ -257,6 +242,83 @@ function ProductCardPanel({
257
242
  };
258
243
  }, [twoColumn, isVisible, desktopExpanded, items.length]);
259
244
 
245
+ // Size the gift-reward slot per visual row so the Select plan CTA sits
246
+ // directly below the gift badge, while staying aligned only with the cards
247
+ // that sit next to it (same row). Cards are grouped by their on-screen row,
248
+ // so a giftless row never borrows the gift height of another row in the same
249
+ // tab: desktop equalizes the single row of cards; two-column mobile equalizes
250
+ // each pair; a row whose slots are all empty collapses so its CTA sits flush
251
+ // with the description. When the tab has no gift at all, `hasAnyGift` omits
252
+ // the slot entirely.
253
+ useEffect(() => {
254
+ if (!isVisible) return;
255
+
256
+ const root = panelRef.current;
257
+
258
+ const reset = () => {
259
+ if (!root) return;
260
+ root.querySelectorAll<HTMLElement>("[data-pc-gift]").forEach(el => {
261
+ el.style.removeProperty("min-height");
262
+ el.style.removeProperty("display");
263
+ });
264
+ };
265
+
266
+ const equalizeGiftSlots = () => {
267
+ if (!root) return;
268
+ reset();
269
+ const els = Array.from(
270
+ root.querySelectorAll<HTMLElement>("[data-pc-gift]")
271
+ ).filter(el => el.offsetParent !== null);
272
+ if (els.length === 0) return;
273
+
274
+ // Group slots by the top of their owning card so each visual row (the
275
+ // cards that sit next to each other) is equalized independently.
276
+ const tolerance = 4;
277
+ const rows: { top: number; els: HTMLElement[] }[] = [];
278
+ els.forEach(el => {
279
+ const card = (el.closest("article") as HTMLElement) || el;
280
+ const top = card.getBoundingClientRect().top;
281
+ const row = rows.find(r => Math.abs(r.top - top) <= tolerance);
282
+ if (row) {
283
+ row.els.push(el);
284
+ } else {
285
+ rows.push({ top, els: [el] });
286
+ }
287
+ });
288
+
289
+ rows.forEach(({ els: rowEls }) => {
290
+ // Collapse a row whose slots are all empty so no space is reserved.
291
+ if (rowEls.every(el => el.hasAttribute("data-pc-gift-empty"))) {
292
+ rowEls.forEach(el =>
293
+ el.style.setProperty("display", "none", "important")
294
+ );
295
+ return;
296
+ }
297
+ const max = Math.max(
298
+ ...rowEls.map(el => el.getBoundingClientRect().height)
299
+ );
300
+ if (max <= 0) return;
301
+ rowEls.forEach(el =>
302
+ el.style.setProperty("min-height", `${max}px`, "important")
303
+ );
304
+ });
305
+ };
306
+
307
+ equalizeGiftSlots();
308
+ const rafId = requestAnimationFrame(equalizeGiftSlots);
309
+ const t1 = setTimeout(equalizeGiftSlots, 150);
310
+ const t2 = setTimeout(equalizeGiftSlots, 500);
311
+ window.addEventListener("resize", equalizeGiftSlots);
312
+
313
+ return () => {
314
+ cancelAnimationFrame(rafId);
315
+ clearTimeout(t1);
316
+ clearTimeout(t2);
317
+ window.removeEventListener("resize", equalizeGiftSlots);
318
+ reset();
319
+ };
320
+ }, [isVisible, desktopExpanded, items.length]);
321
+
260
322
  if (!items.length && !fields?.title) {
261
323
  return null;
262
324
  }
@@ -338,6 +400,7 @@ function ProductCardPanel({
338
400
  cta={item.cta}
339
401
  priceSuffixBottom={item?.priceSuffixBottom}
340
402
  hasAnyBadge={hasAnyBadge}
403
+ hasAnyGift={hasAnyGift}
341
404
  twoColumn={twoColumn}
342
405
  enableBusinessSettings={enableBusinessSettings}
343
406
  />
@@ -345,87 +408,91 @@ function ProductCardPanel({
345
408
  );
346
409
  };
347
410
 
348
- return !isCarousel ? (
349
- <div
350
- ref={mobileGridRef}
351
- className={cx(
352
- "w-full max-w-[1280px] gap-4",
353
- twoColumn
354
- ? "grid grid-cols-2 md:flex md:flex-wrap md:justify-center"
355
- : "flex flex-wrap justify-center"
356
- )}
357
- >
358
- {items.map((item, index) => renderCard(item, index, twoColumn))}
359
- </div>
360
- ) : (
361
- <>
362
- {/* Mobile/Tablet View: Vertical Stack (or two-column grid) */}
363
- <div
364
- ref={mobileGridRef}
365
- className={cx(
366
- "md:hidden",
367
- twoColumn ? "grid grid-cols-2 gap-4" : "flex flex-col gap-6"
368
- )}
369
- >
370
- {items.map((item, index) => renderCard(item, index, true))}
371
- </div>
372
-
373
- {/* Desktop View: Horizontal Carousel */}
374
- <div className="relative hidden w-full md:block">
375
- {/* Navigation Arrows */}
376
- {showArrows && (
377
- <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">
378
- {currentIndex > 0 ? (
379
- <Button
380
- onClick={prevSlide}
381
- 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"
382
- aria-label="Previous"
383
- >
384
- <MaterialIcon name="arrow_back" size={24} />
385
- </Button>
386
- ) : (
387
- <span className="h-12 w-12" aria-hidden="true" />
388
- )}
389
-
390
- {currentIndex < maxIndex ? (
391
- <Button
392
- onClick={nextSlide}
393
- 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"
394
- aria-label="Next"
395
- >
396
- <MaterialIcon name="arrow_forward" size={24} />
397
- </Button>
398
- ) : (
399
- <span className="h-12 w-12" aria-hidden="true" />
400
- )}
401
- </div>
402
- )}
403
-
404
- {/* Carousel Window */}
411
+ return (
412
+ <div ref={panelRef} style={{ display: "contents" }}>
413
+ {!isCarousel ? (
405
414
  <div
406
- ref={windowRef}
407
- className="mx-auto max-w-[1280px] select-none overflow-hidden"
408
- onTouchStart={handleTouchStart}
409
- onTouchMove={handleTouchMove}
410
- onTouchEnd={handleTouchEnd}
411
- onTouchCancel={handleTouchEnd}
415
+ ref={mobileGridRef}
416
+ className={cx(
417
+ "w-full max-w-[1280px] gap-4",
418
+ twoColumn
419
+ ? "grid grid-cols-2 md:flex md:flex-wrap md:justify-center"
420
+ : "flex flex-wrap justify-center"
421
+ )}
412
422
  >
423
+ {items.map((item, index) => renderCard(item, index, twoColumn))}
424
+ </div>
425
+ ) : (
426
+ <>
427
+ {/* Mobile/Tablet View: Vertical Stack (or two-column grid) */}
413
428
  <div
429
+ ref={mobileGridRef}
414
430
  className={cx(
415
- "ml-8 flex items-stretch gap-4 pb-4",
416
- isSwiping
417
- ? "transition-none"
418
- : "transition-transform duration-500 ease-in-out"
431
+ "md:hidden",
432
+ twoColumn ? "grid grid-cols-2 gap-4" : "flex flex-col gap-6"
419
433
  )}
420
- style={{
421
- transform: `translateX(calc(-${currentIndex} * (392px + 16px) + ${swipeOffset}px))`,
422
- }}
423
434
  >
424
- {items.map((item, index) => renderCard(item, index, false))}
435
+ {items.map((item, index) => renderCard(item, index, true))}
425
436
  </div>
426
- </div>
427
- </div>
428
- </>
437
+
438
+ {/* Desktop View: Horizontal Carousel */}
439
+ <div className="relative hidden w-full md:block">
440
+ {/* Navigation Arrows */}
441
+ {showArrows && (
442
+ <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">
443
+ {currentIndex > 0 ? (
444
+ <Button
445
+ onClick={prevSlide}
446
+ 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"
447
+ aria-label="Previous"
448
+ >
449
+ <MaterialIcon name="arrow_back" size={24} />
450
+ </Button>
451
+ ) : (
452
+ <span className="h-12 w-12" aria-hidden="true" />
453
+ )}
454
+
455
+ {currentIndex < maxIndex ? (
456
+ <Button
457
+ onClick={nextSlide}
458
+ 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"
459
+ aria-label="Next"
460
+ >
461
+ <MaterialIcon name="arrow_forward" size={24} />
462
+ </Button>
463
+ ) : (
464
+ <span className="h-12 w-12" aria-hidden="true" />
465
+ )}
466
+ </div>
467
+ )}
468
+
469
+ {/* Carousel Window */}
470
+ <div
471
+ ref={windowRef}
472
+ className="mx-auto max-w-[1280px] select-none overflow-hidden"
473
+ onTouchStart={handleTouchStart}
474
+ onTouchMove={handleTouchMove}
475
+ onTouchEnd={handleTouchEnd}
476
+ onTouchCancel={handleTouchEnd}
477
+ >
478
+ <div
479
+ className={cx(
480
+ "ml-8 flex items-stretch gap-4 pb-4",
481
+ isSwiping
482
+ ? "transition-none"
483
+ : "transition-transform duration-500 ease-in-out"
484
+ )}
485
+ style={{
486
+ transform: `translateX(calc(-${currentIndex} * (392px + 16px) + ${swipeOffset}px))`,
487
+ }}
488
+ >
489
+ {items.map((item, index) => renderCard(item, index, false))}
490
+ </div>
491
+ </div>
492
+ </div>
493
+ </>
494
+ )}
495
+ </div>
429
496
  );
430
497
  }
431
498