Files
reactShop/src/components/Product.js

650 lines
22 KiB
JavaScript

import React, { Component } from 'react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardMedia from '@mui/material/CardMedia';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import AddToCartButton from './AddToCartButton.js';
import { Link, useNavigate } from 'react-router-dom';
import { withI18n } from '../i18n/withTranslation.js';
import ZoomInIcon from '@mui/icons-material/ZoomIn';
import { STAR_POLYGON_POINTS } from '../utils/starPolygon.js';
import {
PRODUCT_CARD_MOBILE_MAX_WIDTH_PX,
PRODUCT_CARD_WIDTH_SM_PX,
PRODUCT_CARD_WIDTH_XS_PX,
} from '../utils/productCardLayout.js';
// Helper function to find level 1 category ID from any category ID
const findLevel1CategoryId = (categoryId) => {
try {
const currentLanguage = 'de'; // Default to German
const categoryTreeCache = window.categoryService?.getSync(209, currentLanguage);
if (!categoryTreeCache || !categoryTreeCache.children) {
return null;
}
// Helper function to find category by ID and get its level 1 parent
const findCategoryAndLevel1 = (categories, targetId) => {
for (const category of categories) {
if (category.id === targetId) {
// Found the category, now find its level 1 parent
return findLevel1Parent(categoryTreeCache.children, category);
}
if (category.children && category.children.length > 0) {
const result = findCategoryAndLevel1(category.children, targetId);
if (result) return result;
}
}
return null;
};
// Helper function to find the level 1 parent (direct child of root category 209)
const findLevel1Parent = (level1Categories, category) => {
// If this category's parent is 209, it's already level 1
if (category.parentId === 209) {
return category.id;
}
// Otherwise, find the parent and check if it's level 1
for (const level1Category of level1Categories) {
if (level1Category.id === category.parentId) {
return level1Category.id;
}
// If parent has children, search recursively
if (level1Category.children && level1Category.children.length > 0) {
const result = findLevel1Parent(level1Category.children, category);
if (result) return result;
}
}
return null;
};
return findCategoryAndLevel1(categoryTreeCache.children, parseInt(categoryId));
} catch (error) {
console.error('Error finding level 1 category:', error);
return null;
}
};
class Product extends Component {
constructor(props) {
super(props);
this._isMounted = false;
if (!window.smallPicCache) {
window.smallPicCache = {};
}
const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
: [];
if (pictureIds.length > 0) {
const initialImages = pictureIds.map(id => window.smallPicCache[id] || null);
const isFirstCached = !!initialImages[0];
this.state = {
images: initialImages,
currentImageIndex: 0,
loading: !isFirstCached,
error: false,
isHovering: false
};
pictureIds.forEach((id, index) => {
if (!window.smallPicCache[id]) {
this.loadImage(id, index);
}
});
} else {
this.state = { images: [], currentImageIndex: 0, loading: false, error: false, isHovering: false };
}
}
componentDidMount() {
this._isMounted = true;
this.startRandomFading();
}
startRandomFading = () => {
if (this.state.isHovering) return;
const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
: [];
if (pictureIds.length > 1) {
const minInterval = 4000;
const maxInterval = 8000;
const randomInterval = Math.floor(Math.random() * (maxInterval - minInterval + 1)) + minInterval;
this.fadeTimeout = setTimeout(() => {
if (this._isMounted) {
this.setState(prevState => {
let nextIndex = (prevState.currentImageIndex + 1) % pictureIds.length;
let attempts = 0;
while (!prevState.images[nextIndex] && attempts < pictureIds.length) {
nextIndex = (nextIndex + 1) % pictureIds.length;
attempts++;
}
if (attempts < pictureIds.length && nextIndex !== prevState.currentImageIndex) {
return { currentImageIndex: nextIndex };
}
return null;
}, () => {
this.startRandomFading();
});
}
}, randomInterval);
}
}
handleMouseMove = (e) => {
const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
: [];
if (pictureIds.length > 1) {
if (this.fadeTimeout) {
clearTimeout(this.fadeTimeout);
this.fadeTimeout = null;
}
const { left, width } = e.currentTarget.getBoundingClientRect();
const x = e.clientX - left;
const segmentWidth = width / pictureIds.length;
let targetIndex = Math.floor(x / segmentWidth);
if (targetIndex >= pictureIds.length) targetIndex = pictureIds.length - 1;
if (targetIndex < 0) targetIndex = 0;
if (this.state.currentImageIndex !== targetIndex) {
if (this.state.images[targetIndex]) {
this.setState({ currentImageIndex: targetIndex, isHovering: true });
} else {
this.setState({ isHovering: true });
}
} else if (!this.state.isHovering) {
this.setState({ isHovering: true });
}
}
}
handleMouseLeave = () => {
if (this.state.isHovering) {
this.setState({ isHovering: false }, () => {
this.startRandomFading();
});
}
}
loadImage = (bildId, index) => {
console.log('loadImagevisSocket', bildId);
window.socketManager.emit('getPic', { bildId, size: 'small' }, (res) => {
if (res.success) {
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
if (this._isMounted) {
this.setState(prevState => {
const newImages = [...prevState.images];
newImages[index] = window.smallPicCache[bildId];
return {
images: newImages,
loading: index === 0 ? false : prevState.loading
};
});
} else {
this.state.images[index] = window.smallPicCache[bildId];
if (index === 0) this.state.loading = false;
}
} else {
console.log('Fehler beim Laden des Bildes:', res);
if (this._isMounted) {
this.setState(prevState => ({
error: index === 0 ? true : prevState.error,
loading: index === 0 ? false : prevState.loading
}));
} else {
if (index === 0) {
this.state.error = true;
this.state.loading = false;
}
}
}
});
}
componentWillUnmount() {
this._isMounted = false;
if (this.fadeTimeout) {
clearTimeout(this.fadeTimeout);
}
}
handleQuantityChange = (quantity) => {
console.log(`Product: ${this.props.name}, Quantity: ${quantity}`);
// In a real app, this would update a cart state in a parent component or Redux store
}
handleProductClick = (e) => {
e.preventDefault();
const { categoryId } = this.props;
// Find the level 1 category for this product
const level1CategoryId = categoryId ? findLevel1CategoryId(categoryId) : null;
// Navigate to the product page WITH the category information in the state
const navigate = this.props.navigate;
if (navigate) {
navigate(`/Artikel/${this.props.seoName}`, {
state: { articleCategoryId: level1CategoryId }
});
}
}
render() {
const {
id, name, price, available, manufacturer, seoName,
currency, vat, cGrundEinheit, fGrundPreis, thc,
floweringWeeks, incoming, neu, weight, versandklasse, availableSupplier, komponenten
} = this.props;
const isNew = neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
const showThcBadge = thc > 0;
let thcBadgeColor = '#4caf50'; // Green default
if (thc > 30) {
thcBadgeColor = '#f44336'; // Red for > 30
} else if (thc > 25) {
thcBadgeColor = '#ffeb3b'; // Yellow for > 25
}
const showFloweringWeeksBadge = floweringWeeks > 0;
let floweringWeeksBadgeColor = '#4caf50'; // Green default
if (floweringWeeks > 12) {
floweringWeeksBadgeColor = '#f44336'; // Red for > 12
} else if (floweringWeeks > 8) {
floweringWeeksBadgeColor = '#ffeb3b'; // Yellow for > 8
}
return (
<Box sx={{
position: 'relative',
height: '100%',
/* Match card width on xs so absolute NEU star is relative to the card, not the full grid row */
width: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: 'auto',
},
minWidth: { xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`, sm: 'auto' },
maxWidth: { xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`, sm: 'none' },
display: 'flex',
justifyContent: { xs: 'center', sm: 'flex-start' },
mx: { xs: 'auto', sm: 0 },
}}>
{isNew && (
<div
style={{
position: 'absolute',
top: '-15px',
right: '-15px',
width: '60px',
height: '60px',
zIndex: 999,
pointerEvents: 'none'
}}
>
{/* Background star - slightly larger and rotated */}
<svg
viewBox="0 0 60 60"
width="56"
height="56"
style={{
position: 'absolute',
top: '-3px',
left: '-3px',
transform: 'rotate(20deg)'
}}
>
<polygon
points={STAR_POLYGON_POINTS}
fill="#20403a"
stroke="none"
/>
</svg>
{/* Middle star - medium size with different rotation */}
<svg
viewBox="0 0 60 60"
width="53"
height="53"
style={{
position: 'absolute',
top: '-1.5px',
left: '-1.5px',
transform: 'rotate(-25deg)'
}}
>
<polygon
points={STAR_POLYGON_POINTS}
fill="#40736b"
stroke="none"
/>
</svg>
{/* Foreground star - main star with text */}
<svg
viewBox="0 0 60 60"
width="50"
height="50"
>
<polygon
points={STAR_POLYGON_POINTS}
fill="#609688"
stroke="none"
/>
</svg>
{/* Text as a separate element to position it at the top */}
<div
style={{
position: 'absolute',
top: '40%',
left: '45%',
transform: 'translate(-50%, -50%) rotate(-10deg)',
color: 'white',
fontWeight: '900',
fontSize: '16px',
textShadow: '0px 1px 2px rgba(0,0,0,0.5)',
zIndex: 1000
}}
>
{this.props.t ? this.props.t('product.new') : 'NEU'}
</div>
</div>
)}
<Card
sx={{
width: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
minWidth: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
maxWidth: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
position: 'relative',
overflow: 'hidden',
borderRadius: { xs: '8px', sm: '8px' },
border: { xs: '1px solid', sm: 'inherit' },
borderColor: { xs: 'divider', sm: 'inherit' },
boxShadow: { xs: '0 1px 4px rgba(0,0,0,0.08)', sm: 'inherit' },
mx: { xs: 'auto', sm: 'auto' },
'&:hover': {
transform: { xs: 'none', sm: 'translateY(-5px)' },
boxShadow: {
xs: '0 1px 4px rgba(0,0,0,0.08)',
sm: '0px 10px 20px rgba(0,0,0,0.1)',
},
},
}}
>
{showThcBadge && (
<div aria-label={`THC Anteil: ${thc}%`}
style={{
position: 'absolute',
top: 0,
left: 0,
backgroundColor: thcBadgeColor,
color: thc > 25 && thc <= 30 ? '#000000' : '#ffffff',
fontWeight: 'bold',
padding: '2px 0',
width: '80px',
textAlign: 'center',
zIndex: 999,
fontSize: '9px',
boxShadow: '0px 2px 4px rgba(0,0,0,0.2)',
transform: 'rotate(-45deg) translateX(-40px) translateY(15px)',
transformOrigin: 'top left'
}}
>
THC {thc}%
</div>
)}
{showFloweringWeeksBadge && (
<div aria-label={`Flowering Weeks: ${floweringWeeks}`}
style={{
position: 'absolute',
top: 0,
left: 0,
backgroundColor: floweringWeeksBadgeColor,
color: floweringWeeks > 8 && floweringWeeks <= 12 ? '#000000' : '#ffffff',
fontWeight: 'bold',
padding: '1px 0',
width: '100px',
textAlign: 'center',
zIndex: 999,
fontSize: '9px',
boxShadow: '0px 2px 4px rgba(0,0,0,0.2)',
transform: 'rotate(-45deg) translateX(-50px) translateY(32px)',
transformOrigin: 'top left'
}}
>
{floweringWeeks} {this.props.t ? this.props.t('product.weeks') : 'Wochen'}
</div>
)}
<Box
onClick={this.handleProductClick}
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
textDecoration: 'none',
color: 'inherit',
cursor: 'pointer'
}}
>
<Box
onMouseMove={this.handleMouseMove}
onMouseLeave={this.handleMouseLeave}
sx={{
position: 'relative',
height: { xs: '240px', sm: '180px' },
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#ffffff',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px'
}}>
{this.state.loading ? (
<CircularProgress sx={{ color: '#90ffc0' }} />
) : this.state.images && this.state.images.length > 0 && this.state.images.some(img => img !== null) ? (
this.state.images.map((imgSrc, index) => {
if (!imgSrc) return null;
return (
<CardMedia
key={index}
component="img"
height={window.innerWidth < PRODUCT_CARD_MOBILE_MAX_WIDTH_PX ? "240" : "180"}
image={imgSrc}
alt={name}
fetchPriority={this.props.priority === 'high' && index === 0 ? 'high' : 'auto'}
loading={this.props.priority === 'high' && index === 0 ? 'eager' : 'lazy'}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = name || 'Produktbild';
}
}}
sx={{
objectFit: 'contain',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0,
opacity: this.state.currentImageIndex === index ? 1 : 0,
transition: this.state.isHovering ? 'opacity 0.2s ease-in-out' : 'opacity 1s ease-in-out'
}}
/>
);
})
) : (
<CardMedia
component="img"
height={window.innerWidth < PRODUCT_CARD_MOBILE_MAX_WIDTH_PX ? "240" : "180"}
image="/assets/images/nopicture.jpg"
alt={name}
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}
loading={this.props.priority === 'high' ? 'eager' : 'lazy'}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = name || 'Produktbild';
}
}}
sx={{
objectFit: 'contain',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0
}}
/>
)}
</Box>
<CardContent sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
'&.MuiCardContent-root:last-child': {
paddingBottom: 0
}
}}>
<Typography
gutterBottom
variant="h6"
component="h2"
sx={{
fontWeight: 600,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
minHeight: '3.4em'
}}
>
{name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Typography variant="body2" color="text.secondary" style={{ minHeight: '1.5em' }}>
{manufacturer || ''}
</Typography>
</Box>
<div style={{ padding: '0px', margin: '0px' }}>
<Typography
variant="h6"
color="primary"
sx={{
fontWeight: 'bold',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
position: 'relative'
}}
>
<Box sx={{ position: 'relative', display: 'inline-block' }}>
{this.props.rebate && this.props.rebate > 0 && (
<span
style={{
position: 'absolute',
top: -8,
left: -8,
fontWeight: 'bold',
color: 'red',
textDecoration: 'line-through',
opacity: 0.4,
zIndex: 1,
pointerEvents: 'none',
fontSize: 'inherit'
}}
>
{(() => {
const rebatePct = this.props.rebate / 100;
const originalPrice = Math.round((price / (1 - rebatePct)) * 10) / 10;
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency || 'EUR' }).format(originalPrice);
})()}
</span>
)}
<span style={{ position: 'relative', zIndex: 2 }}>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency || 'EUR' }).format(price)}</span>
</Box>
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>({this.props.t ? this.props.t('product.inclVatFooter', { vat }) : `incl. ${vat}% USt.,*`})</small>
</Typography>
</div>
<div style={{ minHeight: '1.5em' }}>
{cGrundEinheit && fGrundPreis && fGrundPreis != price && (<Typography variant="body2" color="text.secondary" sx={{ m: 0, p: 0 }}>
({new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency || 'EUR' }).format(fGrundPreis)}/{cGrundEinheit})
</Typography>)}
</div>
{/*incoming*/}
</CardContent>
</Box>
<Box sx={{ p: 2, pt: 0, display: 'flex', alignItems: 'center' }}>
<IconButton
component={Link}
to={`/Artikel/${seoName}`}
size="small"
aria-label="Produktdetails anzeigen"
sx={{ mr: 1, color: 'text.secondary' }}
>
<ZoomInIcon />
</IconButton>
<AddToCartButton cartButton={true} availableSupplier={availableSupplier} komponenten={komponenten} cGrundEinheit={cGrundEinheit} fGrundPreis={fGrundPreis} available={available} incoming={incoming} seoName={seoName} pictureList={this.props.pictureList} id={id} price={price} vat={vat} weight={weight} name={name} versandklasse={versandklasse} />
</Box>
</Card>
</Box>
);
}
}
// Wrapper component to provide navigate hook
const ProductWithNavigation = (props) => {
const navigate = useNavigate();
return <Product {...props} navigate={navigate} />;
};
export default withI18n()(ProductWithNavigation);