Compare commits
6 Commits
fb6c1159fe
...
live
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a93aa22a76 | ||
|
|
f0e4a94dfc | ||
|
|
1e1a0c7320 | ||
|
|
80b066842d | ||
|
|
78bb99b418 | ||
|
|
65a676de46 |
@@ -34,11 +34,12 @@ import Header from "./components/Header.js";
|
|||||||
import Footer from "./components/Footer.js";
|
import Footer from "./components/Footer.js";
|
||||||
import MainPageLayout from "./components/MainPageLayout.js";
|
import MainPageLayout from "./components/MainPageLayout.js";
|
||||||
|
|
||||||
// TEMPORARILY DISABLE ALL LAZY LOADING TO ELIMINATE CircularProgress
|
|
||||||
import Content from "./components/Content.js";
|
import Content from "./components/Content.js";
|
||||||
import ProductDetail from "./components/ProductDetail.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
|
// Lazy load admin pages - only loaded when admin users access them
|
||||||
const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js"));
|
const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js"));
|
||||||
|
|||||||
@@ -296,6 +296,13 @@ class Filter extends Component {
|
|||||||
const event = { target: { name: option.id, checked: !options[option.id] } };
|
const event = { target: { name: option.id, checked: !options[option.id] } };
|
||||||
this.handleOptionChange(event);
|
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}
|
{option.name}
|
||||||
</td>
|
</td>
|
||||||
<td style={countCellStyle}>
|
<td style={countCellStyle}>
|
||||||
|
|||||||
@@ -345,16 +345,48 @@ class Footer extends Component {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Copyright Section */}
|
{/* 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 }}>
|
<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'}
|
{this.props.t ? this.props.t('footer.allPricesIncl') : '* Alle Preise inkl. gesetzlicher USt., zzgl. Versand'}
|
||||||
</Typography>
|
</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 }}>
|
<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>
|
© {new Date().getFullYear()} <StyledDomainLink href="https://growheads.de" target="_blank" rel="noopener noreferrer">GrowHeads.de</StyledDomainLink>
|
||||||
</Typography>
|
</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>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -28,6 +28,27 @@ import GoogleIcon from '@mui/icons-material/Google';
|
|||||||
// Lazy load GoogleAuthProvider
|
// Lazy load GoogleAuthProvider
|
||||||
const GoogleAuthProvider = lazy(() => import('../providers/GoogleAuthProvider.js'));
|
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
|
// Function to check if user is logged in
|
||||||
export const isUserLoggedIn = () => {
|
export const isUserLoggedIn = () => {
|
||||||
const storedUser = sessionStorage.getItem('user');
|
const storedUser = sessionStorage.getItem('user');
|
||||||
@@ -77,6 +98,7 @@ function cartsAreIdentical(cartA, cartB) {
|
|||||||
export class LoginComponent extends Component {
|
export class LoginComponent extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
const { isLoggedIn, user, isAdmin } = isUserLoggedIn();
|
||||||
this.state = {
|
this.state = {
|
||||||
open: false,
|
open: false,
|
||||||
tabValue: 0,
|
tabValue: 0,
|
||||||
@@ -86,9 +108,9 @@ export class LoginComponent extends Component {
|
|||||||
error: '',
|
error: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
success: '',
|
success: '',
|
||||||
isLoggedIn: false,
|
isLoggedIn,
|
||||||
isAdmin: false,
|
isAdmin,
|
||||||
user: null,
|
user,
|
||||||
anchorEl: null,
|
anchorEl: null,
|
||||||
showGoogleAuth: false,
|
showGoogleAuth: false,
|
||||||
cartSyncOpen: false,
|
cartSyncOpen: false,
|
||||||
@@ -102,16 +124,6 @@ export class LoginComponent extends Component {
|
|||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
// Make the open function available globally
|
// Make the open function available globally
|
||||||
window.openLoginDrawer = this.handleOpen;
|
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) {
|
if (this.props.open) {
|
||||||
this.setState({ open: true });
|
this.setState({ open: true });
|
||||||
@@ -190,7 +202,7 @@ export class LoginComponent extends Component {
|
|||||||
window.socketManager.emit('verifyUser', { email, password }, (response) => {
|
window.socketManager.emit('verifyUser', { email, password }, (response) => {
|
||||||
console.log('LoginComponent: verifyUser', response);
|
console.log('LoginComponent: verifyUser', response);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
sessionStorage.setItem('user', JSON.stringify(response.user));
|
persistSessionAuth(response);
|
||||||
this.setState({
|
this.setState({
|
||||||
user: response.user,
|
user: response.user,
|
||||||
isLoggedIn: true,
|
isLoggedIn: true,
|
||||||
@@ -306,6 +318,7 @@ export class LoginComponent extends Component {
|
|||||||
window.socketManager.emit('logout', (response) => {
|
window.socketManager.emit('logout', (response) => {
|
||||||
if(response.success){
|
if(response.success){
|
||||||
sessionStorage.removeItem('user');
|
sessionStorage.removeItem('user');
|
||||||
|
sessionStorage.removeItem('authToken');
|
||||||
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
||||||
this.props.navigate('/');
|
this.props.navigate('/');
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -362,7 +375,7 @@ export class LoginComponent extends Component {
|
|||||||
window.socketManager.emit('verifyGoogleUser', { credential: credentialResponse.credential }, (response) => {
|
window.socketManager.emit('verifyGoogleUser', { credential: credentialResponse.credential }, (response) => {
|
||||||
console.log('google respo',response);
|
console.log('google respo',response);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
sessionStorage.setItem('user', JSON.stringify(response.user));
|
persistSessionAuth(response);
|
||||||
this.setState({
|
this.setState({
|
||||||
isLoggedIn: true,
|
isLoggedIn: true,
|
||||||
isAdmin: !!response.user.admin,
|
isAdmin: !!response.user.admin,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity, translatedContent }) => (
|
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' }}>
|
<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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
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" />
|
<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>
|
</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 }}>
|
<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>
|
||||||
<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' }}>
|
<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}
|
{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" />
|
<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>
|
</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' }}>
|
<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>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -116,7 +116,7 @@ const MainPageLayout = () => {
|
|||||||
const translatedContent = {
|
const translatedContent = {
|
||||||
showUsPhoto: t('sections.showUsPhoto'),
|
showUsPhoto: t('sections.showUsPhoto'),
|
||||||
selectSeedRate: t('sections.selectSeedRate'),
|
selectSeedRate: t('sections.selectSeedRate'),
|
||||||
indoorSeason: t('sections.indoorSeason')
|
outdoorSeason: t('sections.outdoorSeason')
|
||||||
};
|
};
|
||||||
|
|
||||||
const isHome = currentPath === "/";
|
const isHome = currentPath === "/";
|
||||||
|
|||||||
189
src/components/ManufacturerCarousel.js
Normal file
189
src/components/ManufacturerCarousel.js
Normal 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));
|
||||||
@@ -71,58 +71,157 @@ const findLevel1CategoryId = (categoryId) => {
|
|||||||
class Product extends Component {
|
class Product extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this._isMounted = false;
|
this._isMounted = false;
|
||||||
|
|
||||||
if (!window.smallPicCache) {
|
if (!window.smallPicCache) {
|
||||||
window.smallPicCache = {};
|
window.smallPicCache = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
if(this.props.pictureList && this.props.pictureList.length > 0 && this.props.pictureList.split(',').length > 0) {
|
const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
|
||||||
const bildId = this.props.pictureList.split(',')[0];
|
? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
|
||||||
if(window.smallPicCache[bildId]){
|
: [];
|
||||||
this.state = {image:window.smallPicCache[bildId],loading:false, error: false}
|
|
||||||
}else{
|
if (pictureIds.length > 0) {
|
||||||
this.state = {image: null, loading: true, error: false};
|
const initialImages = pictureIds.map(id => window.smallPicCache[id] || null);
|
||||||
|
const isFirstCached = !!initialImages[0];
|
||||||
this.loadImage(bildId);
|
|
||||||
}
|
this.state = {
|
||||||
}else{
|
images: initialImages,
|
||||||
this.state = {image: null, loading: false, error: false};
|
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() {
|
componentDidMount() {
|
||||||
this._isMounted = true;
|
this._isMounted = true;
|
||||||
|
this.startRandomFading();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadImage = (bildId) => {
|
|
||||||
|
|
||||||
console.log('loadImagevisSocket', bildId);
|
startRandomFading = () => {
|
||||||
window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => {
|
if (this.state.isHovering) return;
|
||||||
if(res.success){
|
|
||||||
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
|
const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
|
||||||
if (this._isMounted) {
|
? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
|
||||||
this.setState({image: window.smallPicCache[bildId], loading: false});
|
: [];
|
||||||
} else {
|
|
||||||
this.state.image = window.smallPicCache[bildId];
|
if (pictureIds.length > 1) {
|
||||||
this.state.loading = false;
|
const minInterval = 4000;
|
||||||
}
|
const maxInterval = 8000;
|
||||||
}else{
|
const randomInterval = Math.floor(Math.random() * (maxInterval - minInterval + 1)) + minInterval;
|
||||||
console.log('Fehler beim Laden des Bildes:', res);
|
|
||||||
if (this._isMounted) {
|
this.fadeTimeout = setTimeout(() => {
|
||||||
this.setState({error: true, loading: false});
|
if (this._isMounted) {
|
||||||
} else {
|
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.error = true;
|
||||||
this.state.loading = false;
|
this.state.loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this._isMounted = false;
|
this._isMounted = false;
|
||||||
|
if (this.fadeTimeout) {
|
||||||
|
clearTimeout(this.fadeTimeout);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleQuantityChange = (quantity) => {
|
handleQuantityChange = (quantity) => {
|
||||||
@@ -151,7 +250,7 @@ class Product extends Component {
|
|||||||
const {
|
const {
|
||||||
id, name, price, available, manufacturer, seoName,
|
id, name, price, available, manufacturer, seoName,
|
||||||
currency, vat, cGrundEinheit, fGrundPreis, thc,
|
currency, vat, cGrundEinheit, fGrundPreis, thc,
|
||||||
floweringWeeks,incoming, neu, weight, versandklasse, availableSupplier, komponenten
|
floweringWeeks, incoming, neu, weight, versandklasse, availableSupplier, komponenten
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const isNew = neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
const isNew = neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||||
@@ -171,10 +270,10 @@ class Product extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
width: { xs: '100%', sm: 'auto' }
|
width: { xs: '100%', sm: 'auto' }
|
||||||
}}>
|
}}>
|
||||||
@@ -191,9 +290,9 @@ class Product extends Component {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Background star - slightly larger and rotated */}
|
{/* Background star - slightly larger and rotated */}
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 60 60"
|
viewBox="0 0 60 60"
|
||||||
width="56"
|
width="56"
|
||||||
height="56"
|
height="56"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -202,17 +301,17 @@ class Product extends Component {
|
|||||||
transform: 'rotate(20deg)'
|
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"
|
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"
|
stroke="none"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Middle star - medium size with different rotation */}
|
{/* Middle star - medium size with different rotation */}
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 60 60"
|
viewBox="0 0 60 60"
|
||||||
width="53"
|
width="53"
|
||||||
height="53"
|
height="53"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -221,26 +320,26 @@ class Product extends Component {
|
|||||||
transform: 'rotate(-25deg)'
|
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"
|
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"
|
stroke="none"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Foreground star - main star with text */}
|
{/* Foreground star - main star with text */}
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 60 60"
|
viewBox="0 0 60 60"
|
||||||
width="50"
|
width="50"
|
||||||
height="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"
|
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"
|
stroke="none"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Text as a separate element to position it at the top */}
|
{/* Text as a separate element to position it at the top */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -259,9 +358,9 @@ class Product extends Component {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
sx={{
|
sx={{
|
||||||
width: { xs: '100vw', sm: '250px' },
|
width: { xs: '100vw', sm: '250px' },
|
||||||
minWidth: { xs: '100vw', sm: '250px' },
|
minWidth: { xs: '100vw', sm: '250px' },
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@@ -325,7 +424,7 @@ class Product extends Component {
|
|||||||
{floweringWeeks} {this.props.t ? this.props.t('product.weeks') : 'Wochen'}
|
{floweringWeeks} {this.props.t ? this.props.t('product.weeks') : 'Wochen'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
onClick={this.handleProductClick}
|
onClick={this.handleProductClick}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -338,45 +437,59 @@ class Product extends Component {
|
|||||||
cursor: 'pointer'
|
cursor: 'pointer'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{
|
<Box
|
||||||
position: 'relative',
|
onMouseMove={this.handleMouseMove}
|
||||||
height: { xs: '240px', sm: '180px' },
|
onMouseLeave={this.handleMouseLeave}
|
||||||
display: 'flex',
|
sx={{
|
||||||
alignItems: 'center',
|
position: 'relative',
|
||||||
justifyContent: 'center',
|
height: { xs: '240px', sm: '180px' },
|
||||||
backgroundColor: '#ffffff',
|
display: 'flex',
|
||||||
borderTopLeftRadius: '8px',
|
alignItems: 'center',
|
||||||
borderTopRightRadius: '8px'
|
justifyContent: 'center',
|
||||||
}}>
|
backgroundColor: '#ffffff',
|
||||||
|
borderTopLeftRadius: '8px',
|
||||||
|
borderTopRightRadius: '8px'
|
||||||
|
}}>
|
||||||
{this.state.loading ? (
|
{this.state.loading ? (
|
||||||
<CircularProgress sx={{ color: '#90ffc0' }} />
|
<CircularProgress sx={{ color: '#90ffc0' }} />
|
||||||
|
) : this.state.images && this.state.images.length > 0 && this.state.images.some(img => img !== null) ? (
|
||||||
) : this.state.image === null ? (
|
this.state.images.map((imgSrc, index) => {
|
||||||
<CardMedia
|
if (!imgSrc) return null;
|
||||||
component="img"
|
return (
|
||||||
height={ window.innerWidth < 600 ? "240" : "180" }
|
<CardMedia
|
||||||
image="/assets/images/nopicture.jpg"
|
key={index}
|
||||||
alt={name}
|
component="img"
|
||||||
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}
|
height={window.innerWidth < 600 ? "240" : "180"}
|
||||||
loading={this.props.priority === 'high' ? 'eager' : 'lazy'}
|
image={imgSrc}
|
||||||
onError={(e) => {
|
alt={name}
|
||||||
// Ensure alt text is always present even on error
|
fetchPriority={this.props.priority === 'high' && index === 0 ? 'high' : 'auto'}
|
||||||
if (!e.target.alt) {
|
loading={this.props.priority === 'high' && index === 0 ? 'eager' : 'lazy'}
|
||||||
e.target.alt = name || 'Produktbild';
|
onError={(e) => {
|
||||||
}
|
// Ensure alt text is always present even on error
|
||||||
}}
|
if (!e.target.alt) {
|
||||||
sx={{
|
e.target.alt = name || 'Produktbild';
|
||||||
objectFit: 'contain',
|
}
|
||||||
borderTopLeftRadius: '8px',
|
}}
|
||||||
borderTopRightRadius: '8px',
|
sx={{
|
||||||
width: '100%'
|
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
|
<CardMedia
|
||||||
component="img"
|
component="img"
|
||||||
height={ window.innerWidth < 600 ? "240" : "180" }
|
height={window.innerWidth < 600 ? "240" : "180"}
|
||||||
image={this.state.image}
|
image="/assets/images/nopicture.jpg"
|
||||||
alt={name}
|
alt={name}
|
||||||
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}
|
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}
|
||||||
loading={this.props.priority === 'high' ? 'eager' : 'lazy'}
|
loading={this.props.priority === 'high' ? 'eager' : 'lazy'}
|
||||||
@@ -386,20 +499,24 @@ class Product extends Component {
|
|||||||
e.target.alt = name || 'Produktbild';
|
e.target.alt = name || 'Produktbild';
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
borderTopLeftRadius: '8px',
|
borderTopLeftRadius: '8px',
|
||||||
borderTopRightRadius: '8px',
|
borderTopRightRadius: '8px',
|
||||||
width: '100%'
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<CardContent sx={{
|
<CardContent sx={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
'&.MuiCardContent-root:last-child': {
|
'&.MuiCardContent-root:last-child': {
|
||||||
paddingBottom: 0
|
paddingBottom: 0
|
||||||
}
|
}
|
||||||
@@ -420,14 +537,14 @@ class Product extends Component {
|
|||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
<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 || ''}
|
{manufacturer || ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<div style={{padding:'0px',margin:'0px'}}>
|
<div style={{ padding: '0px', margin: '0px' }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h6"
|
variant="h6"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -458,24 +575,24 @@ class Product extends Component {
|
|||||||
{(() => {
|
{(() => {
|
||||||
const rebatePct = this.props.rebate / 100;
|
const rebatePct = this.props.rebate / 100;
|
||||||
const originalPrice = Math.round((price / (1 - rebatePct)) * 10) / 10;
|
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>
|
||||||
)}
|
)}
|
||||||
<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>
|
</Box>
|
||||||
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>({this.props.t ? this.props.t('product.inclVatFooter', { vat }) : `incl. ${vat}% USt.,*`})</small>
|
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>({this.props.t ? this.props.t('product.inclVatFooter', { vat }) : `incl. ${vat}% USt.,*`})</small>
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ minHeight: '1.5em' }}>
|
<div style={{ minHeight: '1.5em' }}>
|
||||||
{cGrundEinheit && fGrundPreis && fGrundPreis != price && (<Typography variant="body2" color="text.secondary" sx={{ m: 0,p: 0 }}>
|
{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})
|
({new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency || 'EUR' }).format(fGrundPreis)}/{cGrundEinheit})
|
||||||
</Typography> )}
|
</Typography>)}
|
||||||
</div>
|
</div>
|
||||||
{/*incoming*/}
|
{/*incoming*/}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ p: 2, pt: 0, display: 'flex', alignItems: 'center' }}>
|
<Box sx={{ p: 2, pt: 0, display: 'flex', alignItems: 'center' }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
component={Link}
|
component={Link}
|
||||||
@@ -486,7 +603,7 @@ class Product extends Component {
|
|||||||
>
|
>
|
||||||
<ZoomInIcon />
|
<ZoomInIcon />
|
||||||
</IconButton>
|
</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>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import EmailIcon from "@mui/icons-material/Email";
|
|||||||
import LinkIcon from "@mui/icons-material/Link";
|
import LinkIcon from "@mui/icons-material/Link";
|
||||||
import CodeIcon from "@mui/icons-material/Code";
|
import CodeIcon from "@mui/icons-material/Code";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import parse from "html-react-parser";
|
import LazySanitizedHtml from "../utils/LazySanitizedHtml.js";
|
||||||
import sanitizeHtml from "sanitize-html";
|
|
||||||
import AddToCartButton from "./AddToCartButton.js";
|
import AddToCartButton from "./AddToCartButton.js";
|
||||||
import ProductImage from "./ProductImage.js";
|
import ProductImage from "./ProductImage.js";
|
||||||
import Product from "./Product.js";
|
import Product from "./Product.js";
|
||||||
@@ -1624,10 +1623,10 @@ class ProductDetailPage extends Component {
|
|||||||
"& strong": { fontWeight: 600 },
|
"& strong": { fontWeight: 600 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{product.description ? (() => {
|
{product.description ? (
|
||||||
try {
|
<LazySanitizedHtml
|
||||||
// Sanitize HTML to remove invalid tags, but preserve style attributes and <product> tags
|
html={product.description}
|
||||||
const sanitized = sanitizeHtml(product.description, {
|
sanitizeOptions={(sanitizeHtml) => ({
|
||||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'product']),
|
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'product']),
|
||||||
allowedAttributes: {
|
allowedAttributes: {
|
||||||
'*': ['class', 'style'],
|
'*': ['class', 'style'],
|
||||||
@@ -1636,26 +1635,20 @@ class ProductDetailPage extends Component {
|
|||||||
'product': ['articlenr']
|
'product': ['articlenr']
|
||||||
},
|
},
|
||||||
disallowedTagsMode: 'discard'
|
disallowedTagsMode: 'discard'
|
||||||
});
|
})}
|
||||||
|
parseOptions={{
|
||||||
// Parse with custom replace function to handle <product> tags
|
|
||||||
return parse(sanitized, {
|
|
||||||
replace: (domNode) => {
|
replace: (domNode) => {
|
||||||
if (domNode.type === 'tag' && domNode.name === 'product') {
|
if (domNode.type === 'tag' && domNode.name === 'product') {
|
||||||
const articleNr = domNode.attribs && domNode.attribs['articlenr'];
|
const articleNr = domNode.attribs && domNode.attribs['articlenr'];
|
||||||
if (articleNr) {
|
if (articleNr) {
|
||||||
// Render embedded product component
|
|
||||||
return this.renderEmbeddedProduct(articleNr);
|
return this.renderEmbeddedProduct(articleNr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}}
|
||||||
} catch (error) {
|
fallback={<span>{product.description}</span>}
|
||||||
console.warn('Failed to parse product description HTML:', error);
|
/>
|
||||||
// Fallback to rendering as plain text if HTML parsing fails
|
) : upgrading ? (
|
||||||
return <span>{product.description}</span>;
|
|
||||||
}
|
|
||||||
})() : upgrading ? (
|
|
||||||
<Box sx={{ textAlign: "center", py: 2 }}>
|
<Box sx={{ textAlign: "center", py: 2 }}>
|
||||||
<Typography variant="body1" color="text.secondary">
|
<Typography variant="body1" color="text.secondary">
|
||||||
{this.props.t ? this.props.t('product.loadingDescription') : 'Produktbeschreibung wird geladen...'}
|
{this.props.t ? this.props.t('product.loadingDescription') : 'Produktbeschreibung wird geladen...'}
|
||||||
|
|||||||
@@ -36,17 +36,39 @@ class ProductFilters extends Component {
|
|||||||
this.state = {
|
this.state = {
|
||||||
availabilityValues,
|
availabilityValues,
|
||||||
uniqueManufacturerArray,
|
uniqueManufacturerArray,
|
||||||
attributeGroups
|
attributeGroups,
|
||||||
|
manufacturerImages: new Map(), // id (number) → object URL
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this._manufacturerImageUrls = []; // track for cleanup
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
// Measure the available space dynamically
|
|
||||||
this.adjustPaperHeight();
|
this.adjustPaperHeight();
|
||||||
// Add event listener for window resize
|
|
||||||
window.addEventListener('resize', this.adjustPaperHeight);
|
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) {
|
componentDidUpdate(prevProps) {
|
||||||
// Regenerate values when products, attributes, or language changes
|
// Regenerate values when products, attributes, or language changes
|
||||||
const productsChanged = this.props.products !== prevProps.products;
|
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 = () => {
|
adjustPaperHeight = () => {
|
||||||
// Skip height adjustment on xs screens
|
// Skip height adjustment on xs screens
|
||||||
@@ -265,6 +283,7 @@ class ProductFilters extends Component {
|
|||||||
products={this.props.products}
|
products={this.props.products}
|
||||||
filteredProducts={this.props.filteredProducts}
|
filteredProducts={this.props.filteredProducts}
|
||||||
attributes={this.props.attributes}
|
attributes={this.props.attributes}
|
||||||
|
manufacturerImages={this.state.manufacturerImages}
|
||||||
onFilterChange={(msg)=>{
|
onFilterChange={(msg)=>{
|
||||||
if(msg.value) {
|
if(msg.value) {
|
||||||
setSessionSetting(`filter_${msg.type}_${msg.name}`, 'true');
|
setSessionSetting(`filter_${msg.type}_${msg.name}`, 'true');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import ChevronLeft from "@mui/icons-material/ChevronLeft";
|
|||||||
import ChevronRight from "@mui/icons-material/ChevronRight";
|
import ChevronRight from "@mui/icons-material/ChevronRight";
|
||||||
import CategoryBox from "./CategoryBox.js";
|
import CategoryBox from "./CategoryBox.js";
|
||||||
import ProductCarousel from "./ProductCarousel.js";
|
import ProductCarousel from "./ProductCarousel.js";
|
||||||
|
import ManufacturerCarousel from "./ManufacturerCarousel.js";
|
||||||
import { withTranslation } from 'react-i18next';
|
import { withTranslation } from 'react-i18next';
|
||||||
import { withLanguage } from '../i18n/withTranslation.js';
|
import { withLanguage } from '../i18n/withTranslation.js';
|
||||||
|
|
||||||
@@ -419,6 +420,9 @@ class SharedCarousel extends React.Component {
|
|||||||
|
|
||||||
{/* Product Carousel for "neu" category */}
|
{/* Product Carousel for "neu" category */}
|
||||||
<ProductCarousel categoryId="neu" />
|
<ProductCarousel categoryId="neu" />
|
||||||
|
|
||||||
|
{/* Manufacturer logo carousel */}
|
||||||
|
<ManufacturerCarousel />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import CheckoutValidation from "./CheckoutValidation.js";
|
|||||||
import { withI18n } from "../../i18n/index.js";
|
import { withI18n } from "../../i18n/index.js";
|
||||||
|
|
||||||
class CartTab extends Component {
|
class CartTab extends Component {
|
||||||
|
normalizeDeliveryMethod = (deliveryMethod) => (deliveryMethod === "DPD" ? "DHL" : deliveryMethod);
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
@@ -107,10 +109,12 @@ class CartTab extends Component {
|
|||||||
// Map delivery method values if needed
|
// Map delivery method values if needed
|
||||||
const deliveryMethodMap = {
|
const deliveryMethodMap = {
|
||||||
"standard": "DHL",
|
"standard": "DHL",
|
||||||
"express": "DPD",
|
"express": "DHL",
|
||||||
"pickup": "Abholung"
|
"pickup": "Abholung"
|
||||||
};
|
};
|
||||||
prefillDeliveryMethod = deliveryMethodMap[prefillDeliveryMethod] || prefillDeliveryMethod;
|
prefillDeliveryMethod = this.normalizeDeliveryMethod(
|
||||||
|
deliveryMethodMap[prefillDeliveryMethod] || prefillDeliveryMethod
|
||||||
|
);
|
||||||
|
|
||||||
// Determine payment method - respect constraints
|
// Determine payment method - respect constraints
|
||||||
let prefillPaymentMethod = template.payment_method || "wire";
|
let prefillPaymentMethod = template.payment_method || "wire";
|
||||||
@@ -168,7 +172,9 @@ class CartTab extends Component {
|
|||||||
const cartItems = Array.isArray(window.cart) ? window.cart : [];
|
const cartItems = Array.isArray(window.cart) ? window.cart : [];
|
||||||
const shouldForcePickup = CheckoutValidation.shouldForcePickupDelivery(cartItems);
|
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();
|
const deliveryCost = this.orderService.getDeliveryCost();
|
||||||
|
|
||||||
// Get optimal payment method for the current state
|
// Get optimal payment method for the current state
|
||||||
@@ -215,7 +221,7 @@ class CartTab extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleDeliveryMethodChange = (event) => {
|
handleDeliveryMethodChange = (event) => {
|
||||||
const newDeliveryMethod = event.target.value;
|
const newDeliveryMethod = this.normalizeDeliveryMethod(event.target.value);
|
||||||
const deliveryCost = this.orderService.getDeliveryCost();
|
const deliveryCost = this.orderService.getDeliveryCost();
|
||||||
|
|
||||||
// Get optimal payment method for the new delivery method
|
// Get optimal payment method for the new delivery method
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ class CheckoutForm extends Component {
|
|||||||
cartItems={cartItems}
|
cartItems={cartItems}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(deliveryMethod === "DHL" || deliveryMethod === "DPD") && (
|
{deliveryMethod === "DHL" && (
|
||||||
<>
|
<>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
|
|||||||
@@ -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 €'),
|
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dhl') : '5,90 €'),
|
||||||
disabled: isPickupOnly
|
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',
|
id: 'Sperrgut',
|
||||||
name: t ? t('delivery.methods.sperrgutName') : 'Sperrgut',
|
name: t ? t('delivery.methods.sperrgutName') : 'Sperrgut',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default {
|
|||||||
"thcTest": "اختبار THC",
|
"thcTest": "اختبار THC",
|
||||||
"address1": "شارع تراشينبرجر 14",
|
"address1": "شارع تراشينبرجر 14",
|
||||||
"address2": "01129 دريسدن",
|
"address2": "01129 دريسدن",
|
||||||
"showUsPhoto": "اعرض لنا أجمل صورة لديك",
|
"showUsPhoto": "ورّينا أجمل صورة عندك",
|
||||||
"selectSeedRate": "اختر البذرة، واضغط للتقييم",
|
"selectSeedRate": "اختار البذرة، واضغط تقييم",
|
||||||
"indoorSeason": "بدأ موسم الزراعة الداخلية"
|
"outdoorSeason": "موسم الزراعة في الهواء الطلق بدأ"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ export default {
|
|||||||
"seeds": "Семена",
|
"seeds": "Семена",
|
||||||
"stecklinge": "Резници",
|
"stecklinge": "Резници",
|
||||||
"konfigurator": "Конфигуратор",
|
"konfigurator": "Конфигуратор",
|
||||||
"oilPress": "Наеми преса за олио",
|
"oilPress": "Наеми преса за масло",
|
||||||
"thcTest": "THC тест",
|
"thcTest": "THC тест",
|
||||||
"address1": "Trachenberger Straße 14",
|
"address1": "Trachenberger Straße 14",
|
||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "Покажи ни най-красивата си снимка",
|
"showUsPhoto": "Покажи ни най-красивата си снимка",
|
||||||
"selectSeedRate": "Избери семе, кликни за оценка",
|
"selectSeedRate": "Избери семе, кликни за оценка",
|
||||||
"indoorSeason": "Вътрешният сезон започва"
|
"outdoorSeason": "Започва откритият сезон"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ export default {
|
|||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "Ukaž nám svou nejkrásnější fotku",
|
"showUsPhoto": "Ukaž nám svou nejkrásnější fotku",
|
||||||
"selectSeedRate": "Vyber semeno, klikni na hodnocení",
|
"selectSeedRate": "Vyber semeno, klikni na hodnocení",
|
||||||
"indoorSeason": "Začíná indoor sezóna"
|
"outdoorSeason": "Venkovní sezóna začíná"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ export default {
|
|||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "Zeig uns dein schönstes Foto",
|
"showUsPhoto": "Zeig uns dein schönstes Foto",
|
||||||
"selectSeedRate": "Wähle Seed aus, klicke Bewerten",
|
"selectSeedRate": "Wähle Seed aus, klicke Bewerten",
|
||||||
"indoorSeason": "Die Indoorsaison beginnt"
|
"outdoorSeason": "Die Outdoorsaison beginnt"
|
||||||
};
|
};
|
||||||
@@ -8,5 +8,5 @@ export default {
|
|||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "Δείξε μας την πιο όμορφη φωτογραφία σου",
|
"showUsPhoto": "Δείξε μας την πιο όμορφη φωτογραφία σου",
|
||||||
"selectSeedRate": "Επίλεξε σπόρο, κάνε κλικ για αξιολόγηση",
|
"selectSeedRate": "Επίλεξε σπόρο, κάνε κλικ για αξιολόγηση",
|
||||||
"indoorSeason": "Η εσωτερική σεζόν ξεκινά"
|
"outdoorSeason": "Η εξωτερική σεζόν ξεκινά"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ export default {
|
|||||||
"address2": "01129 Dresden", // 01129 Dresden
|
"address2": "01129 Dresden", // 01129 Dresden
|
||||||
"showUsPhoto": "Show us your most beautiful photo", // Zeig uns dein schönstes Foto
|
"showUsPhoto": "Show us your most beautiful photo", // Zeig uns dein schönstes Foto
|
||||||
"selectSeedRate": "Select seed, click rate", // Wähle Seed aus, klicke Bewerten
|
"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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ export default {
|
|||||||
"address1": "Trachenberger Straße 14",
|
"address1": "Trachenberger Straße 14",
|
||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "Muéstranos tu foto más hermosa",
|
"showUsPhoto": "Muéstranos tu foto más hermosa",
|
||||||
"selectSeedRate": "Selecciona semilla, haz clic en valorar",
|
"selectSeedRate": "Selecciona semilla, haz clic para valorar",
|
||||||
"indoorSeason": "Comienza la temporada de interior"
|
"outdoorSeason": "Comienza la temporada al aire libre"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ export default {
|
|||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "Montre-nous ta plus belle photo",
|
"showUsPhoto": "Montre-nous ta plus belle photo",
|
||||||
"selectSeedRate": "Sélectionne une graine, clique pour noter",
|
"selectSeedRate": "Sélectionne une graine, clique pour noter",
|
||||||
"indoorSeason": "La saison indoor commence"
|
"outdoorSeason": "La saison en extérieur commence"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ export default {
|
|||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "Pokaži nam svoju najljepšu fotografiju",
|
"showUsPhoto": "Pokaži nam svoju najljepšu fotografiju",
|
||||||
"selectSeedRate": "Odaberi sjeme, klikni ocijeni",
|
"selectSeedRate": "Odaberi sjeme, klikni ocijeni",
|
||||||
"indoorSeason": "Počinje sezona uzgoja u zatvorenom"
|
"outdoorSeason": "Sezona na otvorenom počinje"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ export default {
|
|||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "Mutasd meg nekünk a legszebb fotódat",
|
"showUsPhoto": "Mutasd meg nekünk a legszebb fotódat",
|
||||||
"selectSeedRate": "Válassz magot, kattints az értékelésre",
|
"selectSeedRate": "Válassz magot, kattints az értékelésre",
|
||||||
"indoorSeason": "Kezdődik a beltéri szezon"
|
"outdoorSeason": "Kezdődik a szabadtéri szezon"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ export default {
|
|||||||
"address1": "Trachenberger Straße 14",
|
"address1": "Trachenberger Straße 14",
|
||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "Mostraci la tua foto più bella",
|
"showUsPhoto": "Mostraci la tua foto più bella",
|
||||||
"selectSeedRate": "Seleziona il seme, clicca per valutare",
|
"selectSeedRate": "Seleziona il seme, clicca valuta",
|
||||||
"indoorSeason": "La stagione indoor inizia"
|
"outdoorSeason": "La stagione all'aperto inizia"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ export default {
|
|||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "Pokaż nam swoje najpiękniejsze zdjęcie",
|
"showUsPhoto": "Pokaż nam swoje najpiękniejsze zdjęcie",
|
||||||
"selectSeedRate": "Wybierz nasiono, kliknij ocenę",
|
"selectSeedRate": "Wybierz nasiono, kliknij ocenę",
|
||||||
"indoorSeason": "Sezon indoorowy się zaczyna",
|
"outdoorSeason": "Sezon na uprawę na zewnątrz się zaczyna",
|
||||||
"locale": "pl"
|
"locale": "pl"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ export default {
|
|||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "Arată-ne cea mai frumoasă fotografie a ta",
|
"showUsPhoto": "Arată-ne cea mai frumoasă fotografie a ta",
|
||||||
"selectSeedRate": "Selectează sămânța, apasă evaluează",
|
"selectSeedRate": "Selectează sămânța, apasă evaluează",
|
||||||
"indoorSeason": "Sezonul indoor începe"
|
"outdoorSeason": "Sezonul în aer liber începe"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ export default {
|
|||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "Покажи нам свою самую красивую фотографию",
|
"showUsPhoto": "Покажи нам свою самую красивую фотографию",
|
||||||
"selectSeedRate": "Выберите семена, нажмите оценить",
|
"selectSeedRate": "Выберите семена, нажмите оценить",
|
||||||
"indoorSeason": "Начинается сезон для выращивания в помещении"
|
"outdoorSeason": "Начинается сезон для выращивания на улице"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ export default {
|
|||||||
"seeds": "Semienka",
|
"seeds": "Semienka",
|
||||||
"stecklinge": "Rezky",
|
"stecklinge": "Rezky",
|
||||||
"konfigurator": "Konfigurátor",
|
"konfigurator": "Konfigurátor",
|
||||||
"oilPress": "Požičajte si lis na olej",
|
"oilPress": "Požičať lis na olej",
|
||||||
"thcTest": "THC test",
|
"thcTest": "THC test",
|
||||||
"address1": "Trachenberger Straße 14",
|
"address1": "Trachenberger Straße 14",
|
||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "Ukážte nám svoju najkrajšiu fotku",
|
"showUsPhoto": "Ukáž nám svoju najkrajšiu fotku",
|
||||||
"selectSeedRate": "Vyberte semienko, kliknite na hodnotenie",
|
"selectSeedRate": "Vyber semienko, klikni na hodnotenie",
|
||||||
"indoorSeason": "Začína sezóna pestovania v interiéri"
|
"outdoorSeason": "Začína vonkajšia sezóna"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ export default {
|
|||||||
"seeds": "Semena",
|
"seeds": "Semena",
|
||||||
"stecklinge": "Rezalci",
|
"stecklinge": "Rezalci",
|
||||||
"konfigurator": "Konfigurator",
|
"konfigurator": "Konfigurator",
|
||||||
"oilPress": "Izposodi si stiskalnico olja",
|
"oilPress": "Izposodi si stiskalnico za olje",
|
||||||
"thcTest": "THC test",
|
"thcTest": "THC test",
|
||||||
"address1": "Trachenberger Straße 14",
|
"address1": "Trachenberger Straße 14",
|
||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "Pokaži nam svojo najlepšo fotografijo",
|
"showUsPhoto": "Pokaži nam svojo najlepšo fotografijo",
|
||||||
"selectSeedRate": "Izberi seme, klikni oceni",
|
"selectSeedRate": "Izberi seme, klikni oceni",
|
||||||
"indoorSeason": "Začne se sezona gojenja v zaprtih prostorih"
|
"outdoorSeason": "Sezona na prostem se začenja"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
export default {
|
export default {
|
||||||
"seeds": "Farëra",
|
"seeds": "Farëra",
|
||||||
"stecklinge": "Përkëmbëza",
|
"stecklinge": "Përkëmbëza",
|
||||||
"konfigurator": "Konfiguruesi",
|
"konfigurator": "Konfigurues",
|
||||||
"oilPress": "Huazo shtypësin e vajit",
|
"oilPress": "Huazo shtypësin e vajit",
|
||||||
"thcTest": "Testi THC",
|
"thcTest": "Testi THC",
|
||||||
"address1": "Trachenberger Straße 14",
|
"address1": "Trachenberger Straße 14",
|
||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "Na trego foton tënde më të bukur",
|
"showUsPhoto": "Na trego foton tënde më të bukur",
|
||||||
"selectSeedRate": "Zgjidh farën, kliko vlerësimin",
|
"selectSeedRate": "Zgjidh farën, kliko vlerësimin",
|
||||||
"indoorSeason": "Sezona e brendshme fillon"
|
"outdoorSeason": "Sezona në natyrë fillon"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ export default {
|
|||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "Pokaži nam svoju najlepšu fotografiju",
|
"showUsPhoto": "Pokaži nam svoju najlepšu fotografiju",
|
||||||
"selectSeedRate": "Izaberi seme, klikni oceni",
|
"selectSeedRate": "Izaberi seme, klikni oceni",
|
||||||
"indoorSeason": "Počinje sezona za uzgoj u zatvorenom"
|
"outdoorSeason": "Počinje sezona na otvorenom"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ export default {
|
|||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "Visa oss din vackraste bild",
|
"showUsPhoto": "Visa oss din vackraste bild",
|
||||||
"selectSeedRate": "Välj frö, klicka betyg",
|
"selectSeedRate": "Välj frö, klicka betyg",
|
||||||
"indoorSeason": "Inomhussäsongen börjar"
|
"outdoorSeason": "Utomhussäsongen börjar"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ export default {
|
|||||||
"seeds": "Tohumlar",
|
"seeds": "Tohumlar",
|
||||||
"stecklinge": "Çelikler",
|
"stecklinge": "Çelikler",
|
||||||
"konfigurator": "Konfigüratör",
|
"konfigurator": "Konfigüratör",
|
||||||
"oilPress": "Yağ presini ödünç al",
|
"oilPress": "Yağ presi ödünç al",
|
||||||
"thcTest": "THC testi",
|
"thcTest": "THC testi",
|
||||||
"address1": "Trachenberger Straße 14",
|
"address1": "Trachenberger Straße 14",
|
||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "En güzel fotoğrafını göster",
|
"showUsPhoto": "En güzel fotoğrafını göster",
|
||||||
"selectSeedRate": "Tohumu seç, oy ver",
|
"selectSeedRate": "Tohumu seç, oy ver",
|
||||||
"indoorSeason": "Kapalı sezon başlıyor"
|
"outdoorSeason": "Açık hava sezonu başlıyor"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ export default {
|
|||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "Покажи нам своє найкрасивіше фото",
|
"showUsPhoto": "Покажи нам своє найкрасивіше фото",
|
||||||
"selectSeedRate": "Виберіть насіння, натисніть оцінити",
|
"selectSeedRate": "Виберіть насіння, натисніть оцінити",
|
||||||
"indoorSeason": "Починається сезон для вирощування в приміщенні"
|
"outdoorSeason": "Починається сезон на відкритому повітрі"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ export default {
|
|||||||
"address2": "01129 Dresden",
|
"address2": "01129 Dresden",
|
||||||
"showUsPhoto": "展示你最美的照片",
|
"showUsPhoto": "展示你最美的照片",
|
||||||
"selectSeedRate": "选择种子,点击评分",
|
"selectSeedRate": "选择种子,点击评分",
|
||||||
"indoorSeason": "室内季节开始了"
|
"outdoorSeason": "户外季节开始了"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,43 +1,154 @@
|
|||||||
import { io } from 'socket.io-client';
|
|
||||||
|
|
||||||
|
|
||||||
class SocketManager {
|
class SocketManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.socket = io('', {
|
this._socket = null;
|
||||||
transports: ["websocket", "polling"],
|
this._socketReady = null;
|
||||||
autoConnect: false
|
// Listeners registered before socket.io-client has loaded
|
||||||
});
|
this._preSocketListeners = [];
|
||||||
|
|
||||||
this.emit = this.emit.bind(this);
|
this.emit = this.emit.bind(this);
|
||||||
this.on = this.on.bind(this);
|
this.on = this.on.bind(this);
|
||||||
this.off = this.off.bind(this);
|
this.off = this.off.bind(this);
|
||||||
this.connectPromise = null;
|
this.connectPromise = null;
|
||||||
|
this.reauthPromise = null;
|
||||||
this.pendingListeners = new Map();
|
this.pendingListeners = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event, callback) {
|
_getStoredUser() {
|
||||||
// If socket is already connected, register the listener directly
|
const storedUser = sessionStorage.getItem('user');
|
||||||
if (this.socket.connected) {
|
if (!storedUser) {
|
||||||
this.socket.on(event, callback);
|
return null;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the listener to be registered when connection is established
|
try {
|
||||||
if (!this.pendingListeners.has(event)) {
|
return JSON.parse(storedUser);
|
||||||
this.pendingListeners.set(event, new Set());
|
} catch (error) {
|
||||||
|
console.error('Failed to parse stored user for socket reauth:', error);
|
||||||
|
sessionStorage.removeItem('user');
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
this.pendingListeners.get(event).add(callback);
|
|
||||||
|
|
||||||
// Register the listener now, it will receive events once connected
|
|
||||||
this.socket.on(event, callback);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_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);
|
||||||
|
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) {
|
off(event, callback) {
|
||||||
// Remove from socket listeners
|
|
||||||
console.log('off', event, callback);
|
console.log('off', event, callback);
|
||||||
this.socket.off(event, callback);
|
|
||||||
|
// Remove from pre-socket queue (component unmounted before socket loaded)
|
||||||
// Remove from pending listeners if present
|
this._preSocketListeners = this._preSocketListeners.filter(
|
||||||
|
(item) => !(item.event === event && item.callback === callback)
|
||||||
|
);
|
||||||
|
|
||||||
if (this.pendingListeners.has(event)) {
|
if (this.pendingListeners.has(event)) {
|
||||||
const listeners = this.pendingListeners.get(event);
|
const listeners = this.pendingListeners.get(event);
|
||||||
listeners.delete(callback);
|
listeners.delete(callback);
|
||||||
@@ -45,57 +156,71 @@ class SocketManager {
|
|||||||
this.pendingListeners.delete(event);
|
this.pendingListeners.delete(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._socket) {
|
||||||
|
this._socket.off(event, callback);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
if (this.connectPromise) return this.connectPromise;
|
if (this.connectPromise) return this.connectPromise;
|
||||||
|
|
||||||
this.connectPromise = new Promise((resolve, reject) => {
|
this.connectPromise = this._ensureSocket().then(
|
||||||
this.socket.connect();
|
(socket) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
this.socket.once('connect', () => {
|
socket.auth = this._buildSocketAuth();
|
||||||
resolve();
|
socket.connect();
|
||||||
});
|
socket.once('connect', () => {
|
||||||
|
this._reauthenticate()
|
||||||
this.socket.once('connect_error', (error) => {
|
.then(() => resolve())
|
||||||
this.connectPromise = null;
|
.catch(() => resolve());
|
||||||
reject(error);
|
});
|
||||||
});
|
socket.once('connect_error', (error) => {
|
||||||
});
|
this.connectPromise = null;
|
||||||
|
|
||||||
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);
|
|
||||||
resolve();
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
reject(error);
|
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 {
|
||||||
|
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();
|
const socketManager = new SocketManager();
|
||||||
|
|
||||||
// Attach to window object
|
|
||||||
window.socketManager = socketManager;
|
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,
|
priority: 20,
|
||||||
reuseExistingChunk: true,
|
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
|
// Other vendor libraries
|
||||||
vendor: {
|
vendor: {
|
||||||
test: /[\\/]node_modules[\\/]/,
|
test: /[\\/]node_modules[\\/]/,
|
||||||
|
|||||||
Reference in New Issue
Block a user