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",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.3.1",
"async-mutex": "^0.5.0",
"chart.js": "^4.5.0",
"country-flag-icons": "^1.5.19",
"html-react-parser": "^5.2.5",
@@ -4200,6 +4201,15 @@
"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": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -11829,7 +11839,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"devOptional": true,
"license": "0BSD"
},
"node_modules/type-check": {

View File

@@ -33,6 +33,7 @@
"@mui/material": "^7.1.1",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.3.1",
"async-mutex": "^0.5.0",
"chart.js": "^4.5.0",
"country-flag-icons": "^1.5.19",
"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";
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) {
super(props);
//const { i18n } = props;
const categories = window.categoryService.getSync(209);
// Get current language from props (provided by withI18n HOC)
const currentLanguage = props.languageContext?.currentLanguage || 'de';
// Check for cached data during SSR/initial render
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,
this.state = {
categories: categories && categories.children && categories.children.length > 0 ? categories.children : [],
mobileMenuOpen: false,
activeCategoryId: null // Will be set properly after categories are loaded
};
// 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() {
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) {
// Handle language changes
const currentLanguage = this.props.languageContext?.currentLanguage || 'de';
const prevLanguage = prevProps.languageContext?.currentLanguage || 'de';
if (prevProps.activeCategoryId !== this.props.activeCategoryId) {
//detect path here
console.log("activeCategoryId updated", this.props.activeCategoryId);
// Get the active category ID of level 1 when prop is seoName
const level1CategoryId = this.getLevel1CategoryId(this.props.activeCategoryId);
if (currentLanguage !== prevLanguage) {
// Language changed, need to refetch categories
this.setState({
currentLanguage: currentLanguage,
fetchedCategories: false,
categoryTree: null,
level1Categories: [],
level2Categories: [],
level3Categories: [],
activePath: [],
}, () => {
this.fetchCategories();
activeCategoryId: level1CategoryId
});
return;
}
// If activeCategoryId changes, update subcategories
if (
prevProps.activeCategoryId !== this.props.activeCategoryId &&
this.state.categoryTree
) {
this.processCategoryTree(this.state.categoryTree);
}
}
fetchCategories = () => {
if (this.state.fetchedCategories) {
console.log('Categories already fetched, skipping');
return;
// Helper method to get level 1 category ID from seoName or numeric ID
getLevel1CategoryId = (categoryIdOrSeoName) => {
if (!categoryIdOrSeoName) return null;
// If it's already a numeric ID, check if it's a level 1 category
if (typeof categoryIdOrSeoName === 'number') {
// Check if this ID is directly under Home (209)
const level1Category = this.state.categories.find(cat => cat.id === categoryIdOrSeoName);
return level1Category ? categoryIdOrSeoName : this.findLevel1ParentId(categoryIdOrSeoName);
}
const currentLanguage = this.state.currentLanguage || 'de';
const windowObj = (typeof global !== "undefined" && global.window) ||
(typeof window !== "undefined" && window);
// Ensure cache exists
windowObj.productCache = windowObj.productCache || {};
// The cache is PRERENDERED - always use it first!
console.log('CategoryList: Checking prerendered cache', windowObj.productCache);
// 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
console.log('CategoryList: No prerendered cache, fetching from socket');
windowObj.productCache[cacheKey] = { fetching: true, timestamp: Date.now() };
this.setState({ fetchedCategories: true });
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 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: [],
});
// If it's a string (seoName), find the category and get its level 1 parent
if (typeof categoryIdOrSeoName === 'string') {
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
if (!categoryTreeCache || !categoryTreeCache.categoryTree) {
return null;
}
});
};
processCategoryTree = (categoryTree) => {
// Level 1 categories are always the children of category 209 (Home)
const level1Categories =
categoryTree && categoryTree.id === 209
? categoryTree.children || []
: [];
const category = this.findCategoryBySeoName(categoryTreeCache.categoryTree, categoryIdOrSeoName);
if (!category) return null;
// Build the navigation path and determine what to show at each level
let level2Categories = [];
let level3Categories = [];
let activePath = [];
// If the found category is already level 1 (direct child of Home)
if (category.parentId === 209) {
return category.id;
}
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
// Find the level 1 parent of this category
return this.findLevel1ParentId(category.id);
}
// 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 || [];
}
return null;
}
if (activePath.length >= 2) {
// Show children of the level 2 category
const level2Category = activePath[1];
level3Categories = level2Category.children || [];
}
// Helper method to find category by seoName (similar to Content.js)
findCategoryBySeoName = (categoryNode, seoName) => {
if (!categoryNode) return null;
if (categoryNode.seoName === seoName) {
return categoryNode;
}
if (categoryNode.children) {
for (const child of categoryNode.children) {
const found = this.findCategoryBySeoName(child, seoName);
if (found) return found;
}
}
this.setState({
categoryTree,
level1Categories,
level2Categories,
level3Categories,
activePath,
fetchedCategories: true,
});
};
return null;
}
// Helper method to find the level 1 parent category ID
findLevel1ParentId = (categoryId) => {
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
if (!categoryTreeCache || !categoryTreeCache.categoryTree) {
return null;
}
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 = () => {
this.setState(prevState => ({
@@ -331,10 +149,9 @@ class CategoryList extends Component {
};
render() {
const { level1Categories, activePath, mobileMenuOpen } =
this.state;
const { categories, mobileMenuOpen, activeCategoryId } = this.state;
const renderCategoryRow = (categories, level = 1, isMobile = false) => (
const renderCategoryRow = (categories, isMobile = false) => (
<Box
sx={{
display: "flex",
@@ -351,7 +168,6 @@ class CategoryList extends Component {
msOverflowStyle: "none",
}}
>
{level === 1 && (
<Button
component={Link}
to="/"
@@ -372,7 +188,7 @@ class CategoryList extends Component {
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(this.props.activeCategoryId === null && {
...(activeCategoryId === null && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
@@ -396,7 +212,7 @@ class CategoryList extends Component {
<HomeIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: this.props.activeCategoryId === null ? "#2e7d32" : "inherit"
color: activeCategoryId === null ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
@@ -405,7 +221,7 @@ class CategoryList extends Component {
className="bold-text"
sx={{
fontWeight: "bold",
color: this.props.activeCategoryId === null ? "#2e7d32" : "transparent",
color: activeCategoryId === null ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
@@ -417,7 +233,7 @@ class CategoryList extends Component {
className="thin-text"
sx={{
fontWeight: "400",
color: this.props.activeCategoryId === null ? "transparent" : "inherit",
color: activeCategoryId === null ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
@@ -429,14 +245,10 @@ class CategoryList extends Component {
</Box>
)}
</Button>
)}
{this.state.fetchedCategories && categories.length > 0 ? (
{categories.length > 0 ? (
<>
{categories.map((category) => {
// Determine if this category is active at this level
const isActiveAtThisLevel =
activePath[level - 1] &&
activePath[level - 1].id === category.id;
return (
<Button
@@ -459,7 +271,7 @@ class CategoryList extends Component {
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(isActiveAtThisLevel && {
...(activeCategoryId === category.id && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
@@ -483,7 +295,7 @@ class CategoryList extends Component {
className="bold-text"
sx={{
fontWeight: "bold",
color: isActiveAtThisLevel ? "#2e7d32" : "transparent",
color: activeCategoryId === category.id ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
@@ -495,7 +307,7 @@ class CategoryList extends Component {
className="thin-text"
sx={{
fontWeight: "400",
color: isActiveAtThisLevel ? "transparent" : "inherit",
color: activeCategoryId === category.id ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
@@ -509,8 +321,7 @@ class CategoryList extends Component {
);
})}
</>
) : (
level === 1 && !isMobile && (
) : ( !isMobile && (
<Typography
variant="caption"
color="inherit"
@@ -549,25 +360,7 @@ class CategoryList extends Component {
}}
>
<Container maxWidth="lg" sx={{ px: 2 }}>
{/* Level 1 Categories Row - Always shown */}
{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>
)}
*/}
{renderCategoryRow(categories, false)}
</Container>
</Box>
@@ -621,7 +414,7 @@ class CategoryList extends Component {
<Collapse in={mobileMenuOpen}>
<Box sx={{ pb: 2 }}>
{/* Level 1 Categories - Only level shown in mobile menu */}
{renderCategoryRow(level1Categories, 1, true)}
{renderCategoryRow(categories, true)}
</Box>
</Collapse>
</Container>

View File

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