Compare commits

...

2 Commits

Author SHA1 Message Date
sebseb7
cbb8dc463f feat: enhance ExtrasSelector and GrowTentKonfigurator for improved extras handling and UI
- Refactored ExtrasSelector to implement dynamic image loading with caching, improving performance and user experience.
- Updated GrowTentKonfigurator to fetch and display extras from a new category, ensuring accurate pricing and availability.
- Enhanced UI elements for better layout and clarity, including loading indicators and improved styling for extras display.
- Added handling for cases when no extras are available, providing clear feedback to users.
2025-09-04 10:45:55 +02:00
sebseb7
479e328e7c feat: update ExtrasSelector and GrowTentKonfigurator for VAT display and extras handling
- Refactored ExtrasSelector to include VAT information for each extra, enhancing clarity for users.
- Removed unused extras data from configuratorData.js to streamline the codebase.
- Updated GrowTentKonfigurator to dynamically retrieve extras from cached data, ensuring accurate pricing and VAT display for selected items.
- Improved UI layout for price and VAT information across various components for better user experience.
2025-09-04 10:31:59 +02:00
3 changed files with 227 additions and 180 deletions

View File

@@ -1,12 +1,9 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import Grid from '@mui/material/Grid'; import Grid from '@mui/material/Grid';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardMedia from '@mui/material/CardMedia'; import CardMedia from '@mui/material/CardMedia';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Checkbox from '@mui/material/Checkbox'; import CircularProgress from '@mui/material/CircularProgress';
import FormControlLabel from '@mui/material/FormControlLabel';
class ExtrasSelector extends Component { class ExtrasSelector extends Component {
formatPrice(price) { formatPrice(price) {
@@ -16,124 +13,159 @@ class ExtrasSelector extends Component {
}).format(price); }).format(price);
} }
// Render product image using working code from GrowTentKonfigurator
renderProductImage(product) {
if (!window.smallPicCache) {
window.smallPicCache = {};
}
const pictureList = product.pictureList;
if (!pictureList || pictureList.length === 0 || !pictureList.split(',').length) {
return (
<CardMedia
component="img"
height="160"
image="/assets/images/nopicture.jpg"
alt={product.name || 'Produktbild'}
sx={{
objectFit: 'contain',
width: '100%'
}}
/>
);
}
const bildId = pictureList.split(',')[0];
if (window.smallPicCache[bildId]) {
return (
<CardMedia
component="img"
height="160"
image={window.smallPicCache[bildId]}
alt={product.name || 'Produktbild'}
sx={{
objectFit: 'contain',
width: '100%'
}}
/>
);
}
// Load image if not cached
if (!this.loadingImages) this.loadingImages = new Set();
if (!this.loadingImages.has(bildId)) {
this.loadingImages.add(bildId);
window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => {
if (res.success) {
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
this.forceUpdate();
}
this.loadingImages.delete(bildId);
});
}
return (
<Box sx={{ height: '160px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress sx={{ color: '#90ffc0' }} />
</Box>
);
}
renderExtraCard(extra) { renderExtraCard(extra) {
const { selectedExtras, onExtraToggle, showImage = true } = this.props; const { selectedExtras, onExtraToggle, showImage = true } = this.props;
const isSelected = selectedExtras.includes(extra.id); const isSelected = selectedExtras.includes(extra.id);
return ( return (
<Card <Box sx={{
key={extra.id} width: { xs: '100%', sm: '250px' },
sx={{ height: '100%',
height: '100%', display: 'flex',
border: '2px solid', flexDirection: 'column',
borderColor: isSelected ? '#2e7d32' : '#e0e0e0', borderRadius: '8px',
backgroundColor: isSelected ? '#f1f8e9' : '#ffffff', overflow: 'hidden',
'&:hover': { cursor: 'pointer',
boxShadow: 5, border: '2px solid',
borderColor: isSelected ? '#2e7d32' : '#90caf9' borderColor: isSelected ? '#2e7d32' : '#e0e0e0',
}, backgroundColor: isSelected ? '#f1f8e9' : '#ffffff',
transition: 'all 0.3s ease', '&:hover': {
cursor: 'pointer' boxShadow: 6,
}} borderColor: isSelected ? '#2e7d32' : '#90caf9'
onClick={() => onExtraToggle(extra.id)} },
> transition: 'all 0.3s ease'
}}
onClick={() => onExtraToggle(extra.id)}>
{/* Image */}
{showImage && ( {showImage && (
<CardMedia <Box sx={{
component="img" height: { xs: '240px', sm: '180px' },
height="160" display: 'flex',
image={extra.image} alignItems: 'center',
alt={extra.name} justifyContent: 'center',
sx={{ objectFit: 'cover' }} backgroundColor: '#ffffff'
/> }}>
)} {this.renderProductImage(extra)}
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 2 }}>
<FormControlLabel
control={
<Checkbox
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
onExtraToggle(extra.id);
}}
sx={{
color: '#2e7d32',
'&.Mui-checked': { color: '#2e7d32' },
padding: 0
}}
/>
}
label=""
sx={{ margin: 0 }}
onClick={(e) => e.stopPropagation()}
/>
<Typography variant="h6" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
{this.formatPrice(extra.price)}
</Typography>
</Box> </Box>
)}
{/* Content */}
<Box sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
{/* Name */}
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}> <Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
{extra.name} {extra.name}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary">
{extra.description} {/* Price with VAT - Same as other sections */}
<Typography variant="h6" sx={{
color: '#2e7d32',
fontWeight: 'bold',
mt: 2,
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap'
}}>
<span>{extra.price ? this.formatPrice(extra.price) : 'Kein Preis'}</span>
{extra.vat && (
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>
(incl. {extra.vat}% MwSt.,*)
</small>
)}
</Typography> </Typography>
{/* Selection Indicator - Separate line */}
{isSelected && ( {isSelected && (
<Box sx={{ mt: 2, textAlign: 'center' }}> <Typography variant="body2" sx={{
<Typography variant="body2" sx={{ color: '#2e7d32', fontWeight: 'bold' }}> color: '#2e7d32',
Hinzugefügt fontWeight: 'bold',
</Typography> mt: 1,
</Box> textAlign: 'center'
}}>
Ausgewählt
</Typography>
)} )}
</CardContent> </Box>
</Card> </Box>
); );
} }
render() { render() {
const { extras, title, subtitle, groupByCategory = true, gridSize = { xs: 12, sm: 6, md: 4 } } = this.props; const { extras, title, subtitle, gridSize = { xs: 12, sm: 6, md: 4 } } = this.props;
if (groupByCategory) {
// Group extras by category
const groupedExtras = extras.reduce((acc, extra) => {
if (!acc[extra.category]) {
acc[extra.category] = [];
}
acc[extra.category].push(extra);
return acc;
}, {});
if (!extras || !Array.isArray(extras)) {
return ( return (
<Box sx={{ mb: 4 }}> <Box sx={{ mb: 4 }}>
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}> <Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
{title} {title}
</Typography> </Typography>
{subtitle && ( <Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}> Keine Extras verfügbar
{subtitle} </Typography>
</Typography>
)}
{Object.entries(groupedExtras).map(([category, categoryExtras]) => (
<Box key={category} sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom sx={{ color: '#1976d2' }}>
{category}
</Typography>
<Grid container spacing={2}>
{categoryExtras.map(extra => (
<Grid item {...gridSize} key={extra.id}>
{this.renderExtraCard(extra)}
</Grid>
))}
</Grid>
</Box>
))}
</Box> </Box>
); );
} }
// Render without category grouping
return ( return (
<Box sx={{ mb: 4 }}> <Box sx={{ mb: 4 }}>
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}> <Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>

View File

@@ -41,54 +41,3 @@ export const tentShapes = [
visualDepth: 60 visualDepth: 60
} }
]; ];
export const extras = [
{
id: 'ph_tester',
name: 'pH-Messgerät',
description: 'Digitales pH-Meter',
price: 29.99,
image: '/assets/images/nopicture.jpg',
category: 'Messung'
},
{
id: 'nutrients_starter',
name: 'Dünger Starter-Set',
description: 'Komplettes Nährstoff-Set',
price: 39.99,
image: '/assets/images/nopicture.jpg',
category: 'Nährstoffe'
},
{
id: 'grow_pots',
name: 'Grow-Töpfe Set (5x)',
description: '5x Stofftöpfe 11L',
price: 24.99,
image: '/assets/images/nopicture.jpg',
category: 'Töpfe'
},
{
id: 'timer_socket',
name: 'Zeitschaltuhr',
description: 'Digitale Zeitschaltuhr',
price: 19.99,
image: '/assets/images/nopicture.jpg',
category: 'Steuerung'
},
{
id: 'thermometer',
name: 'Thermo-Hygrometer',
description: 'Min/Max Temperatur & Luftfeuchtigkeit',
price: 14.99,
image: '/assets/images/nopicture.jpg',
category: 'Messung'
},
{
id: 'pruning_shears',
name: 'Gartenschere',
description: 'Präzisions-Gartenschere',
price: 16.99,
image: '/assets/images/nopicture.jpg',
category: 'Werkzeug'
}
];

