Files
reactShop/src/components/ProductDetailPage.js
sebseb7 29a4bfc1c6 fix: update Content and ProductDetailPage components for improved UI and functionality
- Added pointerEvents: 'none' to the Content component's Box for better interaction handling.
- Adjusted spacing in the ProductDetailPage's Stack component for a more consistent layout.
- Enhanced disabled Chip styling in ProductDetailPage to improve visibility and user experience.
2025-09-07 12:14:03 +02:00

1364 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { Component } from "react";
import { Box, Container, Typography, CardMedia, Stack, Chip, Button, Collapse } from "@mui/material";
import { Link } from "react-router-dom";
import parse from "html-react-parser";
import AddToCartButton from "./AddToCartButton.js";
import ProductImage from "./ProductImage.js";
import { withI18n } from "../i18n/withTranslation.js";
import ArticleQuestionForm from "./ArticleQuestionForm.js";
import ArticleRatingForm from "./ArticleRatingForm.js";
import ArticleAvailabilityForm from "./ArticleAvailabilityForm.js";
// Utility function to clean product names by removing trailing number in parentheses
const cleanProductName = (name) => {
if (!name) return "";
// Remove patterns like " (1)", " (3)", " (10)" at the end of the string
return name.replace(/\s*\(\d+\)\s*$/, "").trim();
};
// Product detail page with image loading
class ProductDetailPage extends Component {
constructor(props) {
super(props);
// First try to find cached data by seoName (complete data)
let cachedData = null;
let partialProduct = null;
let isUpgrading = false;
if (window.productDetailCache && window.productDetailCache[this.props.seoName]) {
cachedData = window.productDetailCache[this.props.seoName];
} else if (window.productDetailCache) {
// If not found by seoName, search for partial data by checking all cached products
// Look for a product where the seoName matches this.props.seoName
for (const key in window.productDetailCache) {
const cached = window.productDetailCache[key];
if (cached && cached.seoName === this.props.seoName) {
partialProduct = cached;
isUpgrading = true;
break;
}
// Also check if cached is a product object directly (from category cache)
if (cached && typeof cached === 'object' && cached.seoName === this.props.seoName) {
partialProduct = cached;
isUpgrading = true;
break;
}
}
}
if (cachedData) {
// Complete cached data found
// Clean up prerender fallback since we have cached data
if (typeof window !== "undefined" && window.__PRERENDER_FALLBACK__) {
delete window.__PRERENDER_FALLBACK__;
console.log("ProductDetailPage: Cleaned up prerender fallback using cached product data");
}
// Initialize komponenten from cached product data
const komponenten = [];
if(cachedData.product.komponenten) {
for(const komponent of cachedData.product.komponenten.split(",")) {
// Handle both "x" and "×" as separators
const [id, count] = komponent.split(/[x×]/);
komponenten.push({id: id.trim(), count: count.trim()});
}
}
this.state = {
product: cachedData.product,
loading: false,
upgrading: false,
error: null,
attributeImages: {},
attributes: cachedData.attributes || [],
isSteckling: false,
imageDialogOpen: false,
komponenten: komponenten,
komponentenLoaded: komponenten.length === 0, // If no komponenten, mark as loaded
komponentenData: {}, // Store individual komponent data with loading states
komponentenImages: {}, // Store tiny pictures for komponenten
totalKomponentenPrice: 0,
totalSavings: 0,
// Collapsible sections state
showQuestionForm: false,
showRatingForm: false,
showAvailabilityForm: false
};
} else if (partialProduct && isUpgrading) {
// Partial product data found - enter upgrading state
console.log("ProductDetailPage: Found partial product data, entering upgrading state");
// Clean up prerender fallback since we have some data
if (typeof window !== "undefined" && window.__PRERENDER_FALLBACK__) {
delete window.__PRERENDER_FALLBACK__;
console.log("ProductDetailPage: Cleaned up prerender fallback using partial product data");
}
// Initialize komponenten from partial product data if available
const komponenten = [];
if(partialProduct.komponenten) {
for(const komponent of partialProduct.komponenten.split(",")) {
// Handle both "x" and "×" as separators
const [id, count] = komponent.split(/[x×]/);
komponenten.push({id: id.trim(), count: count.trim()});
}
}
this.state = {
product: partialProduct,
loading: false,
upgrading: true, // This indicates we have partial data and are loading complete data
error: null,
attributeImages: {},
attributes: [], // Will be loaded when upgrading
isSteckling: false,
imageDialogOpen: false,
komponenten: komponenten,
komponentenLoaded: komponenten.length === 0, // If no komponenten, mark as loaded
komponentenData: {}, // Store individual komponent data with loading states
komponentenImages: {}, // Store tiny pictures for komponenten
totalKomponentenPrice: 0,
totalSavings: 0,
// Collapsible sections state
showQuestionForm: false,
showRatingForm: false,
showAvailabilityForm: false
};
} else {
// No cached data found - full loading state
this.state = {
product: null,
loading: true,
upgrading: false,
error: null,
attributeImages: {},
attributes: [],
isSteckling: false,
imageDialogOpen: false,
komponenten: [],
komponentenLoaded: false,
komponentenData: {}, // Store individual komponent data with loading states
komponentenImages: {}, // Store tiny pictures for komponenten
totalKomponentenPrice: 0,
totalSavings: 0,
// Collapsible sections state
showQuestionForm: false,
showRatingForm: false,
showAvailabilityForm: false
};
}
}
componentDidMount() {
// Load product data if we have no product or if we're in upgrading state
if (!this.state.product || this.state.upgrading) {
this.loadProductData();
} else {
// Product is cached, but we still need to load komponenten if they exist
if (this.state.komponenten.length > 0 && !this.state.komponentenLoaded) {
for(const komponent of this.state.komponenten) {
this.loadKomponent(komponent.id, komponent.count);
}
}
}
}
componentDidUpdate(prevProps) {
// Check for seoName changes
if (prevProps.seoName !== this.props.seoName) {
this.setState(
{ product: null, loading: true, upgrading: false, error: null, imageDialogOpen: false },
this.loadProductData
);
return;
}
// Check for language changes
const prevLanguage = prevProps.languageContext?.currentLanguage || prevProps.i18n?.language || 'de';
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
if (prevLanguage !== currentLanguage) {
console.log('Language changed from', prevLanguage, 'to', currentLanguage, '- reloading product data');
// Clear caches globally to force fresh socket calls with new language
if (typeof window !== 'undefined') {
window.productCache = {};
window.productDetailCache = {};
}
// Reset component state and reload data
this.setState(
{
loading: false,
upgrading: false,
error: null,
imageDialogOpen: false,
komponentenData: {},
komponentenLoaded: false,
totalKomponentenPrice: 0,
totalSavings: 0
},
this.loadProductData
);
}
}
loadKomponentImage = (komponentId, pictureList) => {
// Initialize cache if it doesn't exist
if (!window.smallPicCache) {
window.smallPicCache = {};
}
// Skip if no pictureList
if (!pictureList || pictureList.length === 0) {
return;
}
// Get the first image ID from pictureList
const bildId = pictureList.split(',')[0];
// Check if already cached
if (window.smallPicCache[bildId]) {
this.setState(prevState => ({
komponentenImages: {
...prevState.komponentenImages,
[komponentId]: window.smallPicCache[bildId]
}
}));
return;
}
// Fetch image from server
console.log('loadKomponentImage', bildId);
window.socketManager.emit('getPic', { bildId, size: 'small' }, (res) => {
if (res.success) {
// Cache the image
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
// Update state
this.setState(prevState => ({
komponentenImages: {
...prevState.komponentenImages,
[komponentId]: window.smallPicCache[bildId]
}
}));
} else {
console.log('Error loading komponent image:', res);
}
});
}
loadKomponent = (id, count) => {
// Initialize cache if it doesn't exist
if (!window.productDetailCache) {
window.productDetailCache = {};
}
// Check if this komponent is already cached
if (window.productDetailCache[id]) {
const cachedProduct = window.productDetailCache[id];
// Load komponent image if available
if (cachedProduct.pictureList) {
this.loadKomponentImage(id, cachedProduct.pictureList);
}
// Update state with cached data
this.setState(prevState => {
const newKomponentenData = {
...prevState.komponentenData,
[id]: {
...cachedProduct,
count: parseInt(count),
loaded: true
}
};
// Check if all remaining komponenten are loaded
const allLoaded = prevState.komponenten.every(k =>
newKomponentenData[k.id] && newKomponentenData[k.id].loaded
);
// Calculate totals if all loaded
let totalKomponentenPrice = 0;
let totalSavings = 0;
if (allLoaded) {
totalKomponentenPrice = prevState.komponenten.reduce((sum, k) => {
const komponentData = newKomponentenData[k.id];
if (komponentData && komponentData.loaded) {
return sum + (komponentData.price * parseInt(k.count));
}
return sum;
}, 0);
// Calculate savings (difference between buying individually vs as set)
const setPrice = prevState.product ? prevState.product.price : 0;
totalSavings = Math.max(0, totalKomponentenPrice - setPrice);
}
console.log("Cached komponent loaded:", id, "data:", newKomponentenData[id]);
console.log("All loaded (cached):", allLoaded);
return {
komponentenData: newKomponentenData,
komponentenLoaded: allLoaded,
totalKomponentenPrice,
totalSavings
};
});
return;
}
// Mark this komponent as loading
this.setState(prevState => ({
komponentenData: {
...prevState.komponentenData,
[id]: {
...prevState.komponentenData[id],
loading: true,
loaded: false,
count: parseInt(count)
}
}
}));
console.log('loadKomponent', id, count);
const currentLanguage = this.props.languageContext?.currentLanguage
const currentLanguage2 = this.props.i18n?.language || 'de';
console.log('debuglanguage', currentLanguage, currentLanguage2);
window.socketManager.emit(
"getProductView",
{ articleId: id, language: currentLanguage, requestTranslation: currentLanguage === "de" ? false : true},
(res) => {
if (res.success) {
// Use translated product if available, otherwise use original product
const productData = res.translatedProduct || res.product;
// Cache the successful response
window.productDetailCache[id] = productData;
// Load komponent image if available
if (productData.pictureList) {
this.loadKomponentImage(id, productData.pictureList);
}
// Update state with loaded data
this.setState(prevState => {
const newKomponentenData = {
...prevState.komponentenData,
[id]: {
...productData,
count: parseInt(count),
loading: false,
loaded: true
}
};
// Check if all remaining komponenten are loaded
const allLoaded = prevState.komponenten.every(k =>
newKomponentenData[k.id] && newKomponentenData[k.id].loaded
);
// Calculate totals if all loaded
let totalKomponentenPrice = 0;
let totalSavings = 0;
if (allLoaded) {
totalKomponentenPrice = prevState.komponenten.reduce((sum, k) => {
const komponentData = newKomponentenData[k.id];
if (komponentData && komponentData.loaded) {
return sum + (komponentData.price * parseInt(k.count));
}
return sum;
}, 0);
// Calculate savings (difference between buying individually vs as set)
const setPrice = prevState.product ? prevState.product.price : 0;
totalSavings = Math.max(0, totalKomponentenPrice - setPrice);
}
console.log("Updated komponentenData for", id, ":", newKomponentenData[id]);
console.log("All loaded:", allLoaded);
return {
komponentenData: newKomponentenData,
komponentenLoaded: allLoaded,
totalKomponentenPrice,
totalSavings
};
});
console.log("getProductView (komponent)", res);
} else {
console.error("Error loading komponent:", res.error || "Unknown error", res);
// Remove failed komponent from the list and check if all remaining are loaded
this.setState(prevState => {
const newKomponenten = prevState.komponenten.filter(k => k.id !== id);
const newKomponentenData = { ...prevState.komponentenData };
// Remove failed komponent from data
delete newKomponentenData[id];
// Check if all remaining komponenten are loaded
const allLoaded = newKomponenten.length === 0 || newKomponenten.every(k =>
newKomponentenData[k.id] && newKomponentenData[k.id].loaded
);
// Calculate totals if all loaded
let totalKomponentenPrice = 0;
let totalSavings = 0;
if (allLoaded && newKomponenten.length > 0) {
totalKomponentenPrice = newKomponenten.reduce((sum, k) => {
const komponentData = newKomponentenData[k.id];
if (komponentData && komponentData.loaded) {
return sum + (komponentData.price * parseInt(k.count));
}
return sum;
}, 0);
// Calculate savings (difference between buying individually vs as set)
const setPrice = this.state.product ? this.state.product.price : 0;
totalSavings = Math.max(0, totalKomponentenPrice - setPrice);
}
console.log("Removed failed komponent:", id, "remaining:", newKomponenten.length);
console.log("All loaded after removal:", allLoaded);
return {
komponenten: newKomponenten,
komponentenData: newKomponentenData,
komponentenLoaded: allLoaded,
totalKomponentenPrice,
totalSavings
};
});
}
}
);
}
loadProductData = () => {
console.log('loadProductData', this.props.seoName);
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
console.log('debuglanguage', currentLanguage);
window.socketManager.emit(
"getProductView",
{ seoName: this.props.seoName, language: currentLanguage, requestTranslation: currentLanguage === "de" ? false : true},
(res) => {
if (res.success) {
// Use translated product if available, otherwise use original product
const productData = res.translatedProduct || res.product;
productData.seoName = this.props.seoName;
// Initialize cache if it doesn't exist
if (!window.productDetailCache) {
window.productDetailCache = {};
}
// Cache the complete response data (product + attributes) - cache the response with translated product
const cacheData = { ...res, product: productData };
window.productDetailCache[this.props.seoName] = cacheData;
// Clean up prerender fallback since we now have real data
if (typeof window !== "undefined" && window.__PRERENDER_FALLBACK__) {
delete window.__PRERENDER_FALLBACK__;
console.log("ProductDetailPage: Cleaned up prerender fallback after loading product data");
}
const komponenten = [];
if(productData.komponenten) {
for(const komponent of productData.komponenten.split(",")) {
// Handle both "x" and "×" as separators
const [id, count] = komponent.split(/[x×]/);
komponenten.push({id: id.trim(), count: count.trim()});
}
}
this.setState({
product: productData,
loading: false,
upgrading: false, // Clear upgrading state since we now have complete data
error: null,
imageDialogOpen: false,
attributes: res.attributes,
komponenten: komponenten,
komponentenLoaded: komponenten.length === 0 // If no komponenten, mark as loaded
}, () => {
if(komponenten.length > 0) {
for(const komponent of komponenten) {
this.loadKomponent(komponent.id, komponent.count);
}
}
});
console.log("getProductView", res);
// Initialize window-level attribute image cache if it doesn't exist
if (!window.attributeImageCache) {
window.attributeImageCache = {};
}
if (res.attributes && res.attributes.length > 0) {
const attributeImages = {};
for (const attribute of res.attributes) {
const cacheKey = attribute.kMerkmalWert;
if (attribute.cName == "Anzahl")
this.setState({ isSteckling: true });
// Check if we have a cached result (either URL or negative result)
if (window.attributeImageCache[cacheKey]) {
const cached = window.attributeImageCache[cacheKey];
if (cached.url) {
// Use cached URL
attributeImages[cacheKey] = cached.url;
}
} else {
// Not in cache, fetch from server
console.log('getAttributePicture', cacheKey);
window.socketManager.emit(
"getAttributePicture",
{ id: cacheKey },
(res) => {
console.log("getAttributePicture", res);
if (res.success && !res.noPicture) {
const blob = new Blob([res.imageBuffer], {
type: "image/jpeg",
});
const url = URL.createObjectURL(blob);
// Cache the successful URL
window.attributeImageCache[cacheKey] = {
url: url,
timestamp: Date.now(),
};
// Update state and force re-render
this.setState(prevState => ({
attributeImages: {
...prevState.attributeImages,
[cacheKey]: url
}
}));
} else {
// Cache negative result to avoid future requests
// This handles both failure cases and success with noPicture: true
window.attributeImageCache[cacheKey] = {
noImage: true,
timestamp: Date.now(),
};
}
}
);
}
}
// Set initial state with cached images
if (Object.keys(attributeImages).length > 0) {
this.setState({ attributeImages });
}
}
} else {
console.error(
"Error loading product:",
res.error || "Unknown error",
res
);
this.setState({
product: null,
loading: false,
error: "Error loading product",
imageDialogOpen: false,
});
}
}
);
};
handleOpenDialog = () => {
this.setState({ imageDialogOpen: true });
};
handleCloseDialog = () => {
this.setState({ imageDialogOpen: false });
};
toggleQuestionForm = () => {
this.setState(prevState => ({
showQuestionForm: !prevState.showQuestionForm,
showRatingForm: false,
showAvailabilityForm: false
}), () => {
if (this.state.showQuestionForm) {
setTimeout(() => this.scrollToSection('question-form'), 100);
}
});
};
toggleRatingForm = () => {
this.setState(prevState => ({
showRatingForm: !prevState.showRatingForm,
showQuestionForm: false,
showAvailabilityForm: false
}), () => {
if (this.state.showRatingForm) {
setTimeout(() => this.scrollToSection('rating-form'), 100);
}
});
};
toggleAvailabilityForm = () => {
this.setState(prevState => ({
showAvailabilityForm: !prevState.showAvailabilityForm,
showQuestionForm: false,
showRatingForm: false
}), () => {
if (this.state.showAvailabilityForm) {
setTimeout(() => this.scrollToSection('availability-form'), 100);
}
});
};
scrollToSection = (sectionId) => {
const element = document.getElementById(sectionId);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
};
render() {
const { product, loading, upgrading, error, attributeImages, isSteckling, attributes, komponentenLoaded, komponentenData, komponentenImages, totalKomponentenPrice, totalSavings } =
this.state;
// Debug alerts removed
if (loading && !upgrading) {
// Only show full loading screen when we have no product data at all
// Check if prerender fallback is available
if (typeof window !== "undefined" && window.__PRERENDER_FALLBACK__) {
return (
<div
dangerouslySetInnerHTML={{
__html: window.__PRERENDER_FALLBACK__.content,
}}
/>
);
}
// Fallback to loading message if no prerender content
return (
<Box
sx={{
p: 4,
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<Typography variant="h2" component="h2" gutterBottom>
Produkt wird geladen...
</Typography>
</Box>
);
}
if (error) {
return (
<Box sx={{ p: 4, textAlign: "center" }}>
<Typography variant="h2" component="h2" gutterBottom color="error">
Fehler
</Typography>
<Typography>{error}</Typography>
<Link to="/" style={{ textDecoration: "none" }}>
<Typography color="primary" sx={{ mt: 2 }}>
{this.props.t ? this.props.t('product.backToHome') : 'Zurück zur Startseite'}
</Typography>
</Link>
</Box>
);
}
if (!product) {
return (
<Box sx={{ p: 4, textAlign: "center" }}>
<Typography variant="h2" component="h2" gutterBottom>
Produkt nicht gefunden
</Typography>
<Typography>
Das gesuchte Produkt existiert nicht oder wurde entfernt.
</Typography>
<Link to="/" style={{ textDecoration: "none" }}>
<Typography color="primary" sx={{ mt: 2 }}>
{this.props.t ? this.props.t('product.backToHome') : 'Zurück zur Startseite'}
</Typography>
</Link>
</Box>
);
}
// Format price with tax
const priceWithTax = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(product.price);
return (
<Container
maxWidth="lg"
sx={{
p: { xs: 2, md: 2 },
pb: { xs: 4, md: 8 },
flexGrow: 1
}}
>
{/* Breadcrumbs */}
<Box
sx={{
mb: 2,
position: ["-webkit-sticky", "sticky"], // Provide both prefixed and standard
top: {
xs: "80px",
sm: "80px",
md: "80px",
lg: "80px",
} /* Offset to sit below the header 120 mith menu for md and lg*/,
left: 0,
width: "100%",
display: "flex",
zIndex: (theme) =>
theme.zIndex.appBar - 1 /* Just below the AppBar */,
py: 0,
px: 2,
}}
>
<Box
sx={{
ml: { xs: 0, md: 0 },
display: "inline-flex",
px: 0,
py: 1,
backgroundColor: "#2e7d32", //primary dark green
borderRadius: 1,
}}
>
<Typography variant="body2" color="text.secondary">
<Link
to="/"
onClick={() => this.props.navigate(-1)}
style={{
paddingLeft: 16,
paddingRight: 16,
paddingTop: 8,
paddingBottom: 8,
textDecoration: "none",
color: "#fff",
fontWeight: "bold",
}}
>
{this.props.t ? this.props.t('common.back') : 'Zurück'}
</Link>
</Typography>
</Box>
</Box>
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", md: "row" },
gap: 4,
background: "#fff",
borderRadius: 2,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
}}
>
<ProductImage
product={product}
fullscreenOpen={this.state.imageDialogOpen}
onOpenFullscreen={this.handleOpenDialog}
onCloseFullscreen={this.handleCloseDialog}
/>
{/* Product Details */}
<Box
sx={{
flex: "1 1 60%",
p: { xs: 2, md: 4 },
display: "flex",
flexDirection: "column",
}}
>
{/* Product identifiers */}
<Box sx={{ mb: 1 }}>
<Typography variant="body2" color="text.secondary">
{this.props.t ? this.props.t('product.articleNumber') : 'Artikelnummer'}: {product.articleNumber} {product.gtin ? ` | GTIN: ${product.gtin}` : ""}
</Typography>
</Box>
{/* Product title */}
<Typography
variant="h4"
component="h1"
gutterBottom
sx={{ fontWeight: 600, color: "#333" }}
>
{cleanProductName(product.name)}
</Typography>
{/* Manufacturer if available */}
{product.manufacturer && (
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Typography variant="body2" sx={{ fontStyle: "italic" }}>
{this.props.t ? this.props.t('product.manufacturer') : 'Hersteller'}: {product.manufacturer}
</Typography>
</Box>
)}
{/* Attribute images and chips with action buttons */}
{(attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert])) && (
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2, gap: 2 }}>
<Stack direction="row" spacing={0} sx={{ flexWrap: "wrap", gap: 1, flex: 1 }}>
{attributes
.filter(attribute => attributeImages[attribute.kMerkmalWert])
.map((attribute) => {
const key = attribute.kMerkmalWert;
return (
<Box key={key} sx={{ mb: 1,border: "1px solid #e0e0e0", borderRadius: 1 }}>
<CardMedia
component="img"
style={{ width: "72px", height: "98px" }}
image={attributeImages[key]}
alt={`Attribute ${key}`}
/>
</Box>
);
})}
{attributes
.filter(attribute => !attributeImages[attribute.kMerkmalWert])
.map((attribute) => (
<Chip
key={attribute.kMerkmalWert}
label={attribute.cWert}
disabled
sx={{
'&.Mui-disabled': {
opacity: 1, // ← Remove the "fog"
},
'& .MuiChip-label': {
fontWeight: 'bold',
color: 'inherit', // ← Keep normal text color
},
}}
/>
))}
</Stack>
{/* Right-aligned action buttons */}
<Stack direction="column" spacing={1} sx={{ flexShrink: 0 }}>
<Button
variant="outlined"
size="small"
onClick={this.toggleQuestionForm}
sx={{
fontSize: "0.75rem",
px: 1.5,
py: 0.5,
minWidth: "auto",
whiteSpace: "nowrap"
}}
>
Frage zum Artikel
</Button>
<Button
variant="outlined"
size="small"
onClick={this.toggleRatingForm}
sx={{
fontSize: "0.75rem",
px: 1.5,
py: 0.5,
minWidth: "auto",
whiteSpace: "nowrap"
}}
>
Artikel Bewerten
</Button>
{(product.available !== 1 && product.availableSupplier !== 1) && (
<Button
variant="outlined"
size="small"
onClick={this.toggleAvailabilityForm}
sx={{
fontSize: "0.75rem",
px: 1.5,
py: 0.5,
minWidth: "auto",
whiteSpace: "nowrap",
borderColor: "warning.main",
color: "warning.main",
"&:hover": {
borderColor: "warning.dark",
backgroundColor: "warning.light"
}
}}
>
Verfügbarkeit anfragen
</Button>
)}
</Stack>
</Box>
)}
{/* Weight */}
{product.weight > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="body2" color="text.secondary">
{this.props.t ? this.props.t('product.weight', { weight: product.weight.toFixed(1).replace(".", ",") }) : `Gewicht: ${product.weight.toFixed(1).replace(".", ",")} kg`}
</Typography>
</Box>
)}
{/* Price and availability section */}
<Box
sx={{
mt: "auto",
p: 3,
background: "#f9f9f9",
borderRadius: 2,
}}
>
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", sm: "row" },
justifyContent: "space-between",
alignItems: { xs: "flex-start", sm: "flex-start" },
gap: 2,
}}
>
<Box>
<Typography
variant="h5"
color="primary"
sx={{ fontWeight: "bold" }}
>
{priceWithTax}
</Typography>
<Typography variant="body2" color="text.secondary">
{this.props.t ? this.props.t('product.inclVat', { vat: product.vat }) : `inkl. ${product.vat}% MwSt.`}
{product.cGrundEinheit && product.fGrundPreis && (
<>; {new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(product.fGrundPreis)}/{product.cGrundEinheit}</>
)}
</Typography>
{product.versandklasse &&
product.versandklasse != "standard" &&
product.versandklasse != "kostenlos" && (
<Typography variant="body2" color="text.secondary">
{product.versandklasse}
</Typography>
)}
</Box>
{/* Savings comparison - positioned between price and cart button */}
{product.komponenten && komponentenLoaded && totalKomponentenPrice > product.price &&
(totalKomponentenPrice - product.price >= 2 &&
(totalKomponentenPrice - product.price) / product.price >= 0.02) && (
<Box sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minWidth: { xs: "100%", sm: "200px" }
}}>
<Box sx={{ p: 2, borderRadius: 1, backgroundColor: "#e8f5e8", textAlign: "center" }}>
<Typography
variant="body2"
sx={{
fontWeight: "bold",
color: "success.main"
}}
>
{this.props.t ? this.props.t('product.youSave', {
amount: new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(totalKomponentenPrice - product.price)
}) : `Sie sparen: ${new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(totalKomponentenPrice - product.price)}`}
</Typography>
<Typography variant="caption" color="text.secondary">
{this.props.t ? this.props.t('product.cheaperThanIndividual') : 'Günstiger als Einzelkauf'}
</Typography>
</Box>
</Box>
)}
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", sm: "row" },
gap: 2,
alignItems: "flex-start",
}}
>
{isSteckling && product.available == 1 && (
<Box
sx={{
display: "flex",
flexDirection: "column",
}}
>
<AddToCartButton
steckling={true}
cartButton={true}
seoName={product.seoName}
pictureList={product.pictureList}
available={product.available}
id={product.id + "steckling"}
price={0}
vat={product.vat}
weight={product.weight}
availableSupplier={product.availableSupplier}
komponenten={product.komponenten}
name={cleanProductName(product.name) + " Stecklingsvorbestellung 1 Stück"}
versandklasse={"nur Abholung"}
/>
<Typography
variant="caption"
sx={{
fontStyle: "italic",
color: "text.secondary",
textAlign: "center",
mt: 1
}}
>
{this.props.t ? this.props.t('product.pickupPrice') : 'Abholpreis: 19,90 € pro Steckling.'}
</Typography>
</Box>
)}
<Box
sx={{
display: "flex",
flexDirection: "column",
}}
>
<AddToCartButton
cartButton={true}
seoName={product.seoName}
pictureList={product.pictureList}
available={product.available}
id={product.id}
availableSupplier={product.availableSupplier}
komponenten={product.komponenten}
cGrundEinheit={product.cGrundEinheit}
fGrundPreis={product.fGrundPreis}
price={product.price}
vat={product.vat}
weight={product.weight}
name={cleanProductName(product.name)}
versandklasse={product.versandklasse}
/>
<Typography
variant="caption"
sx={{
fontStyle: "italic",
color: "text.secondary",
textAlign: "center",
mt: 1
}}
>
{product.id.toString().endsWith("steckling") ?
(this.props.t ? this.props.t('delivery.times.cutting14Days') : "Lieferzeit: 14 Tage") :
product.available == 1 ?
(this.props.t ? this.props.t('delivery.times.standard2to3Days') : "Lieferzeit: 2-3 Tage") :
product.availableSupplier == 1 ?
(this.props.t ? this.props.t('delivery.times.supplier7to9Days') : "Lieferzeit: 7-9 Tage") : ""}
</Typography>
</Box>
</Box>
</Box>
</Box>
</Box>
</Box>
{/* Product full description */}
{(product.description || upgrading) && (
<Box
sx={{
mt: 4,
p: 4,
background: "#fff",
borderRadius: 2,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
}}
>
<Box
sx={{
mt: 2,
lineHeight: 1.7,
"& p": { mt: 0, mb: 2 },
"& strong": { fontWeight: 600 },
}}
>
{product.description ? (
parse(product.description)
) : upgrading ? (
<Box sx={{ textAlign: "center", py: 2 }}>
<Typography variant="body1" color="text.secondary">
{this.props.t ? this.props.t('product.loadingDescription') : 'Produktbeschreibung wird geladen...'}
</Typography>
</Box>
) : null}
</Box>
</Box>
)}
{/* Article Question Form */}
<Collapse in={this.state.showQuestionForm}>
<div id="question-form">
<ArticleQuestionForm
productId={product.id}
productName={cleanProductName(product.name)}
/>
</div>
</Collapse>
{/* Article Rating Form */}
<Collapse in={this.state.showRatingForm}>
<div id="rating-form">
<ArticleRatingForm
productId={product.id}
productName={cleanProductName(product.name)}
/>
</div>
</Collapse>
{/* Article Availability Form - only show for out of stock items */}
{(product.available !== 1 && product.availableSupplier !== 1) && (
<Collapse in={this.state.showAvailabilityForm}>
<ArticleAvailabilityForm
productId={product.id}
productName={cleanProductName(product.name)}
/>
</Collapse>
)}
{product.komponenten && product.komponenten.split(",").length > 0 && (
<Box sx={{ mt: 4, p: 4, background: "#fff", borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
<Typography variant="h2" component="h2" gutterBottom>{this.props.t ? this.props.t('product.consistsOf') : 'Bestehend aus:'}</Typography>
<Box sx={{ maxWidth: 800, mx: "auto" }}>
{(console.log("komponentenLoaded:", komponentenLoaded), komponentenLoaded) ? (
<>
{console.log("Rendering loaded komponenten:", this.state.komponenten.length, "komponentenData:", Object.keys(komponentenData).length)}
{this.state.komponenten.map((komponent, index) => {
const komponentData = komponentenData[komponent.id];
console.log(`Rendering komponent ${komponent.id}:`, komponentData);
// Don't show border on last item (pricing section has its own top border)
const isLastItem = index === this.state.komponenten.length - 1;
const showBorder = !isLastItem;
if (!komponentData || !komponentData.loaded) {
return (
<Box key={komponent.id} sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
py: 1,
borderBottom: showBorder ? "1px solid #eee" : "none",
minHeight: "70px" // Consistent height to prevent layout shifts
}}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ width: 50, height: 50, flexShrink: 0, backgroundColor: "#f5f5f5", borderRadius: 1, border: "1px solid #e0e0e0" }}>
{/* Empty placeholder for image */}
</Box>
<Box>
<Typography variant="body1">
{index + 1}. Lädt...
</Typography>
<Typography variant="body2" color="text.secondary">
{komponent.count}x
</Typography>
</Box>
</Box>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
-
</Typography>
</Box>
);
}
const itemPrice = komponentData.price * parseInt(komponent.count);
const formattedPrice = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(itemPrice);
return (
<Box
key={komponent.id}
component={Link}
to={`/Artikel/${komponentData.seoName}`}
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
py: 1,
borderBottom: showBorder ? "1px solid #eee" : "none",
textDecoration: "none",
color: "inherit",
minHeight: "70px", // Consistent height to prevent layout shifts
"&:hover": {
backgroundColor: "#f5f5f5"
}
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ width: 50, height: 50, flexShrink: 0 }}>
{komponentenImages[komponent.id] ? (
<CardMedia
component="img"
height="50"
image={komponentenImages[komponent.id]}
alt={komponentData.name}
sx={{
objectFit: "contain",
borderRadius: 1,
border: "1px solid #e0e0e0"
}}
/>
) : (
<CardMedia
component="img"
height="50"
image="/assets/images/nopicture.jpg"
alt={komponentData.name}
sx={{
objectFit: "contain",
borderRadius: 1,
border: "1px solid #e0e0e0"
}}
/>
)}
</Box>
<Box>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{index + 1}. {cleanProductName(komponentData.name)}
</Typography>
<Typography variant="body2" color="text.secondary">
{komponent.count}x à {new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(komponentData.price)}
</Typography>
</Box>
</Box>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{formattedPrice}
</Typography>
</Box>
);
})}
{/* Total price and savings display - only show when prices differ meaningfully */}
{totalKomponentenPrice > product.price &&
(totalKomponentenPrice - product.price >= 2 &&
(totalKomponentenPrice - product.price) / product.price >= 0.02) && (
<Box sx={{ mt: 3, pt: 2, borderTop: "2px solid #eee" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}>
<Typography variant="h6">
{this.props.t ? this.props.t('product.individualPriceTotal') : 'Einzelpreis gesamt:'}
</Typography>
<Typography variant="h6" sx={{ textDecoration: "line-through", color: "text.secondary" }}>
{new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(totalKomponentenPrice)}
</Typography>
</Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}>
<Typography variant="h6">
{this.props.t ? this.props.t('product.setPrice') : 'Set-Preis:'}
</Typography>
<Typography variant="h6" color="primary" sx={{ fontWeight: "bold" }}>
{new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(product.price)}
</Typography>
</Box>
{totalSavings > 0 && (
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mt: 2, p: 2, backgroundColor: "#e8f5e8", borderRadius: 1 }}>
<Typography variant="h6" color="success.main" sx={{ fontWeight: "bold" }}>
{this.props.t ? this.props.t('product.yourSavings') : 'Ihre Ersparnis:'}
</Typography>
<Typography variant="h6" color="success.main" sx={{ fontWeight: "bold" }}>
{new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(totalSavings)}
</Typography>
</Box>
)}
</Box>
)}
</>
) : (
// Loading state
<Box>
{this.state.komponenten.map((komponent, index) => {
// For loading state, we don't know if pricing will be shown, so show all borders
return (
<Box key={komponent.id} sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
py: 1,
borderBottom: "1px solid #eee",
minHeight: "70px" // Consistent height to prevent layout shifts
}}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ width: 50, height: 50, flexShrink: 0, backgroundColor: "#f5f5f5", borderRadius: 1, border: "1px solid #e0e0e0" }}>
{/* Empty placeholder for image */}
</Box>
<Box>
<Typography variant="body1">
{this.props.t ? this.props.t('product.loadingComponentDetails', { index: index + 1 }) : `${index + 1}. Lädt Komponent-Details...`}
</Typography>
<Typography variant="body2" color="text.secondary">
{komponent.count}x
</Typography>
</Box>
</Box>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
-
</Typography>
</Box>
);
})}
</Box>
)}
</Box>
</Box>
)}
</Container>
);
}
}
export default withI18n()(ProductDetailPage);