@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.
- package/dist/contentful/index.d.ts +1 -0
- package/dist/contentful/index.esm.js +1 -1
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +2 -2
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.esm.js +6 -6
- 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/dist/utils/index.esm.js +1 -1
- package/dist/utils/index.esm.js.map +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/rich-text-basic/index.tsx +2 -1
- package/src/contentful/blocks/cards/product-card/index.test.tsx +31 -0
- package/src/contentful/blocks/cards/product-card/index.tsx +9 -8
- package/src/contentful/blocks/cards/product-card/types.ts +1 -0
- package/src/contentful/blocks/carousel/Carousel.stories.tsx +303 -3
- package/src/contentful/blocks/carousel/helper.test.tsx +265 -3
- package/src/contentful/blocks/carousel/helper.tsx +165 -98
- package/src/contentful/blocks/ordered-list/index.test.tsx +163 -0
- package/src/contentful/blocks/primary-hero/index.tsx +1 -0
- package/src/hooks/contentful/use-contentful-rich-text.test.tsx +55 -0
- package/src/hooks/contentful/use-contentful-rich-text.tsx +15 -8
|
@@ -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
|
-
<
|
|
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
|
-
</
|
|
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
|
|
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
|
|
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
|
|
349
|
-
<div
|
|
350
|
-
|
|
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={
|
|
407
|
-
className=
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
"
|
|
416
|
-
|
|
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,
|
|
435
|
+
{items.map((item, index) => renderCard(item, index, true))}
|
|
425
436
|
</div>
|
|
426
|
-
|
|
427
|
-
|
|
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
|
|