View File

@@ -16,7 +16,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import { TentShapeSelector, ExtrasSelector } from '../components/configurator/index.js'; import { TentShapeSelector, ExtrasSelector } from '../components/configurator/index.js';
import { tentShapes, extras } from '../data/configuratorData.js'; import { tentShapes } from '../data/configuratorData.js';
function setCachedCategoryData(categoryId, data) { function setCachedCategoryData(categoryId, data) {
if (!window.productCache) { if (!window.productCache) {
@@ -109,6 +109,7 @@ class GrowTentKonfigurator extends Component {
this.fetchCategoryData("Zelte"); this.fetchCategoryData("Zelte");
this.fetchCategoryData("Lampen"); this.fetchCategoryData("Lampen");
this.fetchCategoryData("Abluft-sets"); this.fetchCategoryData("Abluft-sets");
this.fetchCategoryData("Set-zubehoer");
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
@@ -497,14 +498,19 @@ class GrowTentKonfigurator extends Component {
} }
} }
// Add extras prices const extrasData = getCachedCategoryData('Set-zubehoer');
selectedExtras.forEach(extraId => { if (!extrasData || !Array.isArray(extrasData.products)) {
const extra = extras.find(e => e.id === extraId); console.warn('Extras data not available; skipping extras price in total calculation');
if (extra) { } else {
total += extra.price; // Add extras prices
itemCount++; selectedExtras.forEach(extraId => {
} const extra = extrasData.products.find(e => e.id === extraId);
}); if (extra) {
total += extra.price;
itemCount++;
}
});
}
// Apply bundle discount // Apply bundle discount
let discountPercentage = 0; let discountPercentage = 0;
@@ -570,13 +576,18 @@ class GrowTentKonfigurator extends Component {
} }
} }
selectedExtras.forEach(extraId => { const extrasData = getCachedCategoryData('Set-zubehoer');
const extra = extras.find(e => e.id === extraId); if (!extrasData || !Array.isArray(extrasData.products)) {
if (extra) { console.warn('Extras data not available; skipping extras in savings calculation');
originalTotal += extra.price; } else {
itemCount++; selectedExtras.forEach(extraId => {
} const extra = extrasData.products.find(e => e.id === extraId);
}); if (extra) {
originalTotal += extra.price;
itemCount++;
}
});
}
// Progressive discount based on number of selected items // Progressive discount based on number of selected items
let discountPercentage = 0; let discountPercentage = 0;
@@ -1034,6 +1045,35 @@ class GrowTentKonfigurator extends Component {
renderExtrasSection() { renderExtrasSection() {
const { selectedExtras } = this.state; const { selectedExtras } = this.state;
const extrasData = getCachedCategoryData('Set-zubehoer');
if (!extrasData) {
return (
<Box sx={{ mb: 4 }}>
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
5. Extras hinzufügen (optional)
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Lade Extras...
</Typography>
</Box>
);
}
const extras = Array.isArray(extrasData.products) ? extrasData.products : [];
if (extras.length === 0) {
return (
<Box sx={{ mb: 4 }}>
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
5. Extras hinzufügen (optional)
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Keine Extras verfügbar
</Typography>
</Box>
);
}
return ( return (
<ExtrasSelector <ExtrasSelector
@@ -1041,7 +1081,6 @@ class GrowTentKonfigurator extends Component {
selectedExtras={selectedExtras} selectedExtras={selectedExtras}
onExtraToggle={this.handleExtraToggle} onExtraToggle={this.handleExtraToggle}
title="5. Extras hinzufügen (optional)" title="5. Extras hinzufügen (optional)"
groupByCategory={true}
gridSize={{ xs: 12, sm: 6, md: 4 }} gridSize={{ xs: 12, sm: 6, md: 4 }}
/> />
); );
@@ -1062,7 +1101,10 @@ class GrowTentKonfigurator extends Component {
const selectedLight = availableLamps.find(l => l.id === selectedLightType); const selectedLight = availableLamps.find(l => l.id === selectedLightType);
const availableVentilation = this.state.selectedTentShape ? this.getVentilationForTentShape(this.state.selectedTentShape) : []; const availableVentilation = this.state.selectedTentShape ? this.getVentilationForTentShape(this.state.selectedTentShape) : [];
const selectedVentilation = availableVentilation.find(v => v.id === selectedVentilationType && v.isDeliverable); const selectedVentilation = availableVentilation.find(v => v.id === selectedVentilationType && v.isDeliverable);
const selectedExtrasItems = extras.filter(e => selectedExtras.includes(e.id)); const extrasData = getCachedCategoryData('Set-zubehoer');
const selectedExtrasItems = Array.isArray(extrasData?.products)
? extrasData.products.filter(e => selectedExtras.includes(e.id))
: [];
const savingsInfo = this.calculateSavings(); const savingsInfo = this.calculateSavings();
return ( return (
@@ -1086,11 +1128,15 @@ class GrowTentKonfigurator extends Component {
<ListItem> <ListItem>
<ListItemText <ListItemText
primary={`Growbox: ${selectedTent.name}`} primary={`Growbox: ${selectedTent.name}`}
secondary={selectedTent.description}
/> />
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}> <Typography variant="h6" sx={{ fontWeight: 'bold', display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
{this.formatPrice(selectedTent.price)} <span>{this.formatPrice(selectedTent.price)}</span>
{selectedTent.vat && (
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>
(incl. {selectedTent.vat}% MwSt.,*)
</small>
)}
</Typography> </Typography>
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
@@ -1100,11 +1146,15 @@ class GrowTentKonfigurator extends Component {
<ListItem> <ListItem>
<ListItemText <ListItemText
primary={`Beleuchtung: ${selectedLight.name}`} primary={`Beleuchtung: ${selectedLight.name}`}
secondary={selectedLight.description}
/> />
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}> <Typography variant="h6" sx={{ fontWeight: 'bold', display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
{this.formatPrice(selectedLight.price)} <span>{this.formatPrice(selectedLight.price)}</span>
{selectedLight.vat && (
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>
(incl. {selectedLight.vat}% MwSt.,*)
</small>
)}
</Typography> </Typography>
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
@@ -1114,11 +1164,15 @@ class GrowTentKonfigurator extends Component {
<ListItem> <ListItem>
<ListItemText <ListItemText
primary={`Belüftung: ${selectedVentilation.name}`} primary={`Belüftung: ${selectedVentilation.name}`}
secondary={selectedVentilation.description}
/> />
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}> <Typography variant="h6" sx={{ fontWeight: 'bold', display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
{this.formatPrice(selectedVentilation.price)} <span>{this.formatPrice(selectedVentilation.price)}</span>
{selectedVentilation.vat && (
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>
(incl. {selectedVentilation.vat}% MwSt.,*)
</small>
)}
</Typography> </Typography>
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
@@ -1128,11 +1182,15 @@ class GrowTentKonfigurator extends Component {
<ListItem key={extra.id}> <ListItem key={extra.id}>
<ListItemText <ListItemText
primary={`Extra: ${extra.name}`} primary={`Extra: ${extra.name}`}
secondary={extra.description}
/> />
<ListItemSecondaryAction> <ListItemSecondaryAction>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}> <Typography variant="h6" sx={{ fontWeight: 'bold', display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
{this.formatPrice(extra.price)} <span>{this.formatPrice(extra.price)}</span>
{extra.vat && (
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>
(incl. {extra.vat}% MwSt.,*)
</small>
)}
</Typography> </Typography>
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
@@ -1146,6 +1204,9 @@ class GrowTentKonfigurator extends Component {
<Typography variant="h6" sx={{ color: '#d32f2f', fontWeight: 'bold' }}> <Typography variant="h6" sx={{ color: '#d32f2f', fontWeight: 'bold' }}>
Sie sparen: {this.formatPrice(savingsInfo.savings)} ({savingsInfo.discountPercentage}% Bundle-Rabatt) Sie sparen: {this.formatPrice(savingsInfo.savings)} ({savingsInfo.discountPercentage}% Bundle-Rabatt)
</Typography> </Typography>
<Typography variant="caption" sx={{ color: '#77aa77' }}>
(incl. 19% MwSt.,*)
</Typography>
</Box> </Box>
)} )}
@@ -1153,9 +1214,14 @@ class GrowTentKonfigurator extends Component {
<Typography variant="h5" sx={{ fontWeight: 'bold' }}> <Typography variant="h5" sx={{ fontWeight: 'bold' }}>
Gesamtpreis: Gesamtpreis:
</Typography> </Typography>
<Typography variant="h5" sx={{ color: '#2e7d32', fontWeight: 'bold' }}> <Box sx={{ textAlign: 'right' }}>
{this.formatPrice(totalPrice)} <Typography variant="h5" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
</Typography> {this.formatPrice(totalPrice)}
</Typography>
<Typography variant="caption" sx={{ color: '#77aa77' }}>
(incl. 19% MwSt.,*)
</Typography>
</Box>
</Box> </Box>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}> <Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>