refactor: integrate CategoryService into SharedCarousel for improved category data management and enhance component structure
This commit is contained in:
@@ -1,146 +1,256 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React from "react";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import ChevronLeft from "@mui/icons-material/ChevronLeft";
|
import ChevronLeft from "@mui/icons-material/ChevronLeft";
|
||||||
import ChevronRight from "@mui/icons-material/ChevronRight";
|
import ChevronRight from "@mui/icons-material/ChevronRight";
|
||||||
import CategoryBox from "./CategoryBox.js";
|
import CategoryBox from "./CategoryBox.js";
|
||||||
import { useCarousel } from "../contexts/CarouselContext.js";
|
import { withTranslation } from 'react-i18next';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
// Helper to process and set categories
|
const ITEM_WIDTH = 130 + 16; // 130px width + 16px gap
|
||||||
const processCategoryTree = (categoryTree) => {
|
const AUTO_SCROLL_SPEED = 0.5; // px per frame (~60fps, so ~30px/sec)
|
||||||
if (
|
const AUTOSCROLL_RESTART_DELAY = 5000; // 5 seconds of inactivity before restarting autoscroll
|
||||||
categoryTree &&
|
const SCROLLBAR_FLASH_DURATION = 3000; // 3 seconds to show the virtual scrollbar
|
||||||
categoryTree.id === 209 &&
|
|
||||||
Array.isArray(categoryTree.children)
|
|
||||||
) {
|
|
||||||
return categoryTree.children;
|
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check for cached data
|
class SharedCarousel extends React.Component {
|
||||||
const getProductCache = () => {
|
_isMounted = false;
|
||||||
// Ensure cache exists
|
categories = [];
|
||||||
if (typeof window !== "undefined") {
|
originalCategories = [];
|
||||||
window.productCache = window.productCache || {};
|
animationFrame = null;
|
||||||
return window.productCache;
|
autoScrollActive = true;
|
||||||
}
|
translateX = 0;
|
||||||
if (typeof global !== "undefined" && global.window) {
|
inactivityTimer = null;
|
||||||
global.window.productCache = global.window.productCache || {};
|
scrollbarTimer = null;
|
||||||
return global.window.productCache;
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize categories
|
constructor(props) {
|
||||||
const initializeCategories = (language = 'en') => {
|
super(props);
|
||||||
const productCache = getProductCache();
|
const { i18n } = props;
|
||||||
|
const categories = window.categoryService.getSync(209);
|
||||||
|
|
||||||
// The cache is PRERENDERED - always use it first!
|
this.state = {
|
||||||
// Try language-specific cache first, then fallback to default
|
categories: categories && categories.children && categories.children.length > 0 ? [...categories.children, ...categories.children] : [],
|
||||||
const categoryTree = productCache?.[`categoryTree_209_${language}`]?.categoryTree ||
|
currentLanguage: (i18n && i18n.language) || 'de',
|
||||||
productCache?.["categoryTree_209"]?.categoryTree;
|
showScrollbar: false,
|
||||||
|
|
||||||
return categoryTree ? processCategoryTree(categoryTree) : [];
|
|
||||||
};
|
|
||||||
|
|
||||||
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([]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
i18n.on('languageChanged', handleLanguageChange);
|
if(categories && categories.children && categories.children.length > 0) {
|
||||||
return () => {
|
this.originalCategories = categories.children;
|
||||||
i18n.off('languageChanged', handleLanguageChange);
|
this.categories = [...categories.children, ...categories.children];
|
||||||
};
|
this.startAutoScroll();
|
||||||
}, [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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only fetch if needed
|
this.carouselTrackRef = React.createRef();
|
||||||
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 || [];
|
componentDidMount() {
|
||||||
setRootCategories(newCategories);
|
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(() => {
|
componentWillUnmount() {
|
||||||
const filtered = rootCategories.filter(
|
this._isMounted = false;
|
||||||
(cat) => cat.id !== 689 && cat.id !== 706
|
this.stopAutoScroll();
|
||||||
|
this.clearInactivityTimer();
|
||||||
|
this.clearScrollbarTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
startAutoScroll = () => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="virtual-scrollbar"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '5px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: '200px',
|
||||||
|
height: '4px',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||||
|
borderRadius: '2px',
|
||||||
|
zIndex: 1000,
|
||||||
|
opacity: this.state.showScrollbar ? 1 : 0,
|
||||||
|
transition: 'opacity 0.3s ease-in-out'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="scrollbar-thumb"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '0',
|
||||||
|
left: `${thumbPosition}%`,
|
||||||
|
width: '20px',
|
||||||
|
height: '4px',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
borderRadius: '2px',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
transition: 'left 0.2s ease-out'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
setFilteredCategories(filtered);
|
};
|
||||||
}, [rootCategories, setFilteredCategories]);
|
|
||||||
|
|
||||||
// Create duplicated array for seamless scrolling
|
render() {
|
||||||
const displayCategories = [...filteredCategories, ...filteredCategories];
|
const { t } = this.props;
|
||||||
|
const { categories } = this.state;
|
||||||
|
|
||||||
if (filteredCategories.length === 0) {
|
if(!categories || categories.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,8 +284,8 @@ const SharedCarousel = () => {
|
|||||||
>
|
>
|
||||||
{/* Left Arrow */}
|
{/* Left Arrow */}
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => moveCarousel("left")}
|
|
||||||
aria-label="Vorherige Kategorien anzeigen"
|
aria-label="Vorherige Kategorien anzeigen"
|
||||||
|
onClick={this.handleLeftClick}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '50%',
|
top: '50%',
|
||||||
@@ -194,8 +304,8 @@ const SharedCarousel = () => {
|
|||||||
|
|
||||||
{/* Right Arrow */}
|
{/* Right Arrow */}
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => moveCarousel("right")}
|
|
||||||
aria-label="Nächste Kategorien anzeigen"
|
aria-label="Nächste Kategorien anzeigen"
|
||||||
|
onClick={this.handleRightClick}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '50%',
|
top: '50%',
|
||||||
@@ -227,7 +337,7 @@ const SharedCarousel = () => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="home-carousel-track"
|
className="home-carousel-track"
|
||||||
ref={carouselRef}
|
ref={this.carouselTrackRef}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '16px',
|
gap: '16px',
|
||||||
@@ -240,7 +350,7 @@ const SharedCarousel = () => {
|
|||||||
margin: '0 auto'
|
margin: '0 auto'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{displayCategories.map((category, index) => (
|
{categories.map((category, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${category.id}-${index}`}
|
key={`${category.id}-${index}`}
|
||||||
className="carousel-item"
|
className="carousel-item"
|
||||||
@@ -266,10 +376,14 @@ const SharedCarousel = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Virtual Scrollbar */}
|
||||||
|
{this.renderVirtualScrollbar()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default SharedCarousel;
|
export default withTranslation()(SharedCarousel);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import "./index.css";
|
|||||||
import App from "./App.js";
|
import App from "./App.js";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import "./services/SocketManager.js";
|
import "./services/SocketManager.js";
|
||||||
|
import "./services/CategoryService.js";
|
||||||
|
|
||||||
// Create a wrapper component with our class-based GoogleAuthProvider
|
// Create a wrapper component with our class-based GoogleAuthProvider
|
||||||
// This avoids the "Invalid hook call" error from GoogleOAuthProvider
|
// This avoids the "Invalid hook call" error from GoogleOAuthProvider
|
||||||
|
|||||||
40
src/services/CategoryService.js
Normal file
40
src/services/CategoryService.js
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user