Compare commits

..

6 Commits

38 changed files with 838 additions and 280 deletions

View File

@@ -34,11 +34,12 @@ import Header from "./components/Header.js";
import Footer from "./components/Footer.js";
import MainPageLayout from "./components/MainPageLayout.js";
// TEMPORARILY DISABLE ALL LAZY LOADING TO ELIMINATE CircularProgress
import Content from "./components/Content.js";
import ProductDetail from "./components/ProductDetail.js";
import ProfilePage from "./pages/ProfilePage.js";
import ResetPassword from "./pages/ResetPassword.js";
// Lazy load rarely-accessed pages
const ProfilePage = lazy(() => import(/* webpackChunkName: "profile" */ "./pages/ProfilePage.js"));
const ResetPassword = lazy(() => import(/* webpackChunkName: "reset-password" */ "./pages/ResetPassword.js"));
// Lazy load admin pages - only loaded when admin users access them
const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js"));

View File

@@ -296,6 +296,13 @@ class Filter extends Component {
const event = { target: { name: option.id, checked: !options[option.id] } };
this.handleOptionChange(event);
}}>
{this.props.filterType === 'manufacturer' && this.props.manufacturerImages?.get(option.id) && (
<img
src={this.props.manufacturerImages.get(option.id)}
alt=""
style={{ height: '24px', width: 'auto', marginRight: '6px', verticalAlign: 'middle', objectFit: 'contain' }}
/>
)}
{option.name}
</td>
<td style={countCellStyle}>

View File

