From 65a676de469f98b17c6818c3f0a020c12ab7fade Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Tue, 10 Mar 2026 11:27:15 +0100 Subject: [PATCH] feat: Implement multi-image product display with fading and hover effects, and introduce lazy-loaded HTML sanitization. --- src/App.js | 7 +- src/components/Product.js | 347 +++++++++++++++++++--------- src/components/ProductDetailPage.js | 29 +-- src/services/SocketManager.js | 165 +++++++------ src/utils/LazySanitizedHtml.js | 52 +++++ webpack.config.js | 8 + 6 files changed, 403 insertions(+), 205 deletions(-) create mode 100644 src/utils/LazySanitizedHtml.js diff --git a/src/App.js b/src/App.js index 68c96fc..1fb30d8 100644 --- a/src/App.js +++ b/src/App.js @@ -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")); diff --git a/src/components/Product.js b/src/components/Product.js index 3f1375c..745cfcd 100644 --- a/src/components/Product.js +++ b/src/components/Product.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 ( - @@ -191,9 +290,9 @@ class Product extends Component { }} > {/* Background star - slightly larger and rotated */} - - - + {/* Middle star - medium size with different rotation */} - - - + {/* Foreground star - main star with text */} - - - + {/* Text as a separate element to position it at the top */}
)} - - )} - + - + {this.state.loading ? ( - - ) : this.state.image === null ? ( - { - // 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 ( + { + // 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' + }} + /> + ); + }) ) : ( )} - - {name} - + - + {manufacturer || ''} - -
+ +
{ 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); })()} )} - {new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price)} + {new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency || 'EUR' }).format(price)} ({this.props.t ? this.props.t('product.inclVatFooter', { vat }) : `incl. ${vat}% USt.,*`})
- {cGrundEinheit && fGrundPreis && fGrundPreis != price && ( - ({new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(fGrundPreis)}/{cGrundEinheit}) - )} + {cGrundEinheit && fGrundPreis && fGrundPreis != price && ( + ({new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency || 'EUR' }).format(fGrundPreis)}/{cGrundEinheit}) + )}
- {/*incoming*/} + {/*incoming*/} - + - + diff --git a/src/components/ProductDetailPage.js b/src/components/ProductDetailPage.js index 4265e2c..102b3bb 100644 --- a/src/components/ProductDetailPage.js +++ b/src/components/ProductDetailPage.js @@ -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 tags - const sanitized = sanitizeHtml(product.description, { + {product.description ? ( + ({ 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 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 {product.description}; - } - })() : upgrading ? ( + }} + fallback={{product.description}} + /> + ) : upgrading ? ( {this.props.t ? this.props.t('product.loadingDescription') : 'Produktbeschreibung wird geladen...'} diff --git a/src/services/SocketManager.js b/src/services/SocketManager.js index 11624c4..ef64373 100644 --- a/src/services/SocketManager.js +++ b/src/services/SocketManager.js @@ -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; \ No newline at end of file +export default socketManager; diff --git a/src/utils/LazySanitizedHtml.js b/src/utils/LazySanitizedHtml.js new file mode 100644 index 0000000..096c6b5 --- /dev/null +++ b/src/utils/LazySanitizedHtml.js @@ -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 {html}; + } + }, + })) +); + +/** + * 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 ( + + + + ); +} diff --git a/webpack.config.js b/webpack.config.js index 8aaede4..4d574ee 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -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[\\/]/,