refactor: enhance category data management in CategoryList and CategoryService by integrating async-mutex for improved concurrency control and simplifying state handling

This commit is contained in:
sebseb7
2025-07-24 07:04:54 +02:00
parent 2f753a81a4
commit b207377a8e
4 changed files with 158 additions and 339 deletions

11
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"@mui/material": "^7.1.1", "@mui/material": "^7.1.1",
"@stripe/react-stripe-js": "^3.7.0", "@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.3.1", "@stripe/stripe-js": "^7.3.1",
"async-mutex": "^0.5.0",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"country-flag-icons": "^1.5.19", "country-flag-icons": "^1.5.19",
"html-react-parser": "^5.2.5", "html-react-parser": "^5.2.5",
@@ -4200,6 +4201,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/async-mutex": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
"integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -11829,7 +11839,6 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"devOptional": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/type-check": { "node_modules/type-check": {

View File

@@ -33,6 +33,7 @@
"@mui/material": "^7.1.1", "@mui/material": "^7.1.1",
"@stripe/react-stripe-js": "^3.7.0", "@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.3.1", "@stripe/stripe-js": "^7.3.1",
"async-mutex": "^0.5.0",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"country-flag-icons": "^1.5.19", "country-flag-icons": "^1.5.19",
"html-react-parser": "^5.2.5", "html-react-parser": "^5.2.5",

View File

@@ -11,311 +11,129 @@ import CloseIcon from "@mui/icons-material/Close";
import { withI18n } from "../../i18n/withTranslation.js"; import { withI18n } from "../../i18n/withTranslation.js";
class CategoryList extends Component { class CategoryList extends Component {
findCategoryById = (category, targetId) => {
if (!category) return null;
if (category.seoName === targetId) {
return category;
}
if (category.children) {
for (let child of category.children) {
const found = this.findCategoryById(child, targetId);
if (found) return found;
}
}
return null;
};
getPathToCategory = (category, targetId, currentPath = []) => {
if (!category) return null;
const newPath = [...currentPath, category];
if (category.seoName === targetId) {
return newPath;
}
if (category.children) {
for (let child of category.children) {
const found = this.getPathToCategory(child, targetId, newPath);
if (found) return found;
}
}
return null;
};
constructor(props) { constructor(props) {
super(props); super(props);
//const { i18n } = props;
const categories = window.categoryService.getSync(209);
// Get current language from props (provided by withI18n HOC) this.state = {
const currentLanguage = props.languageContext?.currentLanguage || 'de'; categories: categories && categories.children && categories.children.length > 0 ? categories.children : [],
mobileMenuOpen: false,
// Check for cached data during SSR/initial render activeCategoryId: null // Will be set properly after categories are loaded
let initialState = {
categoryTree: null,
level1Categories: [], // Children of category 209 (Home) - always shown
level2Categories: [], // Children of active level 1 category
level3Categories: [], // Children of active level 2 category
activePath: [], // Array of active category objects for each level
fetchedCategories: false,
mobileMenuOpen: false, // State for mobile collapsible menu
currentLanguage: currentLanguage,
}; };
// Try to get cached data for SSR
try {
// @note Check both global.window (SSR) and window (browser) for cache
const productCache = (typeof global !== "undefined" && global.window && global.window.productCache) ||
(typeof window !== "undefined" && window.productCache);
if (productCache) {
const cacheKey = `categoryTree_209_${currentLanguage}`;
const cachedData = productCache[cacheKey];
if (cachedData && cachedData.categoryTree) {
const { categoryTree, timestamp } = cachedData;
const cacheAge = Date.now() - timestamp;
const tenMinutes = 10 * 60 * 1000;
// Use cached data if it's fresh
if (cacheAge < tenMinutes) {
initialState.categoryTree = categoryTree;
initialState.fetchedCategories = true;
// Process category tree to set up navigation
const level1Categories =
categoryTree && categoryTree.id === 209
? categoryTree.children || []
: [];
initialState.level1Categories = level1Categories;
// Process active category path if needed
if (props.activeCategoryId) {
const activeCategory = this.findCategoryById(
categoryTree,
props.activeCategoryId
);
if (activeCategory) {
const pathToActive = this.getPathToCategory(
categoryTree,
props.activeCategoryId
);
initialState.activePath = pathToActive
? pathToActive.slice(1)
: [];
if (initialState.activePath.length >= 1) {
const level1Category = initialState.activePath[0];
initialState.level2Categories = level1Category.children || [];
}
if (initialState.activePath.length >= 2) {
const level2Category = initialState.activePath[1];
initialState.level3Categories = level2Category.children || [];
}
}
}
}
}
}
} catch (err) {
console.error("Error reading cache in constructor:", err);
}
this.state = initialState;
} }
componentDidMount() { componentDidMount() {
this.fetchCategories(); if (!this.state.categories || this.state.categories.length === 0) {
window.categoryService.get(209).then((response) => {
console.log("response", response);
if (response.children && response.children.length > 0) {
this.setState({
categories: response.children,
activeCategoryId: this.getLevel1CategoryId(this.props.activeCategoryId)
});
}
});
} else {
// Categories are already loaded, set the initial activeCategoryId
this.setState({
activeCategoryId: this.getLevel1CategoryId(this.props.activeCategoryId)
});
}
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
// Handle language changes if (prevProps.activeCategoryId !== this.props.activeCategoryId) {
const currentLanguage = this.props.languageContext?.currentLanguage || 'de'; //detect path here
const prevLanguage = prevProps.languageContext?.currentLanguage || 'de'; console.log("activeCategoryId updated", this.props.activeCategoryId);
if (currentLanguage !== prevLanguage) { // Get the active category ID of level 1 when prop is seoName
// Language changed, need to refetch categories const level1CategoryId = this.getLevel1CategoryId(this.props.activeCategoryId);
this.setState({ this.setState({
currentLanguage: currentLanguage, activeCategoryId: level1CategoryId
fetchedCategories: false,
categoryTree: null,
level1Categories: [],
level2Categories: [],
level3Categories: [],
activePath: [],
}, () => {
this.fetchCategories();
}); });
return;
}
// If activeCategoryId changes, update subcategories
if (
prevProps.activeCategoryId !== this.props.activeCategoryId &&
this.state.categoryTree
) {
this.processCategoryTree(this.state.categoryTree);
} }
} }
fetchCategories = () => { // Helper method to get level 1 category ID from seoName or numeric ID
if (this.state.fetchedCategories) { getLevel1CategoryId = (categoryIdOrSeoName) => {
console.log('Categories already fetched, skipping'); if (!categoryIdOrSeoName) return null;
return;
}
const currentLanguage = this.state.currentLanguage || 'de';
const windowObj = (typeof global !== "undefined" && global.window) ||
(typeof window !== "undefined" && window);
// Ensure cache exists // If it's already a numeric ID, check if it's a level 1 category
windowObj.productCache = windowObj.productCache || {}; if (typeof categoryIdOrSeoName === 'number') {
// Check if this ID is directly under Home (209)
// The cache is PRERENDERED - always use it first! const level1Category = this.state.categories.find(cat => cat.id === categoryIdOrSeoName);
console.log('CategoryList: Checking prerendered cache', windowObj.productCache); return level1Category ? categoryIdOrSeoName : this.findLevel1ParentId(categoryIdOrSeoName);
// Use either language-specific or default cache
const cacheKey = `categoryTree_209_${currentLanguage}`;
const defaultCacheKey = "categoryTree_209";
// Try language-specific cache first, then fall back to default
const categoryTree =
windowObj.productCache[cacheKey]?.categoryTree ||
windowObj.productCache[defaultCacheKey]?.categoryTree;
if (categoryTree) {
console.log('CategoryList: Using prerendered cache');
this.processCategoryTree(categoryTree);
this.setState({ fetchedCategories: true });
return;
} }
// Only fetch if no prerendered cache exists // If it's a string (seoName), find the category and get its level 1 parent
console.log('CategoryList: No prerendered cache, fetching from socket'); if (typeof categoryIdOrSeoName === 'string') {
windowObj.productCache[cacheKey] = { fetching: true, timestamp: Date.now() }; const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
this.setState({ fetchedCategories: true }); if (!categoryTreeCache || !categoryTreeCache.categoryTree) {
window.socketManager.emit("categoryList", { categoryId: 209, language: currentLanguage, requestTranslation: true }, (response) => { return null;
if (response && response.success) {
// Use translated data if available, otherwise fall back to original
const categoryTreeToUse = response.translation || response.categoryTree;
if (categoryTreeToUse) {
// Store in global cache with timestamp
try {
const cacheKey = `categoryTree_209_${currentLanguage}`;
if (windowObj && windowObj.productCache) {
windowObj.productCache[cacheKey] = {
categoryTree: categoryTreeToUse,
timestamp: Date.now(),
fetching: false,
};
}
} catch (err) {
console.error("Error writing to cache:", err);
}
this.processCategoryTree(categoryTreeToUse);
} else {
console.error('No category tree found in response');
// Clear cache on error
try {
const cacheKey = `categoryTree_209_${currentLanguage}`;
if (windowObj && windowObj.productCache) {
windowObj.productCache[cacheKey] = {
categoryTree: null,
timestamp: Date.now(),
fetching: false,
};
}
} catch (err) {
console.error("Error writing to cache:", err);
}
this.setState({
categoryTree: null,
level1Categories: [],
level2Categories: [],
level3Categories: [],
activePath: [],
});
}
} else {
console.error('Failed to fetch categories:', response);
try {
const cacheKey = `categoryTree_209_${currentLanguage}`;
if (windowObj && windowObj.productCache) {
windowObj.productCache[cacheKey] = {
categoryTree: null,
timestamp: Date.now(),
fetching: false,
};
}
} catch (err) {
console.error("Error writing to cache:", err);
}
this.setState({
categoryTree: null,
level1Categories: [],
level2Categories: [],
level3Categories: [],
activePath: [],
});
} }
});
}; const category = this.findCategoryBySeoName(categoryTreeCache.categoryTree, categoryIdOrSeoName);
if (!category) return null;
// If the found category is already level 1 (direct child of Home)
if (category.parentId === 209) {
return category.id;
}
// Find the level 1 parent of this category
return this.findLevel1ParentId(category.id);
}
return null;
}
processCategoryTree = (categoryTree) => { // Helper method to find category by seoName (similar to Content.js)
// Level 1 categories are always the children of category 209 (Home) findCategoryBySeoName = (categoryNode, seoName) => {
const level1Categories = if (!categoryNode) return null;
categoryTree && categoryTree.id === 209
? categoryTree.children || [] if (categoryNode.seoName === seoName) {
: []; return categoryNode;
}
// Build the navigation path and determine what to show at each level
let level2Categories = []; if (categoryNode.children) {
let level3Categories = []; for (const child of categoryNode.children) {
let activePath = []; const found = this.findCategoryBySeoName(child, seoName);
if (found) return found;
if (this.props.activeCategoryId) {
const activeCategory = this.findCategoryById(
categoryTree,
this.props.activeCategoryId
);
if (activeCategory) {
// Build the path from root to active category
const pathToActive = this.getPathToCategory(
categoryTree,
this.props.activeCategoryId
);
activePath = pathToActive.slice(1); // Remove root (209) from path
// Determine what to show at each level based on the path depth
if (activePath.length >= 1) {
// Show children of the level 1 category
const level1Category = activePath[0];
level2Categories = level1Category.children || [];
}
if (activePath.length >= 2) {
// Show children of the level 2 category
const level2Category = activePath[1];
level3Categories = level2Category.children || [];
}
} }
} }
return null;
}
this.setState({ // Helper method to find the level 1 parent category ID
categoryTree, findLevel1ParentId = (categoryId) => {
level1Categories, const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
level2Categories, if (!categoryTreeCache || !categoryTreeCache.categoryTree) {
level3Categories, return null;
activePath, }
fetchedCategories: true,
}); const findParentPath = (node, targetId, path = []) => {
}; if (node.id === targetId) {
return [...path, node.id];
}
if (node.children) {
for (const child of node.children) {
const result = findParentPath(child, targetId, [...path, node.id]);
if (result) return result;
}
}
return null;
};
const path = findParentPath(categoryTreeCache.categoryTree, categoryId);
if (!path || path.length < 3) return null; // path should be [209, level1Id, ...]
return path[1]; // Return the level 1 category ID (second in path after Home/209)
}
handleMobileMenuToggle = () => { handleMobileMenuToggle = () => {
this.setState(prevState => ({ this.setState(prevState => ({
@@ -331,10 +149,9 @@ class CategoryList extends Component {
}; };
render() { render() {
const { level1Categories, activePath, mobileMenuOpen } = const { categories, mobileMenuOpen, activeCategoryId } = this.state;
this.state;
const renderCategoryRow = (categories, level = 1, isMobile = false) => ( const renderCategoryRow = (categories, isMobile = false) => (
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@@ -351,7 +168,6 @@ class CategoryList extends Component {
msOverflowStyle: "none", msOverflowStyle: "none",
}} }}
> >
{level === 1 && (
<Button <Button
component={Link} component={Link}
to="/" to="/"
@@ -372,7 +188,7 @@ class CategoryList extends Component {
transition: "all 0.2s ease", transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)", textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative", position: "relative",
...(this.props.activeCategoryId === null && { ...(activeCategoryId === null && {
bgcolor: "#fff", bgcolor: "#fff",
textShadow: "none", textShadow: "none",
opacity: 1, opacity: 1,
@@ -396,7 +212,7 @@ class CategoryList extends Component {
<HomeIcon sx={{ <HomeIcon sx={{
fontSize: "1rem", fontSize: "1rem",
mr: isMobile ? 1 : 0, mr: isMobile ? 1 : 0,
color: this.props.activeCategoryId === null ? "#2e7d32" : "inherit" color: activeCategoryId === null ? "#2e7d32" : "inherit"
}} /> }} />
{isMobile && ( {isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}> <Box sx={{ position: "relative", display: "inline-block" }}>
@@ -405,7 +221,7 @@ class CategoryList extends Component {
className="bold-text" className="bold-text"
sx={{ sx={{
fontWeight: "bold", fontWeight: "bold",
color: this.props.activeCategoryId === null ? "#2e7d32" : "transparent", color: activeCategoryId === null ? "#2e7d32" : "transparent",
position: "relative", position: "relative",
zIndex: 2, zIndex: 2,
}} }}
@@ -417,7 +233,7 @@ class CategoryList extends Component {
className="thin-text" className="thin-text"
sx={{ sx={{
fontWeight: "400", fontWeight: "400",
color: this.props.activeCategoryId === null ? "transparent" : "inherit", color: activeCategoryId === null ? "transparent" : "inherit",
position: "absolute", position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
@@ -429,14 +245,10 @@ class CategoryList extends Component {
</Box> </Box>
)} )}
</Button> </Button>
)}
{this.state.fetchedCategories && categories.length > 0 ? ( {categories.length > 0 ? (
<> <>
{categories.map((category) => { {categories.map((category) => {
// Determine if this category is active at this level
const isActiveAtThisLevel =
activePath[level - 1] &&
activePath[level - 1].id === category.id;
return ( return (
<Button <Button
@@ -459,7 +271,7 @@ class CategoryList extends Component {
transition: "all 0.2s ease", transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)", textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative", position: "relative",
...(isActiveAtThisLevel && { ...(activeCategoryId === category.id && {
bgcolor: "#fff", bgcolor: "#fff",
textShadow: "none", textShadow: "none",
opacity: 1, opacity: 1,
@@ -483,7 +295,7 @@ class CategoryList extends Component {
className="bold-text" className="bold-text"
sx={{ sx={{
fontWeight: "bold", fontWeight: "bold",
color: isActiveAtThisLevel ? "#2e7d32" : "transparent", color: activeCategoryId === category.id ? "#2e7d32" : "transparent",
position: "relative", position: "relative",
zIndex: 2, zIndex: 2,
}} }}
@@ -495,7 +307,7 @@ class CategoryList extends Component {
className="thin-text" className="thin-text"
sx={{ sx={{
fontWeight: "400", fontWeight: "400",
color: isActiveAtThisLevel ? "transparent" : "inherit", color: activeCategoryId === category.id ? "transparent" : "inherit",
position: "absolute", position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
@@ -509,8 +321,7 @@ class CategoryList extends Component {
); );
})} })}
</> </>
) : ( ) : ( !isMobile && (
level === 1 && !isMobile && (
<Typography <Typography
variant="caption" variant="caption"
color="inherit" color="inherit"
@@ -549,25 +360,7 @@ class CategoryList extends Component {
}} }}
> >
<Container maxWidth="lg" sx={{ px: 2 }}> <Container maxWidth="lg" sx={{ px: 2 }}>
{/* Level 1 Categories Row - Always shown */} {renderCategoryRow(categories, false)}
{renderCategoryRow(level1Categories, 1, false)}
{/* Level 2 Categories Row - Show when level 1 is selected */}
{/* DISABLED FOR NOW
{level2Categories.length > 0 && (
<Box sx={{ mt: 0.5 }}>
{renderCategoryRow(level2Categories, 2, false)}
</Box>
)}
{/* Level 3 Categories Row - Show when level 2 is selected */}
{/* DISABLED FOR NOW
{level3Categories.length > 0 && (
<Box sx={{ mt: 0.5 }}>
{renderCategoryRow(level3Categories, 3, false)}
</Box>
)}
*/}
</Container> </Container>
</Box> </Box>
@@ -621,7 +414,7 @@ class CategoryList extends Component {
<Collapse in={mobileMenuOpen}> <Collapse in={mobileMenuOpen}>
<Box sx={{ pb: 2 }}> <Box sx={{ pb: 2 }}>
{/* Level 1 Categories - Only level shown in mobile menu */} {/* Level 1 Categories - Only level shown in mobile menu */}
{renderCategoryRow(level1Categories, 1, true)} {renderCategoryRow(categories, true)}
</Box> </Box>
</Collapse> </Collapse>
</Container> </Container>

View File

@@ -1,3 +1,8 @@
import { Mutex } from 'async-mutex';
const mutex = new Mutex();
class CategoryService { class CategoryService {
constructor() { constructor() {
this.get = this.get.bind(this); this.get = this.get.bind(this);
@@ -11,26 +16,37 @@ class CategoryService {
return null; return null;
} }
get(categoryId, language = "de") { async get(categoryId, language = "de") {
const cacheKey = `${categoryId}_${language}`;
if (window.categoryCache && window.categoryCache[cacheKey]) { return await mutex.runExclusive(async () => {
return Promise.resolve(window.categoryCache[cacheKey]); console.log("mutex locked");
}
return new Promise((resolve) => { const cacheKey = `${categoryId}_${language}`;
window.socketManager.emit("categoryList", {categoryId: categoryId, language: language}, (response) => { if (window.categoryCache && window.categoryCache[cacheKey]) {
console.log("CategoryService", cacheKey); console.log("mutex unlocked and returning cached value");
if (response.categoryTree) { return Promise.resolve(window.categoryCache[cacheKey]);
if (!window.categoryCache) { }
window.categoryCache = {};
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;
console.log("mutex unlocked and returning new value");
resolve(response.categoryTree);
} else {
console.log("mutex unlocked and returning null");
resolve(null);
} }
window.categoryCache[cacheKey] = response.categoryTree; });
resolve(response.categoryTree);
} else {
resolve(null);
}
}); });
}); });
} }
} }