@@ -345,16 +345,48 @@ class Footer extends Component {
</Stack>
{/* Copyright Section */}
<Box sx={{ pb:'20px',textAlign: 'center', filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))', opacity: 0.7 }}>
<Box sx={{ pb: 0, textAlign: 'center', filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))', opacity: 0.7 }}>
<Typography variant="body2" sx={{ mb: 1, fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
{this.props.t ? this.props.t('footer.allPricesIncl') : '* Alle Preise inkl. gesetzlicher USt., zzgl. Versand'}
</Typography>
<Typography
variant="body2"
sx={{
mb: 1,
fontSize: { xs: '11px', md: '14px' },
lineHeight: 1.5,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 0.75,
}}
>
Made with
<Box
component="span"
sx={{
backgroundColor: '#1976d2',
color: '#ffffff',
borderRadius: '3px',
px: 0.6,
py: 0.15,
fontWeight: 700,
lineHeight: 1,
fontSize: { xs: '10px', md: '12px' },
letterSpacing: '0.3px',
display: 'inline-flex',
alignItems: 'center',
}}
>
jB
</Box>
<StyledDomainLink href="https://jbuddy.de" target="_blank" rel="noopener noreferrer">
jBuddy.de
</StyledDomainLink>
</Typography>
<Typography variant="body2" sx={{ fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
© {new Date().getFullYear()} <StyledDomainLink href="https://growheads.de" target="_blank" rel="noopener noreferrer">GrowHeads.de</StyledDomainLink>
</Typography>
<Typography variant="body2" sx={{ fontSize: { xs: '9px', md: '9px' }, lineHeight: 1.5, mt: 1 }}>
<StyledDomainLink href="https://telegraf.growheads.de" target="_blank" rel="noreferrer">Telegraf - sicherer Chat mit unseren Mitarbeitern</StyledDomainLink>
</Typography>
</Box>
</Stack>
</Box>

View File

@@ -28,6 +28,27 @@ import GoogleIcon from '@mui/icons-material/Google';
// Lazy load GoogleAuthProvider
const GoogleAuthProvider = lazy(() => import('../providers/GoogleAuthProvider.js'));
const getTokenFromAuthResponse = (response) =>
response?.token ||
response?.accessToken ||
response?.jwt ||
response?.user?.token ||
response?.user?.accessToken ||
null;
const persistSessionAuth = (response) => {
if (response?.user) {
sessionStorage.setItem('user', JSON.stringify(response.user));
}
const token = getTokenFromAuthResponse(response);
if (token) {
sessionStorage.setItem('authToken', token);
} else {
sessionStorage.removeItem('authToken');
}
};
// Function to check if user is logged in
export const isUserLoggedIn = () => {
const storedUser = sessionStorage.getItem('user');
@@ -77,6 +98,7 @@ function cartsAreIdentical(cartA, cartB) {
export class LoginComponent extends Component {
constructor(props) {
super(props);
const { isLoggedIn, user, isAdmin } = isUserLoggedIn();
this.state = {
open: false,
tabValue: 0,
@@ -86,9 +108,9 @@ export class LoginComponent extends Component {
error: '',
loading: false,
success: '',
isLoggedIn: false,
isAdmin: false,
user: null,
isLoggedIn,
isAdmin,
user,
anchorEl: null,
showGoogleAuth: false,
cartSyncOpen: false,
@@ -103,16 +125,6 @@ export class LoginComponent extends Component {
// Make the open function available globally
window.openLoginDrawer = this.handleOpen;
// Check if user is logged in
const { isLoggedIn: userIsLoggedIn, user: storedUser } = isUserLoggedIn();
if (userIsLoggedIn) {
this.setState({
user: storedUser,
isAdmin: !!storedUser.admin,
isLoggedIn: true
});
}
if (this.props.open) {
this.setState({ open: true });
}
@@ -190,7 +202,7 @@ export class LoginComponent extends Component {
window.socketManager.emit('verifyUser', { email, password }, (response) => {
console.log('LoginComponent: verifyUser', response);
if (response.success) {
sessionStorage.setItem('user', JSON.stringify(response.user));
persistSessionAuth(response);
this.setState({
user: response.user,
isLoggedIn: true,
@@ -306,6 +318,7 @@ export class LoginComponent extends Component {
window.socketManager.emit('logout', (response) => {
if(response.success){
sessionStorage.removeItem('user');
sessionStorage.removeItem('authToken');
window.dispatchEvent(new CustomEvent('userLoggedIn'));
this.props.navigate('/');
this.setState({
@@ -362,7 +375,7 @@ export class LoginComponent extends Component {
window.socketManager.emit('verifyGoogleUser', { credential: credentialResponse.credential }, (response) => {
console.log('google respo',response);
if (response.success) {
sessionStorage.setItem('user', JSON.stringify(response.user));
persistSessionAuth(response);
this.setState({
isLoggedIn: true,
isAdmin: !!response.user.admin,

View File

@@ -14,7 +14,7 @@ import { useTranslation } from 'react-i18next';
const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity, translatedContent }) => (
<Grid key={`${pageType}-${index}`} item xs={12} sm={6} sx={{ p: 2, width: "50%", position: 'relative' }}>
{index === 0 && pageType === "filiale" && (
{index === 0 && pageType === "home" && (
<Box
sx={{
position: 'absolute',
@@ -39,7 +39,7 @@ const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#FFD700" />
</svg>
<div style={{ position: 'absolute', top: '45%', left: '43%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', transition: 'opacity 0.3s ease', opacity: starHovered ? 0 : 1 }}>
{translatedContent.showUsPhoto}
{translatedContent.outdoorSeason}
</div>
<div style={{ position: 'absolute', top: '45%', left: '43%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', opacity: starHovered ? 1 : 0, transition: 'opacity 0.3s ease' }}>
{translatedContent.selectSeedRate}
@@ -71,7 +71,7 @@ const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#AFEEEE" />
</svg>
<div style={{ position: 'absolute', top: '42%', left: '45%', transform: 'translate(-50%, -50%) rotate(10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px' }}>
{translatedContent.indoorSeason}
{translatedContent.showUsPhoto}
</div>
</Box>
)}
@@ -116,7 +116,7 @@ const MainPageLayout = () => {
const translatedContent = {
showUsPhoto: t('sections.showUsPhoto'),
selectSeedRate: t('sections.selectSeedRate'),
indoorSeason: t('sections.indoorSeason')
outdoorSeason: t('sections.outdoorSeason')
};
const isHome = currentPath === "/";

View File

@@ -0,0 +1,189 @@
import React from 'react';
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { withTranslation } from 'react-i18next';
import { withLanguage } from '../i18n/withTranslation.js';
const ITEM_WIDTH = 140 + 16; // 140px width + 16px gap
const AUTO_SCROLL_SPEED = 1.0;
class ManufacturerCarousel extends React.Component {
_isMounted = false;
originalItems = [];
animationFrame = null;
translateX = 0;
constructor(props) {
super(props);
this.state = {
items: [], // [{ id, name, src }]
};
this.carouselTrackRef = React.createRef();
}
componentDidMount() {
this._isMounted = true;
this.loadImages();
}
componentWillUnmount() {
this._isMounted = false;
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
// Revoke object URLs to avoid memory leaks
for (const item of this.originalItems) {
if (item.src) URL.revokeObjectURL(item.src);
}
}
loadImages = () => {
window.socketManager.emit('getHerstellerImages', {}, (res) => {
if (!this._isMounted || !res?.success || !res.manufacturers?.length) return;
const items = res.manufacturers
.filter(m => m.imageBuffer)
.map(m => {
const blob = new Blob([m.imageBuffer], { type: 'image/avif' });
return { id: m.id, name: m.name || '', src: URL.createObjectURL(blob) };
})
.sort(() => Math.random() - 0.5);
if (items.length === 0) return;
this.originalItems = items;
this.setState({ items: [...items, ...items] }, () => {
this.startAutoScroll();
});
});
};
startAutoScroll = () => {
if (!this.animationFrame) {
this.animationFrame = requestAnimationFrame(this.tick);
}
};
tick = () => {
if (!this._isMounted || this.originalItems.length === 0) return;
this.translateX -= AUTO_SCROLL_SPEED;
const maxScroll = ITEM_WIDTH * this.originalItems.length;
if (Math.abs(this.translateX) >= maxScroll) {
this.translateX = 0;
}
if (this.carouselTrackRef.current) {
this.carouselTrackRef.current.style.transform = `translateX(${this.translateX}px)`;
}
this.animationFrame = requestAnimationFrame(this.tick);
};
render() {
const { t } = this.props;
const { items } = this.state;
if (!items || items.length === 0) return null;
return (
<Box sx={{ mt: 4, mb: 4 }}>
<Typography
variant="h4"
component="div"
sx={{
fontFamily: 'SwashingtonCP',
textShadow: '3px 3px 10px rgba(0, 0, 0, 0.4)',
textAlign: 'center',
mb: 2,
color: 'primary.main',
}}
>
{t('product.manufacturer')}
</Typography>
<div
style={{
position: 'relative',
overflow: 'hidden',
width: '100%',
maxWidth: '1200px',
margin: '0 auto',
padding: '0 20px',
boxSizing: 'border-box',
}}
>
{/* Fade edges */}
<div style={{
position: 'absolute', top: 0, left: 0,
width: '60px', height: '100%',
background: 'linear-gradient(to right, #c8e6c9, transparent)',
zIndex: 2, pointerEvents: 'none',
}} />
<div style={{
position: 'absolute', top: 0, right: 0,
width: '60px', height: '100%',
background: 'linear-gradient(to left, #c8e6c9, transparent)',
zIndex: 2, pointerEvents: 'none',
}} />
<div
style={{
position: 'relative',
overflow: 'hidden',
padding: '12px 0',
width: '100%',
maxWidth: '1080px',
margin: '0 auto',
boxSizing: 'border-box',
}}
>
<div
ref={this.carouselTrackRef}
style={{
display: 'flex',
gap: '16px',
alignItems: 'center',
width: 'fit-content',
transform: 'translateX(0px)',
}}
>
{items.map((item, index) => (
<div
key={`${item.id}-${index}`}
style={{
flex: '0 0 140px',
width: '140px',
height: '140px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
userSelect: 'none',
pointerEvents: 'none',
}}
>
<img
src={item.src}
alt={item.name}
draggable={false}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
display: 'block',
}}
/>
</div>
))}
</div>
</div>
</div>
</Box>
);
}
}
export default withTranslation()(withLanguage(ManufacturerCarousel));

View File

@@ -78,51 +78,150 @@ class Product extends Component {
window.smallPicCache = {};
}
if(this.props.pictureList && this.props.pictureList.length > 0 && this.props.pictureList.split(',').length > 0) {
const bildId = this.props.pictureList.split(',')[0];
if(window.smallPicCache[bildId]){
this.state = {image:window.smallPicCache[bildId],loading:false, error: false}
}else{
this.state = {image: null, loading: true, error: false};
const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
: [];
this.loadImage(bildId);
if (pictureIds.length > 0) {
const initialImages = pictureIds.map(id => window.smallPicCache[id] || null);
const isFirstCached = !!initialImages[0];
this.state = {
images: initialImages,
currentImageIndex: 0,
loading: !isFirstCached,
error: false,
isHovering: false
};
pictureIds.forEach((id, index) => {
if (!window.smallPicCache[id]) {
this.loadImage(id, index);
}
}else{
this.state = {image: null, loading: false, error: false};
});
} else {
this.state = { images: [], currentImageIndex: 0, loading: false, error: false, isHovering: false };
}
}
componentDidMount() {
this._isMounted = true;
this.startRandomFading();
}
loadImage = (bildId) => {
startRandomFading = () => {
if (this.state.isHovering) return;
const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
: [];
if (pictureIds.length > 1) {
const minInterval = 4000;
const maxInterval = 8000;
const randomInterval = Math.floor(Math.random() * (maxInterval - minInterval + 1)) + minInterval;
this.fadeTimeout = setTimeout(() => {
if (this._isMounted) {
this.setState(prevState => {
let nextIndex = (prevState.currentImageIndex + 1) % pictureIds.length;
let attempts = 0;
while (!prevState.images[nextIndex] && attempts < pictureIds.length) {
nextIndex = (nextIndex + 1) % pictureIds.length;
attempts++;
}
if (attempts < pictureIds.length && nextIndex !== prevState.currentImageIndex) {
return { currentImageIndex: nextIndex };
}
return null;
}, () => {
this.startRandomFading();
});
}
}, randomInterval);
}
}
handleMouseMove = (e) => {
const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
: [];
if (pictureIds.length > 1) {
if (this.fadeTimeout) {
clearTimeout(this.fadeTimeout);
this.fadeTimeout = null;
}
const { left, width } = e.currentTarget.getBoundingClientRect();
const x = e.clientX - left;
const segmentWidth = width / pictureIds.length;
let targetIndex = Math.floor(x / segmentWidth);
if (targetIndex >= pictureIds.length) targetIndex = pictureIds.length - 1;
if (targetIndex < 0) targetIndex = 0;
if (this.state.currentImageIndex !== targetIndex) {
if (this.state.images[targetIndex]) {
this.setState({ currentImageIndex: targetIndex, isHovering: true });
} else {
this.setState({ isHovering: true });
}
} else if (!this.state.isHovering) {
this.setState({ isHovering: true });
}
}
}
handleMouseLeave = () => {
if (this.state.isHovering) {
this.setState({ isHovering: false }, () => {
this.startRandomFading();
});
}
}
loadImage = (bildId, index) => {
console.log('loadImagevisSocket', bildId);
window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => {
if(res.success){
window.socketManager.emit('getPic', { bildId, size: 'small' }, (res) => {
if (res.success) {
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
if (this._isMounted) {
this.setState({image: window.smallPicCache[bildId], loading: false});
this.setState(prevState => {
const newImages = [...prevState.images];
newImages[index] = window.smallPicCache[bildId];
return {
images: newImages,
loading: index === 0 ? false : prevState.loading
};
});
} else {
this.state.image = window.smallPicCache[bildId];
this.state.loading = false;
this.state.images[index] = window.smallPicCache[bildId];
if (index === 0) this.state.loading = false;
}
}else{
} else {
console.log('Fehler beim Laden des Bildes:', res);
if (this._isMounted) {
this.setState({error: true, loading: false});
this.setState(prevState => ({
error: index === 0 ? true : prevState.error,
loading: index === 0 ? false : prevState.loading
}));
} else {
if (index === 0) {
this.state.error = true;
this.state.loading = false;
}
}
}
});
}
componentWillUnmount() {
this._isMounted = false;
if (this.fadeTimeout) {
clearTimeout(this.fadeTimeout);
}
}
handleQuantityChange = (quantity) => {
@@ -151,7 +250,7 @@ class Product extends Component {
const {
id, name, price, available, manufacturer, seoName,
currency, vat, cGrundEinheit, fGrundPreis, thc,
floweringWeeks,incoming, neu, weight, versandklasse, availableSupplier, komponenten
floweringWeeks, incoming, neu, weight, versandklasse, availableSupplier, komponenten
} = this.props;
const isNew = neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
@@ -338,7 +437,10 @@ class Product extends Component {
cursor: 'pointer'
}}
>
<Box sx={{
<Box
onMouseMove={this.handleMouseMove}
onMouseLeave={this.handleMouseLeave}
sx={{
position: 'relative',
height: { xs: '240px', sm: '180px' },
display: 'flex',
@@ -350,11 +452,43 @@ class Product extends Component {
}}>
{this.state.loading ? (
<CircularProgress sx={{ color: '#90ffc0' }} />
) : this.state.image === null ? (
) : this.state.images && this.state.images.length > 0 && this.state.images.some(img => img !== null) ? (
this.state.images.map((imgSrc, index) => {
if (!imgSrc) return null;
return (
<CardMedia
key={index}
component="img"
height={window.innerWidth < 600 ? "240" : "180"}
image={imgSrc}
alt={name}
fetchPriority={this.props.priority === 'high' && index === 0 ? 'high' : 'auto'}
loading={this.props.priority === 'high' && index === 0 ? 'eager' : 'lazy'}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = name || 'Produktbild';
}
}}
sx={{
objectFit: 'contain',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0,
opacity: this.state.currentImageIndex === index ? 1 : 0,
transition: this.state.isHovering ? 'opacity 0.2s ease-in-out' : 'opacity 1s ease-in-out'
}}
/>
);
})
) : (
<CardMedia
component="img"
height={ window.innerWidth < 600 ? "240" : "180" }
height={window.innerWidth < 600 ? "240" : "180"}
image="/assets/images/nopicture.jpg"
alt={name}
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}
@@ -369,28 +503,11 @@ class Product extends Component {
objectFit: 'contain',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
width: '100%'
}}
/>
) : (
<CardMedia
component="img"
height={ window.innerWidth < 600 ? "240" : "180" }
image={this.state.image}
alt={name}
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}
loading={this.props.priority === 'high' ? 'eager' : 'lazy'}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = name || 'Produktbild';
}
}}
sx={{
objectFit: 'contain',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
width: '100%'
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0
}}
/>
)}
@@ -422,12 +539,12 @@ class Product extends Component {
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Typography variant="body2" color="text.secondary" style={{minHeight:'1.5em'}}>
<Typography variant="body2" color="text.secondary" style={{ minHeight: '1.5em' }}>
{manufacturer || ''}
</Typography>
</Box>
<div style={{padding:'0px',margin:'0px'}}>
<div style={{ padding: '0px', margin: '0px' }}>
<Typography
variant="h6"
color="primary"
@@ -458,19 +575,19 @@ class Product extends Component {
{(() => {
const rebatePct = this.props.rebate / 100;
const originalPrice = Math.round((price / (1 - rebatePct)) * 10) / 10;
return new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(originalPrice);
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency || 'EUR' }).format(originalPrice);
})()}
</span>
)}
<span style={{ position: 'relative', zIndex: 2 }}>{new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price)}</span>
<span style={{ position: 'relative', zIndex: 2 }}>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency || 'EUR' }).format(price)}</span>
</Box>
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>({this.props.t ? this.props.t('product.inclVatFooter', { vat }) : `incl. ${vat}% USt.,*`})</small>
</Typography>
</div>
<div style={{ minHeight: '1.5em' }}>
{cGrundEinheit && fGrundPreis && fGrundPreis != price && (<Typography variant="body2" color="text.secondary" sx={{ m: 0,p: 0 }}>
({new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(fGrundPreis)}/{cGrundEinheit})
</Typography> )}
{cGrundEinheit && fGrundPreis && fGrundPreis != price && (<Typography variant="body2" color="text.secondary" sx={{ m: 0, p: 0 }}>
({new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency || 'EUR' }).format(fGrundPreis)}/{cGrundEinheit})
</Typography>)}
</div>
{/*incoming*/}
</CardContent>
@@ -486,7 +603,7 @@ class Product extends Component {
>
<ZoomInIcon />
</IconButton>
<AddToCartButton cartButton={true} availableSupplier={availableSupplier} komponenten={komponenten} cGrundEinheit={cGrundEinheit} fGrundPreis={fGrundPreis} available={available} incoming={incoming} seoName={seoName} pictureList={this.props.pictureList} id={id} price={price} vat={vat} weight={weight} name={name} versandklasse={versandklasse}/>
<AddToCartButton cartButton={true} availableSupplier={availableSupplier} komponenten={komponenten} cGrundEinheit={cGrundEinheit} fGrundPreis={fGrundPreis} available={available} incoming={incoming} seoName={seoName} pictureList={this.props.pictureList} id={id} price={price} vat={vat} weight={weight} name={name} versandklasse={versandklasse} />
</Box>
</Card>
</Box>

View File

@@ -8,8 +8,7 @@ import EmailIcon from "@mui/icons-material/Email";
import LinkIcon from "@mui/icons-material/Link";
import CodeIcon from "@mui/icons-material/Code";
import { Link } from "react-router-dom";
import parse from "html-react-parser";
import sanitizeHtml from "sanitize-html";
import LazySanitizedHtml from "../utils/LazySanitizedHtml.js";
import AddToCartButton from "./AddToCartButton.js";
import ProductImage from "./ProductImage.js";
import Product from "./Product.js";
@@ -1624,10 +1623,10 @@ class ProductDetailPage extends Component {
"& strong": { fontWeight: 600 },
}}
>
{product.description ? (() => {
try {
// Sanitize HTML to remove invalid tags, but preserve style attributes and <product> tags
const sanitized = sanitizeHtml(product.description, {
{product.description ? (
<LazySanitizedHtml
html={product.description}
sanitizeOptions={(sanitizeHtml) => ({
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'product']),
allowedAttributes: {
'*': ['class', 'style'],
@@ -1636,26 +1635,20 @@ class ProductDetailPage extends Component {
'product': ['articlenr']
},
disallowedTagsMode: 'discard'
});
// Parse with custom replace function to handle <product> tags
return parse(sanitized, {
})}
parseOptions={{
replace: (domNode) => {
if (domNode.type === 'tag' && domNode.name === 'product') {
const articleNr = domNode.attribs && domNode.attribs['articlenr'];
if (articleNr) {
// Render embedded product component
return this.renderEmbeddedProduct(articleNr);
}
}
}
});
} catch (error) {
console.warn('Failed to parse product description HTML:', error);
// Fallback to rendering as plain text if HTML parsing fails
return <span>{product.description}</span>;
}
})() : upgrading ? (
}}
fallback={<span>{product.description}</span>}
/>
) : upgrading ? (
<Box sx={{ textAlign: "center", py: 2 }}>
<Typography variant="body1" color="text.secondary">
{this.props.t ? this.props.t('product.loadingDescription') : 'Produktbeschreibung wird geladen...'}

View File

@@ -36,17 +36,39 @@ class ProductFilters extends Component {
this.state = {
availabilityValues,
uniqueManufacturerArray,
attributeGroups
attributeGroups,
manufacturerImages: new Map(), // id (number) → object URL
};
this._manufacturerImageUrls = []; // track for cleanup
}
componentDidMount() {
// Measure the available space dynamically
this.adjustPaperHeight();
// Add event listener for window resize
window.addEventListener('resize', this.adjustPaperHeight);
this._loadManufacturerImages();
}
componentWillUnmount() {
window.removeEventListener('resize', this.adjustPaperHeight);
this._manufacturerImageUrls.forEach(url => URL.revokeObjectURL(url));
}
_loadManufacturerImages = () => {
window.socketManager.emit('getHerstellerImages', {}, (res) => {
if (!res?.success || !res.manufacturers?.length) return;
const map = new Map();
for (const m of res.manufacturers) {
if (!m.imageBuffer) continue;
const blob = new Blob([m.imageBuffer], { type: 'image/avif' });
const url = URL.createObjectURL(blob);
map.set(m.id, url);
this._manufacturerImageUrls.push(url);
}
this.setState({ manufacturerImages: map });
});
};
componentDidUpdate(prevProps) {
// Regenerate values when products, attributes, or language changes
const productsChanged = this.props.products !== prevProps.products;
@@ -82,10 +104,6 @@ class ProductFilters extends Component {
}
}
componentWillUnmount() {
// Remove event listener when component unmounts
window.removeEventListener('resize', this.adjustPaperHeight);
}
adjustPaperHeight = () => {
// Skip height adjustment on xs screens
@@ -265,6 +283,7 @@ class ProductFilters extends Component {
products={this.props.products}
filteredProducts={this.props.filteredProducts}
attributes={this.props.attributes}
manufacturerImages={this.state.manufacturerImages}
onFilterChange={(msg)=>{
if(msg.value) {
setSessionSetting(`filter_${msg.type}_${msg.name}`, 'true');

View File

@@ -7,6 +7,7 @@ import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight";
import CategoryBox from "./CategoryBox.js";
import ProductCarousel from "./ProductCarousel.js";
import ManufacturerCarousel from "./ManufacturerCarousel.js";
import { withTranslation } from 'react-i18next';
import { withLanguage } from '../i18n/withTranslation.js';
@@ -419,6 +420,9 @@ class SharedCarousel extends React.Component {
{/* Product Carousel for "neu" category */}
<ProductCarousel categoryId="neu" />
{/* Manufacturer logo carousel */}
<ManufacturerCarousel />
</Box>
);
}

View File

@@ -8,6 +8,8 @@ import CheckoutValidation from "./CheckoutValidation.js";
import { withI18n } from "../../i18n/index.js";
class CartTab extends Component {
normalizeDeliveryMethod = (deliveryMethod) => (deliveryMethod === "DPD" ? "DHL" : deliveryMethod);
constructor(props) {
super(props);
@@ -107,10 +109,12 @@ class CartTab extends Component {
// Map delivery method values if needed
const deliveryMethodMap = {
"standard": "DHL",
"express": "DPD",
"express": "DHL",
"pickup": "Abholung"
};
prefillDeliveryMethod = deliveryMethodMap[prefillDeliveryMethod] || prefillDeliveryMethod;
prefillDeliveryMethod = this.normalizeDeliveryMethod(
deliveryMethodMap[prefillDeliveryMethod] || prefillDeliveryMethod
);
// Determine payment method - respect constraints
let prefillPaymentMethod = template.payment_method || "wire";
@@ -168,7 +172,9 @@ class CartTab extends Component {
const cartItems = Array.isArray(window.cart) ? window.cart : [];
const shouldForcePickup = CheckoutValidation.shouldForcePickupDelivery(cartItems);
const newDeliveryMethod = shouldForcePickup ? "Abholung" : this.state.deliveryMethod;
const newDeliveryMethod = shouldForcePickup
? "Abholung"
: this.normalizeDeliveryMethod(this.state.deliveryMethod);
const deliveryCost = this.orderService.getDeliveryCost();
// Get optimal payment method for the current state
@@ -215,7 +221,7 @@ class CartTab extends Component {
};
handleDeliveryMethodChange = (event) => {
const newDeliveryMethod = event.target.value;
const newDeliveryMethod = this.normalizeDeliveryMethod(event.target.value);
const deliveryCost = this.orderService.getDeliveryCost();
// Get optimal payment method for the new delivery method

View File

@@ -96,7 +96,7 @@ class CheckoutForm extends Component {
cartItems={cartItems}
/>
{(deliveryMethod === "DHL" || deliveryMethod === "DPD") && (
{deliveryMethod === "DHL" && (
<>
<FormControlLabel
control={

View File

@@ -20,14 +20,6 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartIt
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dhl') : '5,90 €'),
disabled: isPickupOnly
},
{
id: 'DPD',
name: 'DPD',
description: isPickupOnly ? (t ? t('delivery.descriptions.notAvailable') : "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können") :
isFreeShipping ? (t ? t('delivery.descriptions.standardFree') : 'Standardversand - KOSTENLOS ab 100€ Warenwert!') : (t ? t('delivery.descriptions.standard') : 'Standardversand'),
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dpd') : '4,90 €'),
disabled: isPickupOnly
},
{
id: 'Sperrgut',
name: t ? t('delivery.methods.sperrgutName') : 'Sperrgut',

View File

@@ -6,7 +6,7 @@ export default {
"thcTest": "اختبار THC",
"address1": "شارع تراشينبرجر 14",
"address2": "01129 دريسدن",
"showUsPhoto": "اعرض لنا أجمل صورة لديك",
"selectSeedRate": "اختر البذرة، واضغط للتقييم",
"indoorSeason": "بدأ موسم الزراعة الداخلية"
"showUsPhoto": "ورّينا أجمل صورة عندك",
"selectSeedRate": "اختار البذرة، واضغط تقييم",
"outdoorSeason": "موسم الزراعة في الهواء الطلق بدأ"
};

View File

@@ -2,11 +2,11 @@ export default {
"seeds": "Семена",
"stecklinge": "Резници",
"konfigurator": "Конфигуратор",
"oilPress": "Наеми преса за олио",
"oilPress": "Наеми преса за масло",
"thcTest": "THC тест",
"address1": "Trachenberger Straße 14",
"address2": "01129 Dresden",
"showUsPhoto": "Покажи ни най-красивата си снимка",
"selectSeedRate": "Избери семе, кликни за оценка",
"indoorSeason": "Вътрешният сезон започва"
"outdoorSeason": "Започва откритият сезон"
};

View File

@@ -8,5 +8,5 @@ export default {
"address2": "01129 Dresden",
"showUsPhoto": "Ukaž nám svou nejkrásnější fotku",
"selectSeedRate": "Vyber semeno, klikni na hodnocení",
"indoorSeason": "Začíná indoor sezóna"
"outdoorSeason": "Venkovní sezóna začíná"
};

View File

@@ -8,5 +8,5 @@ export default {
"address2": "01129 Dresden",
"showUsPhoto": "Zeig uns dein schönstes Foto",
"selectSeedRate": "Wähle Seed aus, klicke Bewerten",
"indoorSeason": "Die Indoorsaison beginnt"
"outdoorSeason": "Die Outdoorsaison beginnt"
};

View File

@@ -8,5 +8,5 @@ export default {
"address2": "01129 Dresden",
"showUsPhoto": "Δείξε μας την πιο όμορφη φωτογραφία σου",
"selectSeedRate": "Επίλεξε σπόρο, κάνε κλικ για αξιολόγηση",
"indoorSeason": "Η εσωτερική σεζόν ξεκινά"
"outdoorSeason": "Η εξωτερική σεζόν ξεκινά"
};

View File

@@ -8,5 +8,5 @@ export default {
"address2": "01129 Dresden", // 01129 Dresden
"showUsPhoto": "Show us your most beautiful photo", // Zeig uns dein schönstes Foto
"selectSeedRate": "Select seed, click rate", // Wähle Seed aus, klicke Bewerten
"indoorSeason": "The indoor season begins" // Die Indoorsaison beginnt
"outdoorSeason": "The outdoor season begins" // Die Outdoorsaison beginnt
};

View File

@@ -7,6 +7,6 @@ export default {
"address1": "Trachenberger Straße 14",
"address2": "01129 Dresden",
"showUsPhoto": "Muéstranos tu foto más hermosa",
"selectSeedRate": "Selecciona semilla, haz clic en valorar",
"indoorSeason": "Comienza la temporada de interior"
"selectSeedRate": "Selecciona semilla, haz clic para valorar",
"outdoorSeason": "Comienza la temporada al aire libre"
};

View File

@@ -8,5 +8,5 @@ export default {
"address2": "01129 Dresden",
"showUsPhoto": "Montre-nous ta plus belle photo",
"selectSeedRate": "Sélectionne une graine, clique pour noter",
"indoorSeason": "La saison indoor commence"
"outdoorSeason": "La saison en extérieur commence"
};

View File

@@ -8,5 +8,5 @@ export default {
"address2": "01129 Dresden",
"showUsPhoto": "Pokaži nam svoju najljepšu fotografiju",
"selectSeedRate": "Odaberi sjeme, klikni ocijeni",
"indoorSeason": "Počinje sezona uzgoja u zatvorenom"
"outdoorSeason": "Sezona na otvorenom počinje"
};

View File

@@ -8,5 +8,5 @@ export default {
"address2": "01129 Dresden",
"showUsPhoto": "Mutasd meg nekünk a legszebb fotódat",
"selectSeedRate": "Válassz magot, kattints az értékelésre",
"indoorSeason": "Kezdődik a beltéri szezon"
"outdoorSeason": "Kezdődik a szabadtéri szezon"
};

View File

@@ -7,6 +7,6 @@ export default {
"address1": "Trachenberger Straße 14",
"address2": "01129 Dresden",
"showUsPhoto": "Mostraci la tua foto più bella",
"selectSeedRate": "Seleziona il seme, clicca per valutare",
"indoorSeason": "La stagione indoor inizia"
"selectSeedRate": "Seleziona il seme, clicca valuta",
"outdoorSeason": "La stagione all'aperto inizia"
};

View File

@@ -8,6 +8,6 @@ export default {
"address2": "01129 Dresden",
"showUsPhoto": "Pokaż nam swoje najpiękniejsze zdjęcie",
"selectSeedRate": "Wybierz nasiono, kliknij ocenę",
"indoorSeason": "Sezon indoorowy się zaczyna",
"outdoorSeason": "Sezon na uprawę na zewnątrz się zaczyna",
"locale": "pl"
};

View File

@@ -8,5 +8,5 @@ export default {
"address2": "01129 Dresden",
"showUsPhoto": "Arată-ne cea mai frumoasă fotografie a ta",
"selectSeedRate": "Selectează sămânța, apasă evaluează",
"indoorSeason": "Sezonul indoor începe"
"outdoorSeason": "Sezonul în aer liber începe"
};

View File

@@ -8,5 +8,5 @@ export default {
"address2": "01129 Dresden",
"showUsPhoto": "Покажи нам свою самую красивую фотографию",
"selectSeedRate": "Выберите семена, нажмите оценить",
"indoorSeason": "Начинается сезон для выращивания в помещении"
"outdoorSeason": "Начинается сезон для выращивания на улице"
};

View File

@@ -2,11 +2,11 @@ export default {
"seeds": "Semienka",
"stecklinge": "Rezky",
"konfigurator": "Konfigurátor",
"oilPress": "Požičajte si lis na olej",
"oilPress": "Požičať lis na olej",
"thcTest": "THC test",
"address1": "Trachenberger Straße 14",
"address2": "01129 Dresden",
"showUsPhoto": "Ukážte nám svoju najkrajšiu fotku",
"selectSeedRate": "Vyberte semienko, kliknite na hodnotenie",
"indoorSeason": "Začína sezóna pestovania v interiéri"
"showUsPhoto": "Ukáž nám svoju najkrajšiu fotku",
"selectSeedRate": "Vyber semienko, klikni na hodnotenie",
"outdoorSeason": "Začína vonkajšia sezóna"
};

View File

@@ -2,11 +2,11 @@ export default {
"seeds": "Semena",
"stecklinge": "Rezalci",
"konfigurator": "Konfigurator",
"oilPress": "Izposodi si stiskalnico olja",
"oilPress": "Izposodi si stiskalnico za olje",
"thcTest": "THC test",
"address1": "Trachenberger Straße 14",
"address2": "01129 Dresden",
"showUsPhoto": "Pokaži nam svojo najlepšo fotografijo",
"selectSeedRate": "Izberi seme, klikni oceni",
"indoorSeason": "Začne se sezona gojenja v zaprtih prostorih"
"outdoorSeason": "Sezona na prostem se začenja"
};

View File

@@ -1,12 +1,12 @@
export default {
"seeds": "Farëra",
"stecklinge": "Përkëmbëza",
"konfigurator": "Konfiguruesi",
"konfigurator": "Konfigurues",
"oilPress": "Huazo shtypësin e vajit",
"thcTest": "Testi THC",
"address1": "Trachenberger Straße 14",
"address2": "01129 Dresden",
"showUsPhoto": "Na trego foton tënde më të bukur",
"selectSeedRate": "Zgjidh farën, kliko vlerësimin",
"indoorSeason": "Sezona e brendshme fillon"
"outdoorSeason": "Sezona në natyrë fillon"
};

View File

@@ -8,5 +8,5 @@ export default {
"address2": "01129 Dresden",
"showUsPhoto": "Pokaži nam svoju najlepšu fotografiju",
"selectSeedRate": "Izaberi seme, klikni oceni",
"indoorSeason": "Počinje sezona za uzgoj u zatvorenom"
"outdoorSeason": "Počinje sezona na otvorenom"
};

View File

@@ -8,5 +8,5 @@ export default {
"address2": "01129 Dresden",
"showUsPhoto": "Visa oss din vackraste bild",
"selectSeedRate": "Välj frö, klicka betyg",
"indoorSeason": "Inomhussäsongen börjar"
"outdoorSeason": "Utomhussäsongen börjar"
};

View File

@@ -2,11 +2,11 @@ export default {
"seeds": "Tohumlar",
"stecklinge": "Çelikler",
"konfigurator": "Konfigüratör",
"oilPress": "Yağ presini ödünç al",
"oilPress": "Yağ presi ödünç al",
"thcTest": "THC testi",
"address1": "Trachenberger Straße 14",
"address2": "01129 Dresden",
"showUsPhoto": "En güzel fotoğrafını göster",
"selectSeedRate": "Tohumu seç, oy ver",
"indoorSeason": "Kapalı sezon başlıyor"
"outdoorSeason": "ık hava sezonu başlıyor"
};

View File

@@ -8,5 +8,5 @@ export default {
"address2": "01129 Dresden",
"showUsPhoto": "Покажи нам своє найкрасивіше фото",
"selectSeedRate": "Виберіть насіння, натисніть оцінити",
"indoorSeason": "Починається сезон для вирощування в приміщенні"
"outdoorSeason": "Починається сезон на відкритому повітрі"
};

View File

@@ -8,5 +8,5 @@ export default {
"address2": "01129 Dresden",
"showUsPhoto": "展示你最美的照片",
"selectSeedRate": "选择种子,点击评分",
"indoorSeason": "室内季节开始了"
"outdoorSeason": "户外季节开始了"
};

View File

@@ -1,43 +1,154 @@
import { io } from 'socket.io-client';
class SocketManager {
constructor() {
this.socket = io('', {
transports: ["websocket", "polling"],
autoConnect: false
});
this._socket = null;
this._socketReady = null;
// Listeners registered before socket.io-client has loaded
this._preSocketListeners = [];
this.emit = this.emit.bind(this);
this.on = this.on.bind(this);
this.off = this.off.bind(this);
this.connectPromise = null;
this.reauthPromise = null;
this.pendingListeners = new Map();
}
on(event, callback) {
// If socket is already connected, register the listener directly
if (this.socket.connected) {
this.socket.on(event, callback);
return;
_getStoredUser() {
const storedUser = sessionStorage.getItem('user');
if (!storedUser) {
return null;
}
// Store the listener to be registered when connection is established
try {
return JSON.parse(storedUser);
} catch (error) {
console.error('Failed to parse stored user for socket reauth:', error);
sessionStorage.removeItem('user');
return null;
}
}
_buildSocketAuth() {
const token = sessionStorage.getItem('authToken');
const user = this._getStoredUser();
if (!token && !user) {
return {};
}
// Provide both nested and flat shapes for backend compatibility.
return {
token,
user,
userId: user ? user.id : undefined,
email: user ? user.email : undefined
};
}
_reauthenticate() {
if (!this._socket || !this._socket.connected) {
return Promise.resolve();
}
const token = sessionStorage.getItem('authToken');
if (!token) {
return Promise.resolve();
}
if (this.reauthPromise) {
return this.reauthPromise;
}
this.reauthPromise = new Promise((resolve) => {
let settled = false;
const done = () => {
if (settled) return;
settled = true;
this.reauthPromise = null;
resolve();
};
// Don't block emits indefinitely if backend ignores this event.
const timeoutId = setTimeout(done, 800);
this._socket.emit('verifyToken', { token }, (response) => {
clearTimeout(timeoutId);
if (response && response.success && response.user) {
sessionStorage.setItem('user', JSON.stringify(response.user));
}
if (response && response.success && response.token) {
sessionStorage.setItem('authToken', response.token);
}
done();
});
});
return this.reauthPromise;
}
// Lazily import socket.io-client and create the socket on first use.
// Subsequent calls return the same promise.
_ensureSocket() {
if (this._socket) return Promise.resolve(this._socket);
if (this._socketReady) return this._socketReady;
this._socketReady = import('socket.io-client').then(({ io }) => {
this._socket = io('', {
transports: ['websocket', 'polling'],
autoConnect: false,
withCredentials: true,
auth: this._buildSocketAuth()
});
// Always refresh auth data before reconnect attempts.
if (this._socket.io && this._socket.io.on) {
this._socket.io.on('reconnect_attempt', () => {
this._socket.auth = this._buildSocketAuth();
});
}
// Re-authenticate every time a new socket connection is established.
this._socket.on('connect', () => {
this.reauthPromise = this._reauthenticate();
});
// Register any listeners that arrived before the socket was ready
this._preSocketListeners.forEach(({ event, callback }) => {
this._socket.on(event, callback);
});
this._preSocketListeners = [];
return this._socket;
});
return this._socketReady;
}
on(event, callback) {
if (this._socket) {
// Socket already loaded — mirror the original behaviour
if (!this.pendingListeners.has(event)) {
this.pendingListeners.set(event, new Set());
}
this.pendingListeners.get(event).add(callback);
// Register the listener now, it will receive events once connected
this.socket.on(event, callback);
this._socket.on(event, callback);
} else {
// Queue for when socket.io-client finishes loading
this._preSocketListeners.push({ event, callback });
if (!this.pendingListeners.has(event)) {
this.pendingListeners.set(event, new Set());
}
this.pendingListeners.get(event).add(callback);
}
}
off(event, callback) {
// Remove from socket listeners
console.log('off', event, callback);
this.socket.off(event, callback);
// Remove from pending listeners if present
// Remove from pre-socket queue (component unmounted before socket loaded)
this._preSocketListeners = this._preSocketListeners.filter(
(item) => !(item.event === event && item.callback === callback)
);
if (this.pendingListeners.has(event)) {
const listeners = this.pendingListeners.get(event);
listeners.delete(callback);
@@ -45,57 +156,71 @@ class SocketManager {
this.pendingListeners.delete(event);
}
}
if (this._socket) {
this._socket.off(event, callback);
}
}
connect() {
if (this.connectPromise) return this.connectPromise;
this.connectPromise = new Promise((resolve, reject) => {
this.socket.connect();
this.socket.once('connect', () => {
resolve();
this.connectPromise = this._ensureSocket().then(
(socket) =>
new Promise((resolve, reject) => {
socket.auth = this._buildSocketAuth();
socket.connect();
socket.once('connect', () => {
this._reauthenticate()
.then(() => resolve())
.catch(() => resolve());
});
this.socket.once('connect_error', (error) => {
socket.once('connect_error', (error) => {
this.connectPromise = null;
reject(error);
});
});
})
);
return this.connectPromise;
}
emit(event, ...args) {
return new Promise((resolve, reject) => {
if (!this.socket.connected) {
// If not already connecting, start connection
this._ensureSocket()
.then((socket) => {
if (!socket.connected) {
if (!this.connectPromise) {
this.connect();
}
// Wait for connection before emitting
this.connectPromise
.then(() => {
this.socket.emit(event, ...args);
socket.emit(event, ...args);
resolve();
})
.catch((error) => {
reject(error);
});
} else {
// Socket already connected, emit directly
this.socket.emit(event, ...args);
const emitNow = () => {
socket.emit(event, ...args);
resolve();
};
if (this.reauthPromise) {
this.reauthPromise.then(emitNow).catch(emitNow);
} else {
emitNow();
}
}
})
.catch(reject);
});
}
}
// Create singleton instance
// Create singleton instance and expose globally so all components can reach it
const socketManager = new SocketManager();
// Attach to window object
window.socketManager = socketManager;
export default socketManager;

View File

@@ -0,0 +1,52 @@
import React, { lazy, Suspense } from 'react';
// Load html-react-parser and sanitize-html in a single async chunk.
// Neither library ships in the main bundle — they are only fetched when a
// component that uses this wrapper actually renders.
const SanitizedHtmlContent = lazy(() =>
Promise.all([
import(/* webpackChunkName: "html-parser" */ 'html-react-parser'),
import(/* webpackChunkName: "html-parser" */ 'sanitize-html'),
]).then(([{ default: parse }, { default: sanitizeHtml }]) => ({
default: function SanitizedHtmlContent({ html, sanitizeOptions, parseOptions }) {
try {
// sanitizeOptions can be a plain object or a factory (fn) that receives
// the sanitizeHtml module so callers can reference sanitizeHtml.defaults.
const resolvedSanitizeOptions =
typeof sanitizeOptions === 'function'
? sanitizeOptions(sanitizeHtml)
: sanitizeOptions;
const sanitized = sanitizeHtml(html, resolvedSanitizeOptions);
return parse(sanitized, parseOptions);
} catch (error) {
console.warn('LazySanitizedHtml: failed to parse HTML', error);
return <span>{html}</span>;
}
},
}))
);
/**
* Renders sanitized and parsed HTML without including sanitize-html or
* html-react-parser in the initial JavaScript bundle.
*
* @param {string} html Raw HTML string to sanitize and render
* @param {object|function} sanitizeOptions sanitize-html options object, or a
* factory (sanitizeHtml) => options so
* callers can use sanitizeHtml.defaults
* @param {object} parseOptions html-react-parser options (e.g. replace)
* @param {React.ReactNode} fallback Shown while the libraries are loading
*/
export default function LazySanitizedHtml({ html, sanitizeOptions, parseOptions, fallback = null }) {
if (!html) return null;
return (
<Suspense fallback={fallback}>
<SanitizedHtmlContent
html={html}
sanitizeOptions={sanitizeOptions}
parseOptions={parseOptions}
/>
</Suspense>
);
}

View File

@@ -301,6 +301,14 @@ export default {
priority: 20,
reuseExistingChunk: true,
},
// socket.io-client and its dependencies — always async, never initial
socketio: {
test: /[\\/]node_modules[\\/](socket\.io-client|engine\.io-client|@socket\.io|socket\.io-parser|socket\.io-msgpack-parser)[\\/]/,
name: 'socketio',
priority: 15,
chunks: 'async',
reuseExistingChunk: true,
},
// Other vendor libraries
vendor: {
test: /[\\/]node_modules[\\/]/,