feat: Implement multi-image product display with fading and hover effects, and introduce lazy-loaded HTML sanitization.
This commit is contained in:
@@ -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"));
|
||||
|
||||
@@ -71,58 +71,157 @@ const findLevel1CategoryId = (categoryId) => {
|
||||
class Product extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
|
||||
this._isMounted = false;
|
||||
|
||||
|
||||
if (!window.smallPicCache) {
|
||||
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};
|
||||
|
||||
this.loadImage(bildId);
|
||||
}
|
||||
}else{
|
||||
this.state = {image: null, loading: false, error: false};
|
||||
const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
|
||||
? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
|
||||
: [];
|
||||
|
||||
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 = { images: [], currentImageIndex: 0, loading: false, error: false, isHovering: false };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
this.startRandomFading();
|
||||
}
|
||||
|
||||
loadImage = (bildId) => {
|
||||
|
||||
console.log('loadImagevisSocket', bildId);
|
||||
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});
|
||||
} else {
|
||||
this.state.image = window.smallPicCache[bildId];
|
||||
this.state.loading = false;
|
||||
}
|
||||
}else{
|
||||
console.log('Fehler beim Laden des Bildes:', res);
|
||||
if (this._isMounted) {
|
||||
this.setState({error: true, loading: false});
|
||||
} else {
|
||||
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.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
|
||||
if (this._isMounted) {
|
||||
this.setState(prevState => {
|
||||
const newImages = [...prevState.images];
|
||||
newImages[index] = window.smallPicCache[bildId];
|
||||
return {
|
||||
images: newImages,
|
||||
loading: index === 0 ? false : prevState.loading
|
||||
};
|
||||
});
|
||||
} else {
|
||||
this.state.images[index] = window.smallPicCache[bildId];
|
||||
if (index === 0) this.state.loading = false;
|
||||
}
|
||||
} else {
|
||||
console.log('Fehler beim Laden des Bildes:', res);
|
||||
if (this._isMounted) {
|
||||
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);
|
||||
@@ -171,10 +270,10 @@ class Product extends Component {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
position: 'relative',
|
||||
<Box sx={{
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
width: { xs: '100%', sm: 'auto' }
|
||||
}}>
|
||||
@@ -191,9 +290,9 @@ class Product extends Component {
|
||||
}}
|
||||
>
|
||||
{/* Background star - slightly larger and rotated */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="56"
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="56"
|
||||
height="56"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -202,17 +301,17 @@ class Product extends Component {
|
||||
transform: 'rotate(20deg)'
|
||||
}}
|
||||
>
|
||||
<polygon
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#20403a"
|
||||
fill="#20403a"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
{/* Middle star - medium size with different rotation */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="53"
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="53"
|
||||
height="53"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -221,26 +320,26 @@ class Product extends Component {
|
||||
transform: 'rotate(-25deg)'
|
||||
}}
|
||||
>
|
||||
<polygon
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#40736b"
|
||||
fill="#40736b"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
{/* Foreground star - main star with text */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="50"
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="50"
|
||||
height="50"
|
||||
>
|
||||
<polygon
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#609688"
|
||||
fill="#609688"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
{/* Text as a separate element to position it at the top */}
|
||||
<div
|
||||
style={{
|
||||
@@ -259,9 +358,9 @@ class Product extends Component {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card
|
||||
sx={{
|
||||
|
||||
<Card
|
||||
sx={{
|
||||
width: { xs: '100vw', sm: '250px' },
|
||||
minWidth: { xs: '100vw', sm: '250px' },
|
||||
height: '100%',
|
||||
@@ -325,7 +424,7 @@ class Product extends Component {
|
||||
{floweringWeeks} {this.props.t ? this.props.t('product.weeks') : 'Wochen'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<Box
|
||||
onClick={this.handleProductClick}
|
||||
sx={{
|
||||
@@ -338,45 +437,59 @@ class Product extends Component {
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
position: 'relative',
|
||||
height: { xs: '240px', sm: '180px' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#ffffff',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px'
|
||||
}}>
|
||||
<Box
|
||||
onMouseMove={this.handleMouseMove}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height: { xs: '240px', sm: '180px' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#ffffff',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px'
|
||||
}}>
|
||||
{this.state.loading ? (
|
||||
<CircularProgress sx={{ color: '#90ffc0' }} />
|
||||
|
||||
) : this.state.image === null ? (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height={ window.innerWidth < 600 ? "240" : "180" }
|
||||
image="/assets/images/nopicture.jpg"
|
||||
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%'
|
||||
}}
|
||||
/>
|
||||
) : 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" }
|
||||
image={this.state.image}
|
||||
height={window.innerWidth < 600 ? "240" : "180"}
|
||||
image="/assets/images/nopicture.jpg"
|
||||
alt={name}
|
||||
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}
|
||||
loading={this.props.priority === 'high' ? 'eager' : 'lazy'}
|
||||
@@ -386,20 +499,24 @@ class Product extends Component {
|
||||
e.target.alt = name || 'Produktbild';
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
borderTopLeftRadius: '8px',
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px',
|
||||
width: '100%'
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<CardContent sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
<CardContent sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'&.MuiCardContent-root:last-child': {
|
||||
paddingBottom: 0
|
||||
}
|
||||
@@ -420,14 +537,14 @@ class Product extends Component {
|
||||
>
|
||||
{name}
|
||||
</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,24 +575,24 @@ 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*/}
|
||||
{/*incoming*/}
|
||||
</CardContent>
|
||||
</Box>
|
||||
|
||||
|
||||
<Box sx={{ p: 2, pt: 0, display: 'flex', alignItems: 'center' }}>
|
||||
<IconButton
|
||||
component={Link}
|
||||
@@ -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>
|
||||
|
||||
@@ -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...'}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
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);
|
||||
@@ -14,30 +11,57 @@ class SocketManager {
|
||||
this.connectPromise = 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;
|
||||
}
|
||||
|
||||
// Store the listener to be registered when connection is established
|
||||
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);
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
// 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);
|
||||
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 +69,60 @@ 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.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
|
||||
if (!this.connectPromise) {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
// Wait for connection before emitting
|
||||
this.connectPromise
|
||||
.then(() => {
|
||||
this.socket.emit(event, ...args);
|
||||
|
||||
this.connectPromise = this._ensureSocket().then(
|
||||
(socket) =>
|
||||
new Promise((resolve, reject) => {
|
||||
socket.connect();
|
||||
socket.once('connect', () => {
|
||||
resolve();
|
||||
})
|
||||
.catch((error) => {
|
||||
});
|
||||
socket.once('connect_error', (error) => {
|
||||
this.connectPromise = null;
|
||||
reject(error);
|
||||
});
|
||||
} else {
|
||||
// Socket already connected, emit directly
|
||||
this.socket.emit(event, ...args);
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return this.connectPromise;
|
||||
}
|
||||
|
||||
emit(event, ...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._ensureSocket()
|
||||
.then((socket) => {
|
||||
if (!socket.connected) {
|
||||
if (!this.connectPromise) {
|
||||
this.connect();
|
||||
}
|
||||
this.connectPromise
|
||||
.then(() => {
|
||||
socket.emit(event, ...args);
|
||||
resolve();
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
} else {
|
||||
socket.emit(event, ...args);
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
.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;
|
||||
export default socketManager;
|
||||
|
||||
52
src/utils/LazySanitizedHtml.js
Normal file
52
src/utils/LazySanitizedHtml.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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[\\/]/,
|
||||
|
||||
Reference in New Issue
Block a user