import React, { useCallback, useEffect, useRef, useState } from "react"; import { CarouselWithProductCards, CarouselWithTestimonialCards, TabSwitchProps, } from "./types"; import { Button } from "@shared/components/button"; import { MaterialIcon } from "@shared/components/material-icon"; import { ProductCard } from "@shared/contentful/blocks/cards/product-card"; import { TestimonialCard } from "@shared/contentful/blocks/cards/testimonial-card"; import { useCarouselSwipe } from "@shared/hooks/use-carousel-swipe"; import { CheckPlansProps } from "@shared/types/micro-components"; import { cx } from "@shared/utils"; function ProductCardPanel({ fields, renderCheckPlans, isVisible = true, }: { fields: CarouselWithProductCards; onModalButtonClick?: (id?: string) => void; renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode; isVisible?: boolean; }) { const itemsExpanded = fields?.items?.items?.[0]?.benefitsExpanded || false; const [desktopExpanded, setDesktopExpanded] = useState(itemsExpanded); const [mobileExpandedStates, setMobileExpandedStates] = useState< Record >({}); const [currentIndex, setCurrentIndex] = useState(0); const [maxIndex, setMaxIndex] = useState(0); const [swipeOffset, setSwipeOffset] = useState(0); const [isSwiping, setIsSwiping] = useState(false); const items = fields?.items?.items || []; const isCarousel = items.length > 2; const showArrows = fields?.showArrows !== false && isCarousel; const cardsRef = useRef<(HTMLDivElement | null)[]>([]); const windowRef = useRef(null); const touchStartX = useRef(0); // Determine how many cards fit so we never scroll past the last card useEffect(() => { if (!isCarousel || !isVisible) return; const gap = 16; // track gap-4 (16px) const cardWidth = 392 + gap; // card width + gap const trackMargin = 32; // inner track ml-8 (32px) const computeMaxIndex = () => { const containerWidth = windowRef.current?.offsetWidth || 0; // Skip while hidden (offsetWidth is 0) to avoid overestimating maxIndex if (containerWidth === 0) return; const usableWidth = containerWidth - trackMargin; const visibleCards = Math.max( 1, Math.floor((usableWidth + gap) / cardWidth) ); setMaxIndex(Math.max(0, items.length - visibleCards)); }; computeMaxIndex(); window.addEventListener("resize", computeMaxIndex); return () => window.removeEventListener("resize", computeMaxIndex); }, [isCarousel, isVisible, items.length]); // Keep the active index within the available range useEffect(() => { setCurrentIndex(prev => Math.min(prev, maxIndex)); }, [maxIndex]); const prevSlide = useCallback(() => { if (!isCarousel) return; setCurrentIndex(prev => Math.max(0, prev - 1)); }, [isCarousel]); const nextSlide = useCallback(() => { if (!isCarousel) return; setCurrentIndex(prev => Math.min(maxIndex, prev + 1)); }, [isCarousel, maxIndex]); // Touch/swipe handlers for finger-following drag (touch devices) const handleTouchStart = useCallback( (e: React.TouchEvent) => { if (!isCarousel) return; touchStartX.current = e.touches[0].clientX; setIsSwiping(true); }, [isCarousel] ); const handleTouchMove = useCallback( (e: React.TouchEvent) => { if (!isSwiping) return; const diff = e.touches[0].clientX - touchStartX.current; // Apply resistance when dragging past the start/end boundaries const atStart = currentIndex === 0; const atEnd = currentIndex >= maxIndex; if ((atStart && diff > 0) || (atEnd && diff < 0)) { setSwipeOffset(diff * 0.3); } else { setSwipeOffset(diff); } }, [isSwiping, currentIndex, maxIndex] ); const handleTouchEnd = useCallback(() => { if (!isSwiping) return; setIsSwiping(false); const containerWidth = windowRef.current?.offsetWidth || 0; const threshold = containerWidth * 0.15; if (swipeOffset > threshold) { prevSlide(); } else if (swipeOffset < -threshold) { nextSlide(); } setSwipeOffset(0); }, [isSwiping, swipeOffset, prevSlide, nextSlide]); // Equalize card heights useEffect(() => { if (!isCarousel || !isVisible) return; const equalizeHeights = () => { const cards = cardsRef.current.filter(Boolean) as HTMLDivElement[]; if (cards.length === 0) return; // Reset heights first cards.forEach(card => { card.style.height = "auto"; }); // Find the tallest card const maxHeight = Math.max(...cards.map(card => card.offsetHeight)); // Set all cards to the max height cards.forEach(card => { card.style.height = `${maxHeight}px`; }); }; // Run on mount and when expanded state changes equalizeHeights(); // Re-run after a short delay due to dynamic content const timeoutId = setTimeout(equalizeHeights, 100); return () => clearTimeout(timeoutId); }, [isCarousel, isVisible, desktopExpanded, items.length]); if (!items.length && !fields?.title) { return null; } const renderCard = ( item: any, globalIndex: number, isMobileView: boolean = false ) => { const planContent = item?.speed?.split("|"); const planName = planContent?.[0] || ""; const planSubtext = planContent?.[1] || ""; const priceCents = item?.priceSuffix?.split("/")?.[0] || "00"; const formattedPrice = `${item?.price || "0"}.${priceCents}`; const features = item?.benefits?.items || []; const bestValue = item?.highlighted || false; const rawBadge = item?.giftRewards?.list?.items || []; const innerBadge = { badgeText: item.innerBadge || "", badgeIcon: item.innerBadgeIcon?.url || "", }; // Mobile: individual card state, Desktop: shared state const isExpanded = isMobileView ? mobileExpandedStates[globalIndex] || false : desktopExpanded; const handleToggle = () => { if (isMobileView) { setMobileExpandedStates(prev => ({ ...prev, [globalIndex]: !prev[globalIndex], })); } else { setDesktopExpanded(!desktopExpanded); } }; const handleCtaClick = () => { return; }; return (
{ if (el && !cardsRef.current.includes(el)) { cardsRef.current[globalIndex] = el; } } : undefined } key={globalIndex} className={cx( isMobileView ? "mx-auto w-full max-w-[392px]" : "w-[392px] flex-shrink-0" )} >
); }; return !isCarousel ? (
{items.map((item, index) => renderCard(item, index))}
) : ( <> {/* Mobile/Tablet View: Vertical Stack */}
{items.map((item, index) => renderCard(item, index, true))}
{/* Desktop View: Horizontal Carousel */}
{/* Navigation Arrows */} {showArrows && (
{currentIndex > 0 ? ( ) : (
)} {/* Carousel Window */}
{items.map((item, index) => renderCard(item, index, false))}
); } export function ProductCardCarousel({ fields, renderCheckPlans, activeTab, tabs, }: { fields: CarouselWithProductCards; onModalButtonClick?: (id?: string) => void; renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode; activeTab?: string; tabs?: string[]; }) { const allItems = fields?.items?.items || []; if (tabs && tabs.length > 1 && activeTab) { return ( <> {tabs.map(tab => { const tabItems = allItems.filter(item => { const category = item.productCategory || tabs[0]; return category === tab; }); const tabFields = { ...fields, items: { ...fields.items, items: tabItems }, }; return (
); })} ); } return ( ); } /** * Individual slide component for the testimonial carousel * Memoized to prevent unnecessary re-renders of inactive slides */ const TestimonialCarouselSlide = React.memo<{ item: any; index: number; currentIndex: number; totalItems: number; swipeOffset: number; isSwiping: boolean; isMobile: boolean; containerWidth: number; cardOffsetPercentage: number; }>( ({ item, index, currentIndex, totalItems, swipeOffset, isSwiping, isMobile, containerWidth, cardOffsetPercentage, }) => { // Calculate circular offset for infinite carousel feel let offset = index - currentIndex; const len = totalItems; if (offset > len / 2) offset -= len; if (offset < -len / 2) offset += len; const isActive = offset === 0; const isAdjacent = Math.abs(offset) === 1; // Calculate card position with smooth swipe following // Base position is determined by card index offset (105% spacing between cards) const baseTransform = offset * cardOffsetPercentage; // Convert swipe pixels to percentage for smooth finger-following during swipe const swipePercentage = (swipeOffset / containerWidth) * 100; const totalTransform = baseTransform + swipePercentage; // Mobile: Show adjacent cards during swipe for preview effect // Desktop: Cards always visible (handled by opacity check below) const showOnMobile = isSwiping ? isActive || isAdjacent : isActive; // Determine opacity visibility (mobile-specific behavior) const isVisible = isMobile ? showOnMobile : true; return (
1 ? "invisible" : "visible", // Opacity: Mobile fade effect for adjacent cards, desktop always visible isVisible ? "opacity-100" : "opacity-0", // Transitions: Faster during swipe, slower when snapping to position isSwiping ? "transition-opacity duration-200" : "transition-[transform,opacity] duration-500 ease-[cubic-bezier(0.25,0.46,0.45,0.94)]" )} style={{ // Use CSS variable for dynamic transform value (can't be done with Tailwind) transform: `translateX(${totalTransform}%)`, }} >
); } ); TestimonialCarouselSlide.displayName = "TestimonialCarouselSlide"; export const TestimonialCarousel: React.FC<{ fields: CarouselWithTestimonialCards; autoScroll?: boolean; autoScrollInterval?: number; }> = ({ fields, autoScroll = true, autoScrollInterval = 8000 }) => { const testimonials = fields?.items?.items || []; if (!testimonials || testimonials.length === 0) { return null; } // Use the generic carousel swipe hook const carousel = useCarouselSwipe({ itemCount: testimonials.length, cardOffsetPercentage: 105, // How far each card is offset (100% width + 5% gap) swipeThreshold: 0.15, // Need to swipe 15% of screen to change slide mobileBreakpoint: 768, // Tailwind's md breakpoint, Mobile if width < 768px autoScrollInterval: autoScrollInterval, // 8 seconds between slides transition enableAutoScroll: autoScroll, }); return (
{/* Navigation Overlay - Left (Hidden on mobile) */} {/* Navigation Overlay - Right (Hidden on mobile) */} {/* Slider Track Wrapper */}
{/* Slides */} {testimonials.map((item, index) => ( ))}
{/* Dots (Hidden on mobile) */}
{testimonials.map((_, idx) => (
); }; export const TabSwitch: React.FC = ({ tabs, activeTab, onChange, className, }) => { const activeIndex = tabs.indexOf(activeTab); const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0 }); useEffect(() => { const activeButton = buttonRefs.current[activeIndex]; if (activeButton) { setIndicatorStyle({ width: activeButton.offsetWidth, left: activeButton.offsetLeft, }); } }, [activeIndex, tabs]); return (
{/* Sliding background indicator */}
{tabs.map((tab, index) => ( ))}
); };