Compare commits

...

6 Commits

38 changed files with 838 additions and 280 deletions

View File

@@ -34,11 +34,12 @@ import Header from "./components/Header.js";
import Footer from "./components/Footer.js"; import 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"));

View File

@@ -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}>

View File

@@ -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>

View File

@@ -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,
@@ -103,16 +125,6 @@ export class LoginComponent extends Component {
// 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,

View File

@@ -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 === "/";

View File

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

View File

@@ -78,51 +78,150 @@ class Product extends Component {
window.smallPicCache = {}; 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{
this.state = {image: null, loading: true, error: false};
this.loadImage(bildId); if (pictureIds.length > 0) {
} const initialImages = pictureIds.map(id => window.smallPicCache[id] || null);
}else{ const isFirstCached = !!initialImages[0];
this.state = {image: null, loading: false, error: false};
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() { componentDidMount() {
this._isMounted = true; this._isMounted = true;
this.startRandomFading();
} }
loadImage = (bildId) => { startRandomFading = () => {
if (this.state.isHovering) return;
console.log('loadImagevisSocket', bildId); const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => { ? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
if(res.success){ : [];
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
if (this._isMounted) { if (pictureIds.length > 1) {
this.setState({image: window.smallPicCache[bildId], loading: false}); const minInterval = 4000;
} else { const maxInterval = 8000;
this.state.image = window.smallPicCache[bildId]; const randomInterval = Math.floor(Math.random() * (maxInterval - minInterval + 1)) + minInterval;
this.state.loading = false;
} this.fadeTimeout = setTimeout(() => {
}else{ if (this._isMounted) {
console.log('Fehler beim Laden des Bildes:', res); this.setState(prevState => {
if (this._isMounted) { let nextIndex = (prevState.currentImageIndex + 1) % pictureIds.length;
this.setState({error: true, loading: false}); let attempts = 0;
} else { 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);
@@ -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'}
@@ -390,7 +503,11 @@ class Product extends Component {
objectFit: 'contain', objectFit: 'contain',
borderTopLeftRadius: '8px', borderTopLeftRadius: '8px',
borderTopRightRadius: '8px', borderTopRightRadius: '8px',
width: '100%' width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0
}} }}
/> />
)} )}
@@ -422,12 +539,12 @@ class Product extends Component {
</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,21 +575,21 @@ 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>
@@ -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>

View File

@@ -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...'}

View File

@@ -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');

View File

@@ -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>
); );
} }

View File

@@ -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

View File

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

View File

@@ -20,14 +20,6 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartIt
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dhl') : '5,90 €'), 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',

View File

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

View File

@@ -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": "Започва откритият сезон"
}; };

View File

@@ -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á"
}; };

View File

@@ -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"
}; };

View File

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

View File

@@ -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
}; };

View File

@@ -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"
}; };

View File

@@ -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"
}; };

View File

@@ -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"
}; };

View File

@@ -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"
}; };

View File

@@ -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"
}; };

View File

@@ -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"
}; };

View File

@@ -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"
}; };

View File

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

View File

@@ -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"
}; };

View File

@@ -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"
}; };

View File

@@ -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"
}; };

View File

@@ -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"
}; };

View File

@@ -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"
}; };

View File

@@ -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": "ık hava sezonu başlıyor"
}; };

View File

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

View File

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

View File

@@ -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();
} }
_getStoredUser() {
const storedUser = sessionStorage.getItem('user');
if (!storedUser) {
return null;
}
try {
return JSON.parse(storedUser);
} catch (error) {
console.error('Failed to parse stored user for socket reauth:', error);
sessionStorage.removeItem('user');
return null;
}
}
_buildSocketAuth() {
const token = sessionStorage.getItem('authToken');
const user = this._getStoredUser();
if (!token && !user) {
return {};
}
// Provide both nested and flat shapes for backend compatibility.
return {
token,
user,
userId: user ? user.id : undefined,
email: user ? user.email : undefined
};
}
_reauthenticate() {
if (!this._socket || !this._socket.connected) {
return Promise.resolve();
}
const token = sessionStorage.getItem('authToken');
if (!token) {
return Promise.resolve();
}
if (this.reauthPromise) {
return this.reauthPromise;
}
this.reauthPromise = new Promise((resolve) => {
let settled = false;
const done = () => {
if (settled) return;
settled = true;
this.reauthPromise = null;
resolve();
};
// Don't block emits indefinitely if backend ignores this event.
const timeoutId = setTimeout(done, 800);
this._socket.emit('verifyToken', { token }, (response) => {
clearTimeout(timeoutId);
if (response && response.success && response.user) {
sessionStorage.setItem('user', JSON.stringify(response.user));
}
if (response && response.success && response.token) {
sessionStorage.setItem('authToken', response.token);
}
done();
});
});
return this.reauthPromise;
}
// Lazily import socket.io-client and create the socket on first use.
// Subsequent calls return the same promise.
_ensureSocket() {
if (this._socket) return Promise.resolve(this._socket);
if (this._socketReady) return this._socketReady;
this._socketReady = import('socket.io-client').then(({ io }) => {
this._socket = io('', {
transports: ['websocket', 'polling'],
autoConnect: false,
withCredentials: true,
auth: this._buildSocketAuth()
});
// Always refresh auth data before reconnect attempts.
if (this._socket.io && this._socket.io.on) {
this._socket.io.on('reconnect_attempt', () => {
this._socket.auth = this._buildSocketAuth();
});
}
// Re-authenticate every time a new socket connection is established.
this._socket.on('connect', () => {
this.reauthPromise = this._reauthenticate();
});
// Register any listeners that arrived before the socket was ready
this._preSocketListeners.forEach(({ event, callback }) => {
this._socket.on(event, callback);
});
this._preSocketListeners = [];
return this._socket;
});
return this._socketReady;
}
on(event, callback) { on(event, callback) {
// If socket is already connected, register the listener directly if (this._socket) {
if (this.socket.connected) { // Socket already loaded — mirror the original behaviour
this.socket.on(event, callback); if (!this.pendingListeners.has(event)) {
return; 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);
} }
// 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);
} }
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 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)) { 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;
reject(error);
});
})
);
return this.connectPromise; return this.connectPromise;
} }
emit(event, ...args) { emit(event, ...args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!this.socket.connected) { this._ensureSocket()
// If not already connecting, start connection .then((socket) => {
if (!this.connectPromise) { if (!socket.connected) {
this.connect(); 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();
};
// Wait for connection before emitting if (this.reauthPromise) {
this.connectPromise this.reauthPromise.then(emitNow).catch(emitNow);
.then(() => { } else {
this.socket.emit(event, ...args); emitNow();
resolve(); }
}) }
.catch((error) => { })
reject(error); .catch(reject);
});
} else {
// Socket already connected, emit directly
this.socket.emit(event, ...args);
resolve();
}
}); });
} }
} }
// 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;

View File

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

View File

@@ -301,6 +301,14 @@ export default {
priority: 20, 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[\\/]/,