diff --git a/src/components/SharedCarousel.js b/src/components/SharedCarousel.js index 3898519..f4d332e 100644 --- a/src/components/SharedCarousel.js +++ b/src/components/SharedCarousel.js @@ -1,275 +1,389 @@ -import React, { useEffect, useState } from "react"; +import React from "react"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; import ChevronLeft from "@mui/icons-material/ChevronLeft"; import ChevronRight from "@mui/icons-material/ChevronRight"; import CategoryBox from "./CategoryBox.js"; -import { useCarousel } from "../contexts/CarouselContext.js"; -import { useTranslation } from 'react-i18next'; +import { withTranslation } from 'react-i18next'; -// Helper to process and set categories -const processCategoryTree = (categoryTree) => { - if ( - categoryTree && - categoryTree.id === 209 && - Array.isArray(categoryTree.children) - ) { - return categoryTree.children; - } else { - return []; - } -}; +const ITEM_WIDTH = 130 + 16; // 130px width + 16px gap +const AUTO_SCROLL_SPEED = 0.5; // px per frame (~60fps, so ~30px/sec) +const AUTOSCROLL_RESTART_DELAY = 5000; // 5 seconds of inactivity before restarting autoscroll +const SCROLLBAR_FLASH_DURATION = 3000; // 3 seconds to show the virtual scrollbar -// Check for cached data -const getProductCache = () => { - // Ensure cache exists - if (typeof window !== "undefined") { - window.productCache = window.productCache || {}; - return window.productCache; - } - if (typeof global !== "undefined" && global.window) { - global.window.productCache = global.window.productCache || {}; - return global.window.productCache; - } - return {}; -}; +class SharedCarousel extends React.Component { + _isMounted = false; + categories = []; + originalCategories = []; + animationFrame = null; + autoScrollActive = true; + translateX = 0; + inactivityTimer = null; + scrollbarTimer = null; -// Initialize categories -const initializeCategories = (language = 'en') => { - const productCache = getProductCache(); - - // The cache is PRERENDERED - always use it first! - // Try language-specific cache first, then fallback to default - const categoryTree = productCache?.[`categoryTree_209_${language}`]?.categoryTree || - productCache?.["categoryTree_209"]?.categoryTree; - - return categoryTree ? processCategoryTree(categoryTree) : []; -}; + constructor(props) { + super(props); + const { i18n } = props; + const categories = window.categoryService.getSync(209); -const SharedCarousel = () => { - const { carouselRef, filteredCategories, setFilteredCategories, moveCarousel } = useCarousel(); - const { t, i18n } = useTranslation(); - const [rootCategories, setRootCategories] = useState([]); - const [currentLanguage, setCurrentLanguage] = useState(i18n.language || 'de'); - - useEffect(() => { - const initialCategories = initializeCategories(currentLanguage); - setRootCategories(initialCategories); - }, [currentLanguage]); - - // Also listen for i18n ready state - useEffect(() => { - if (i18n.isInitialized && i18n.language !== currentLanguage) { - setCurrentLanguage(i18n.language); - } - }, [i18n.isInitialized, i18n.language, currentLanguage]); - - // Listen for language changes - useEffect(() => { - const handleLanguageChange = (lng) => { - setCurrentLanguage(lng); - // Clear categories to force refetch - setRootCategories([]); + this.state = { + categories: categories && categories.children && categories.children.length > 0 ? [...categories.children, ...categories.children] : [], + currentLanguage: (i18n && i18n.language) || 'de', + showScrollbar: false, }; - - i18n.on('languageChanged', handleLanguageChange); - return () => { - i18n.off('languageChanged', handleLanguageChange); - }; - }, [i18n]); - - useEffect(() => { - // The cache is PRERENDERED - check it first - const cache = getProductCache(); - console.log('SharedCarousel: Checking prerendered cache', cache); - // Try language-specific cache first, then fallback to default - const categoryTree = cache?.[`categoryTree_209_${currentLanguage}`]?.categoryTree || - cache?.["categoryTree_209"]?.categoryTree; - - // Use cache if available - if (categoryTree) { - console.log('SharedCarousel: Using prerendered cache'); - setRootCategories(processCategoryTree(categoryTree)); - return; + if(categories && categories.children && categories.children.length > 0) { + this.originalCategories = categories.children; + this.categories = [...categories.children, ...categories.children]; + this.startAutoScroll(); } - - // Only fetch if needed - if (rootCategories.length === 0 && typeof window !== "undefined") { - console.log('SharedCarousel: No prerendered cache, fetching from socket'); - window.socketManager.emit("categoryList", { categoryId: 209, language: currentLanguage, requestTranslation: true }, (response) => { - if (response && response.success) { - // Use translated data if available, otherwise fall back to original - const categoryTreeToUse = response.translation || response.categoryTree; - - if (categoryTreeToUse) { - // Store in cache with language-specific key - if (!window.productCache) window.productCache = {}; - window.productCache[`categoryTree_209_${currentLanguage}`] = { - categoryTree: categoryTreeToUse, - timestamp: Date.now(), - }; - - const newCategories = categoryTreeToUse.children || []; - setRootCategories(newCategories); - } - } else if (response && response.categoryTree) { - // Fallback for old response format - // Store in cache with language-specific key - if (!window.productCache) window.productCache = {}; - window.productCache[`categoryTree_209_${currentLanguage}`] = { - categoryTree: response.categoryTree, - timestamp: Date.now(), - }; - - const newCategories = response.categoryTree.children || []; - setRootCategories(newCategories); + + this.carouselTrackRef = React.createRef(); + } + + componentDidMount() { + this._isMounted = true; + if (!this.state.categories || this.state.categories.length === 0) { + window.categoryService.get(209).then((response) => { + if (this._isMounted && response.children && response.children.length > 0) { + this.originalCategories = response.children; + // Duplicate for seamless looping + this.categories = [...response.children, ...response.children]; + this.setState({ categories: this.categories }); + this.startAutoScroll(); } }); } - }, [rootCategories.length, currentLanguage]); - - useEffect(() => { - const filtered = rootCategories.filter( - (cat) => cat.id !== 689 && cat.id !== 706 - ); - setFilteredCategories(filtered); - }, [rootCategories, setFilteredCategories]); - - // Create duplicated array for seamless scrolling - const displayCategories = [...filteredCategories, ...filteredCategories]; - - if (filteredCategories.length === 0) { - return null; } - return ( - - - {t('navigation.categories')} - + componentWillUnmount() { + this._isMounted = false; + this.stopAutoScroll(); + this.clearInactivityTimer(); + this.clearScrollbarTimer(); + } -
{ + this.autoScrollActive = true; + if (!this.animationFrame) { + this.animationFrame = requestAnimationFrame(this.handleAutoScroll); + } + }; + + stopAutoScroll = () => { + this.autoScrollActive = false; + if (this.animationFrame) { + cancelAnimationFrame(this.animationFrame); + this.animationFrame = null; + } + }; + + clearInactivityTimer = () => { + if (this.inactivityTimer) { + clearTimeout(this.inactivityTimer); + this.inactivityTimer = null; + } + }; + + clearScrollbarTimer = () => { + if (this.scrollbarTimer) { + clearTimeout(this.scrollbarTimer); + this.scrollbarTimer = null; + } + }; + + startInactivityTimer = () => { + this.clearInactivityTimer(); + this.inactivityTimer = setTimeout(() => { + if (this._isMounted) { + this.startAutoScroll(); + } + }, AUTOSCROLL_RESTART_DELAY); + }; + + showScrollbarFlash = () => { + this.clearScrollbarTimer(); + this.setState({ showScrollbar: true }); + + this.scrollbarTimer = setTimeout(() => { + if (this._isMounted) { + this.setState({ showScrollbar: false }); + } + }, SCROLLBAR_FLASH_DURATION); + }; + + handleAutoScroll = () => { + if (!this.autoScrollActive || this.originalCategories.length === 0) return; + + this.translateX -= AUTO_SCROLL_SPEED; + this.updateTrackTransform(); + + const originalItemCount = this.originalCategories.length; + const maxScroll = ITEM_WIDTH * originalItemCount; + + // Check if we've scrolled past the first set of items + if (Math.abs(this.translateX) >= maxScroll) { + // Reset to beginning seamlessly + this.translateX = 0; + this.updateTrackTransform(); + } + + this.animationFrame = requestAnimationFrame(this.handleAutoScroll); + }; + + updateTrackTransform = () => { + if (this.carouselTrackRef.current) { + this.carouselTrackRef.current.style.transform = `translateX(${this.translateX}px)`; + } + }; + + handleLeftClick = () => { + this.stopAutoScroll(); + this.scrollBy(1); + this.showScrollbarFlash(); + this.startInactivityTimer(); + }; + + handleRightClick = () => { + this.stopAutoScroll(); + this.scrollBy(-1); + this.showScrollbarFlash(); + this.startInactivityTimer(); + }; + + scrollBy = (direction) => { + if (this.originalCategories.length === 0) return; + + // direction: 1 = left (scroll content right), -1 = right (scroll content left) + const originalItemCount = this.originalCategories.length; + const maxScroll = ITEM_WIDTH * originalItemCount; + + this.translateX += direction * ITEM_WIDTH; + + // Handle wrap-around when scrolling left (positive translateX) + if (this.translateX > 0) { + this.translateX = -(maxScroll - ITEM_WIDTH); + } + // Handle wrap-around when scrolling right (negative translateX beyond limit) + else if (Math.abs(this.translateX) >= maxScroll) { + this.translateX = 0; + } + + this.updateTrackTransform(); + + // Force scrollbar to update immediately after wrap-around + if (this.state.showScrollbar) { + this.forceUpdate(); + } + }; + + renderVirtualScrollbar = () => { + if (!this.state.showScrollbar || this.originalCategories.length === 0) { + return null; + } + + const originalItemCount = this.originalCategories.length; + const viewportWidth = 1080; // carousel container max-width + const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH); + + // Calculate which item is currently at the left edge (first visible) + // Map translateX directly to item index using the same logic as scrollBy + let currentItemIndex; + + if (this.translateX === 0) { + // At the beginning - item 0 is visible + currentItemIndex = 0; + } else if (this.translateX > 0) { + // Wrapped to show end items (this happens when scrolling left past beginning) + const maxScroll = ITEM_WIDTH * originalItemCount; + const effectivePosition = maxScroll + this.translateX; + currentItemIndex = Math.floor(effectivePosition / ITEM_WIDTH); + } else { + // Normal negative scrolling - calculate which item is at left edge + currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH); + } + + // Ensure we stay within bounds + currentItemIndex = Math.max(0, Math.min(currentItemIndex, originalItemCount - 1)); + + // Calculate scrollbar position: 0% when item 0 is first visible, 100% when last item is first visible + const lastPossibleFirstItem = Math.max(0, originalItemCount - itemsInView); + const thumbPosition = lastPossibleFirstItem > 0 ? Math.min((currentItemIndex / lastPossibleFirstItem) * 100, 100) : 0; + + return ( +
- {/* Left Arrow */} - moveCarousel("left")} - aria-label="Vorherige Kategorien anzeigen" +
+
+ ); + }; + + render() { + const { t } = this.props; + const { categories } = this.state; + + if(!categories || categories.length === 0) { + return null; + } + + return ( + + - -
+ {t('navigation.categories')} + - {/* Right Arrow */} - moveCarousel("right")} - aria-label="Nächste Kategorien anzeigen" - style={{ - position: 'absolute', - top: '50%', - right: '8px', - transform: 'translateY(-50%)', - zIndex: 1200, - backgroundColor: 'rgba(255, 255, 255, 0.9)', - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)', - width: '48px', - height: '48px', - borderRadius: '50%' - }} - > - - - -
-
- {displayCategories.map((category, index) => ( -
- -
- ))} + + + + {/* Right Arrow */} + + + + +
+
+ {categories.map((category, index) => ( +
+ +
+ ))} +
+ + {/* Virtual Scrollbar */} + {this.renderVirtualScrollbar()}
-
- - ); -}; + + ); + } +} -export default SharedCarousel; \ No newline at end of file +export default withTranslation()(SharedCarousel); diff --git a/src/index.js b/src/index.js index e9e359d..c5b63e5 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ import "./index.css"; import App from "./App.js"; import { BrowserRouter } from "react-router-dom"; import "./services/SocketManager.js"; +import "./services/CategoryService.js"; // Create a wrapper component with our class-based GoogleAuthProvider // This avoids the "Invalid hook call" error from GoogleOAuthProvider diff --git a/src/services/CategoryService.js b/src/services/CategoryService.js new file mode 100644 index 0000000..378a88d --- /dev/null +++ b/src/services/CategoryService.js @@ -0,0 +1,40 @@ +class CategoryService { + constructor() { + this.get = this.get.bind(this); + } + + getSync(categoryId, language = "de") { + const cacheKey = `${categoryId}_${language}`; + if (window.categoryCache && window.categoryCache[cacheKey]) { + return window.categoryCache[cacheKey]; + } + return null; + } + + get(categoryId, language = "de") { + const cacheKey = `${categoryId}_${language}`; + if (window.categoryCache && window.categoryCache[cacheKey]) { + return Promise.resolve(window.categoryCache[cacheKey]); + } + + return new Promise((resolve) => { + window.socketManager.emit("categoryList", {categoryId: categoryId, language: language}, (response) => { + console.log("CategoryService", cacheKey); + if (response.categoryTree) { + if (!window.categoryCache) { + window.categoryCache = {}; + } + window.categoryCache[cacheKey] = response.categoryTree; + resolve(response.categoryTree); + } else { + resolve(null); + } + }); + }); + } +} + +const categoryService = new CategoryService(); +window.categoryService = categoryService; + +export default categoryService; \ No newline at end of file