import { RefObject, useCallback, useEffect, useRef, useState } from 'react'; import type { ContextType } from 'react'; import { useCarouselDimensions } from '../../../../hooks/useCarouselDimensions'; import { useCarouselHeight } from '../../../../hooks/useCarouselHeight'; import { useCarouselMinHeight } from '../../../../hooks/useCarouselMinHeight'; import { useCompactDesktopWidth, getCompactContainerStyle, } from '../../../../hooks/useCompactDesktopWidth'; import { StoreContext } from '../../../../services/store'; import { infiniteScrollTimeout, spotlightCarouselTransitionDurationSec, partialItemWidth, } from '../../../consts'; import { ISettings } from '../../../../types'; export interface LayoutParams { context: { widgetWidth: number; contentSize: ISettings['content_size']; isMobile: boolean; hideArrows: boolean; }; config: { contentSize: ISettings['content_size']; itemCount: number; partialItemWidth: number; alwaysPartial?: boolean; }; refs: { slideRef: RefObject; }; } export interface HeightParams { refs: { outerRef: RefObject }; config: { thumbnailShape: ISettings['thumbnail_shape']; contentSize: ISettings['content_size']; itemCount: number; }; layout: { isCompact: boolean; constrainedWidgetWidth: number; widgetWidth: number; }; } type StoreStateUpdater = ContextType['setStoreState']; interface NavigationParams { config: { itemCount: number; contentSize: ISettings['content_size']; cloneCount: number; contentSizeNotReached: boolean; }; refs: { slideRef: RefObject; containerRef: RefObject; }; state: { itemWidth: number; currentPosition?: number; }; actions: { setStoreState: StoreStateUpdater; }; transitionDurationSec?: number; } interface NavigationState { activeIndex: number; isTransitioning: boolean; // When true for one cycle, suppresses the CSS transition during the clone→real snap. skipTransition: boolean; } // Forces re-render on window resize to update carousel dimensions. const useForceResizeRerender = (): void => { const [, setTick] = useState(0); useEffect(() => { if (typeof window === 'undefined') { return; } const handleResize = () => setTick((tick) => tick + 1); window.addEventListener('resize', handleResize); window.removeEventListener('resize', handleResize); }, []); }; /** * Decide whether the uniform carousel should use clones and partial visibility. * If there are fewer items than visible slots, disable cloning and partial positioning. */ export const useUniformCenteredConfig = ( itemCount: number, contentSize: ISettings['content_size'], setStoreState: StoreStateUpdater, ) => { const normalizedContentSize = Math.max(contentSize, 1); const contentSizeNotReached = itemCount < normalizedContentSize; const partialItemWidthValue = contentSizeNotReached ? 0 : partialItemWidth; const cloneCount = contentSizeNotReached || itemCount <= 1 ? 0 : Math.min(normalizedContentSize, itemCount); useEffect(() => { setStoreState((state) => { if (state.productListUniformMode === contentSizeNotReached) { return state; } return { ...state, productListUniformMode: contentSizeNotReached, }; }); }, [contentSizeNotReached, setStoreState]); return { contentSizeNotReached, partialItemWidth: partialItemWidthValue, cloneCount }; }; // Manages layout dimensions, compact mode, and arrow sizing for the carousel. export const useCarouselLayout = ({ context: { widgetWidth, contentSize, isMobile, hideArrows }, config: { contentSize: carouselContentSize, itemCount, partialItemWidth: carouselPartialItemWidth, alwaysPartial, }, refs: { slideRef }, }: LayoutParams) => { useForceResizeRerender(); const { width: constrainedWidgetWidth, isCompact } = useCompactDesktopWidth({ widgetWidth, contentSize, isMobile, }); const compactStyle = getCompactContainerStyle(isCompact, constrainedWidgetWidth); const carouselDimensions = useCarouselDimensions({ widgetWidth: constrainedWidgetWidth, slideRef, hideArrows, hasItems: itemCount > 1, nbElems: carouselContentSize, itemCount, isMobile: false, partialItemWidth: carouselPartialItemWidth, alwaysPartial, forcedArrowSize: isCompact ? 'medium' : undefined, }); const arrowSize = isCompact ? 'medium' : carouselDimensions.arrowSize; return { constrainedWidgetWidth, isCompact, compactStyle, carouselDimensions, arrowSize, }; }; // Calculates enforced minimum height for the carousel container. export const useCarouselHeightConfig = ({ refs: { outerRef }, config: { thumbnailShape, contentSize, itemCount }, layout: { isCompact, constrainedWidgetWidth, widgetWidth }, }: HeightParams) => { const carouselHeight = useCarouselHeight({ ref: outerRef, shape: thumbnailShape, contentSize, itemCount, partialItemWidth, }); const enforceConfiguredHeight = contentSize <= 3 && !isCompact; const enforcedMinHeight = useCarouselMinHeight( outerRef, carouselHeight, enforceConfiguredHeight, `${itemCount}-${isCompact ? constrainedWidgetWidth : widgetWidth}`, ); return { enforcedMinHeight }; }; // Handlesandle navigation state, slide transitions, and position syncing for infinite carousels. export const useInfiniteNavigation = ({ config: { itemCount, contentSize, contentSizeNotReached }, refs: { containerRef }, state: { currentPosition }, actions: { setStoreState }, transitionDurationSec = spotlightCarouselTransitionDurationSec, }: NavigationParams) => { const [navigationState, setNavigationState] = useState({ activeIndex: 0, isTransitioning: false, skipTransition: false, }); const { activeIndex, isTransitioning } = navigationState; const timeoutRef = useRef(null); const clearPendingTimeout = useCallback(() => { if (timeoutRef.current !== null) { if (typeof window !== 'undefined') { window.clearTimeout(timeoutRef.current); } timeoutRef.current = null; } }, []); useEffect(() => () => clearPendingTimeout(), [clearPendingTimeout]); const updateStorePosition = useCallback( (position: number) => { setStoreState((state) => { if (state.currentPosition === position) { return state; } return { ...state, currentPosition: position, }; }); }, [setStoreState], ); const goToSlide = useCallback( (index: number, shouldUpdateCurrentPosition = true) => { if (itemCount === 0) { return; } if (contentSizeNotReached) { const normalizedIndex = ((index % itemCount) + itemCount) % itemCount; setNavigationState((state) => ({ ...state, activeIndex: normalizedIndex, isTransitioning: false, })); if (shouldUpdateCurrentPosition) { updateStorePosition(normalizedIndex); } return; } // Normalise the destination index now so we can update the store immediately. // This makes the spotlight grow start at the same time as the slide animation. let newIndex = index; if (index >= itemCount) newIndex = Math.abs(index) - itemCount; if (index < 0) newIndex = itemCount - Math.abs(index); setNavigationState((state) => ({ ...state, activeIndex: index, isTransitioning: true })); if (shouldUpdateCurrentPosition) { updateStorePosition(newIndex); } clearPendingTimeout(); if (typeof window === 'undefined') { setNavigationState((state) => ({ ...state, activeIndex: index, isTransitioning: false })); return; } // After the transition, snap back to the normalized index without visible animation. timeoutRef.current = window.setTimeout(() => { const isWrapping = newIndex !== index; setNavigationState((state) => ({ ...state, activeIndex: newIndex, isTransitioning: false, skipTransition: isWrapping, })); }, transitionDurationSec * 1000 + 50); }, [ itemCount, contentSizeNotReached, containerRef, updateStorePosition, clearPendingTimeout, transitionDurationSec, ], ); useEffect(() => { if (currentPosition === undefined || currentPosition === activeIndex || isTransitioning) { return; } const diff = Math.abs(currentPosition - activeIndex); // If the external position lands in the cloned buffer, use the clone equivalent // so the carousel can move continuously instead of jumping straight to the real index. if (diff > contentSize || diff + 1 === itemCount) { if (currentPosition > activeIndex) { goToSlide(currentPosition - itemCount); } else { goToSlide(currentPosition + itemCount); } } else { goToSlide(currentPosition); } }, [currentPosition, activeIndex, contentSize, itemCount, goToSlide, isTransitioning]); const handleSlide = useCallback( (direction: 'left' | 'right') => { if (contentSizeNotReached || isTransitioning) { return; } const delta = direction === 'left' ? -1 : 1; goToSlide(activeIndex + delta); }, [contentSizeNotReached, isTransitioning, activeIndex, goToSlide], ); // Clear skipTransition after the browser has painted the transition:none frame. useEffect(() => { if (!navigationState.skipTransition) return; const id = window.setTimeout(() => { setNavigationState((state) => ({ ...state, skipTransition: false })); }, 0); return () => window.clearTimeout(id); }, [navigationState.skipTransition]); return { activeIndex, isTransitioning, skipTransition: navigationState.skipTransition, handleSlide, goToSlide, }; };