Files
reactShop/src/components/ProductDetailPage.js
sebseb7 43e67ee4c4 feat(Context): integrate Product and Category context providers into App
- Wrapped AppContent with ProductContextProvider and CategoryContextProvider to manage product and category states.
- Added TitleUpdater component for dynamic title management.
- Enhanced Content and ProductDetailPage components to utilize the new context for setting and clearing current product and category states.
- Updated ProductDetailWithSocket to pass setCurrentProduct function from context.
2025-11-19 09:25:21 +01:00

2011 lines
75 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, Popper, ClickAwayListener, MenuList, MenuItem, ListItemIcon, ListItemText, Snackbar, Alert } from "@mui/material";
import ShareIcon from "@mui/icons-material/Share";
import WhatsAppIcon from "@mui/icons-material/WhatsApp";
import FacebookIcon from "@mui/icons-material/Facebook";
import TelegramIcon from "@mui/icons-material/Telegram";
import EmailIcon from "@mui/icons-material/Email";
import LinkIcon from "@mui/icons-material/Link";
import CodeIcon from "@mui/icons-material/Code";
import { Link } from "react-router-dom";
import parse from "html-react-parser";
import sanitizeHtml from "sanitize-html";
import AddToCartButton from "./AddToCartButton.js";
import ProductImage from "./ProductImage.js";
import Product from "./Product.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;
// Get current language for cache key
const currentLanguage = this.props.i18n?.language || 'de';
const cacheKey = `product_${this.props.seoName}_${currentLanguage}`;
if (window.productDetailCache && window.productDetailCache[cacheKey]) {
cachedData = window.productDetailCache[cacheKey];
} 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.product && cached.product.seoName === this.props.seoName) {
partialProduct = cached.product;
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,
// Embedded products from <product> tags in description
embeddedProducts: {},
embeddedProductImages: {},
// Collapsible sections state
showQuestionForm: false,
showRatingForm: false,
showAvailabilityForm: false,
// Share popper state
shareAnchorEl: null,
sharePopperOpen: false,
// Snackbar state
snackbarOpen: false,
snackbarMessage: "",
snackbarSeverity: "success",
// Similar products
similarProducts: cachedData.similarProducts || []
};
} 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,
// Embedded products from <product> tags in description
embeddedProducts: {},
embeddedProductImages: {},
// Collapsible sections state
showQuestionForm: false,
showRatingForm: false,
showAvailabilityForm: false,
// Share popper state
shareAnchorEl: null,
sharePopperOpen: false,
// Snackbar state
snackbarOpen: false,
snackbarMessage: "",
snackbarSeverity: "success",
// Similar products
similarProducts: []
};
} 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,
// Embedded products from <product> tags in description
embeddedProducts: {},
embeddedProductImages: {},
// Collapsible sections state
showQuestionForm: false,
showRatingForm: false,
showAvailabilityForm: false,
// Share popper state
shareAnchorEl: null,
sharePopperOpen: false,
// Snackbar state
snackbarOpen: false,
snackbarMessage: "",
snackbarSeverity: "success",
// Similar products
similarProducts: []
};
}
}
componentDidMount() {
// Update context with cached product if available
if (this.state.product && this.props.setCurrentProduct) {
console.log('ProductDetailPage: Setting product context from cache', this.state.product.name);
this.props.setCurrentProduct({
name: this.state.product.name,
categoryId: this.state.product.kategorien ? this.state.product.kategorien.split(',')[0] : undefined
});
} else if (this.state.product) {
console.warn('ProductDetailPage: setCurrentProduct prop is missing despite having product');
}
// 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 attribute images and komponenten
if (this.state.attributes && this.state.attributes.length > 0) {
this.loadAttributeImages(this.state.attributes);
}
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) {
// Clear context when navigating to new product
if (this.props.setCurrentProduct) {
this.props.setCurrentProduct(null);
}
this.setState(
{ product: null, loading: true, upgrading: false, error: null, imageDialogOpen: false, similarProducts: [] },
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,
similarProducts: []
},
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 = {};
}
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
const cacheKey = `product_${id}_${currentLanguage}`;
// Check if this komponent is already cached
if (window.productDetailCache[cacheKey]) {
const cachedProduct = window.productDetailCache[cacheKey];
// 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);
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[cacheKey] = 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
};
});
}
}
);
}
loadAttributeImages = (attributes) => {
// Initialize window-level attribute image cache if it doesn't exist
if (!window.attributeImageCache) {
window.attributeImageCache = {};
}
if (attributes && attributes.length > 0) {
const attributeImages = {};
for (const attribute of 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 });
}
}
}
loadProductData = () => {
console.log('loadProductData', this.props.seoName);
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
console.log('debuglanguage', currentLanguage);
const cacheKey = `product_${this.props.seoName}_${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[cacheKey] = 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
similarProducts: res.similarProducts || []
}, () => {
// Update context
if (this.props.setCurrentProduct) {
console.log('ProductDetailPage: Setting product context from fetch', productData.name);
this.props.setCurrentProduct({
name: productData.name,
categoryId: productData.kategorien ? productData.kategorien.split(',')[0] : undefined
});
} else {
console.warn('ProductDetailPage: setCurrentProduct prop is missing after fetch');
}
if(komponenten.length > 0) {
for(const komponent of komponenten) {
this.loadKomponent(komponent.id, komponent.count);
}
}
});
console.log("getProductView", res);
// Load attribute images
this.loadAttributeImages(res.attributes);
} 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'
});
}
};
// Share functionality
handleShareClick = (event) => {
this.setState({
shareAnchorEl: event.currentTarget,
sharePopperOpen: true
});
};
handleShareClose = () => {
this.setState({
shareAnchorEl: null,
sharePopperOpen: false
});
};
showSnackbar = (message, severity = "success") => {
this.setState({
snackbarOpen: true,
snackbarMessage: message,
snackbarSeverity: severity
});
};
handleSnackbarClose = (event, reason) => {
if (reason === 'clickaway') {
return;
}
this.setState({ snackbarOpen: false });
};
getProductUrl = () => {
return `${window.location.origin}/Artikel/${this.props.seoName}`;
};
handleEmbedShare = () => {
const embedCode = `<iframe src="${this.getProductUrl()}" width="100%" height="600" frameborder="0"></iframe>`;
navigator.clipboard.writeText(embedCode).then(() => {
this.showSnackbar("Einbettungscode wurde in die Zwischenablage kopiert!");
}).catch(() => {
// Fallback for older browsers
try {
const textArea = document.createElement('textarea');
textArea.value = embedCode;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
this.showSnackbar("Einbettungscode wurde in die Zwischenablage kopiert!");
} catch {
this.showSnackbar("Fehler beim Kopieren des Einbettungscodes", "error");
}
});
this.handleShareClose();
};
handleWhatsAppShare = () => {
const url = this.getProductUrl();
const text = `Schau dir dieses Produkt an: ${cleanProductName(this.state.product.name)}`;
const whatsappUrl = `https://wa.me/?text=${encodeURIComponent(text + ' ' + url)}`;
window.open(whatsappUrl, '_blank');
this.handleShareClose();
};
handleFacebookShare = () => {
const url = this.getProductUrl();
const facebookUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`;
window.open(facebookUrl, '_blank');
this.handleShareClose();
};
handleTelegramShare = () => {
const url = this.getProductUrl();
const text = `Schau dir dieses Produkt an: ${cleanProductName(this.state.product.name)}`;
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`;
window.open(telegramUrl, '_blank');
this.handleShareClose();
};
handleEmailShare = () => {
const url = this.getProductUrl();
const subject = `Produktempfehlung: ${cleanProductName(this.state.product.name)}`;
const body = `Hallo,\n\nich möchte dir dieses Produkt empfehlen:\n\n${cleanProductName(this.state.product.name)}\n${url}\n\nViele Grüße`;
const emailUrl = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
window.location.href = emailUrl;
this.handleShareClose();
};
handleLinkCopy = () => {
const url = this.getProductUrl();
navigator.clipboard.writeText(url).then(() => {
this.showSnackbar("Link wurde in die Zwischenablage kopiert!");
}).catch(() => {
// Fallback for older browsers
try {
const textArea = document.createElement('textarea');
textArea.value = url;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
this.showSnackbar("Link wurde in die Zwischenablage kopiert!");
} catch {
this.showSnackbar("Fehler beim Kopieren des Links", "error");
}
});
this.handleShareClose();
};
// Render embedded product from <product articlenr="..."> tag in description
renderEmbeddedProduct = (articleNr) => {
console.log('renderEmbeddedProduct called with articleNr:', articleNr);
// Check if we already have this product data in state
const embeddedProducts = this.state.embeddedProducts || {};
const productData = embeddedProducts[articleNr];
console.log('Embedded product data:', productData);
// If there was an error loading, show error message (don't retry infinitely)
if (productData && productData.error) {
return (
<Box
key={`embedded-${articleNr}`}
sx={{
my: 2,
p: 2,
background: "#fff3f3",
borderRadius: 2,
border: "1px solid #ffcdd2"
}}
>
<Typography variant="body2" color="error">
Produkt nicht gefunden (Artikelnr: {articleNr})
</Typography>
</Box>
);
}
if (!productData || !productData.loaded) {
// If not loaded yet and not currently loading, fetch it
if (!productData || (!productData.loading && !productData.error)) {
console.log('Starting to load embedded product:', articleNr);
this.loadEmbeddedProduct(articleNr);
}
// Return loading state
return (
<Box
key={`embedded-${articleNr}`}
sx={{
my: 2,
p: 2,
background: "#f9f9f9",
borderRadius: 2,
border: "1px solid #e0e0e0"
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ width: 60, height: 60, flexShrink: 0, backgroundColor: "#f5f5f5", borderRadius: 1, border: "1px solid #e0e0e0" }}>
{/* Empty placeholder for image */}
</Box>
<Box>
<Typography variant="body1">
{this.props.t('product.loadingProduct')}
</Typography>
<Typography variant="body2" color="text.secondary">
{this.props.t('product.articleNumber')}: {articleNr}
</Typography>
</Box>
</Box>
</Box>
);
}
// Product data is loaded, render it
const embeddedImages = this.state.embeddedProductImages || {};
const productImage = embeddedImages[articleNr];
return (
<Box
key={`embedded-${articleNr}`}
component={Link}
to={`/Artikel/${productData.seoName}`}
sx={{
display: "block",
my: 2,
p: 2,
background: "#f9f9f9",
borderRadius: 2,
border: "1px solid #e0e0e0",
textDecoration: "none",
color: "inherit",
transition: "all 0.2s",
"&:hover": {
backgroundColor: "#f0f0f0",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)"
}
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ width: 60, height: 60, flexShrink: 0 }}>
{productImage ? (
<CardMedia
component="img"
height="60"
image={productImage}
alt={productData.name}
sx={{
objectFit: "contain",
borderRadius: 1,
border: "1px solid #e0e0e0"
}}
/>
) : (
<CardMedia
component="img"
height="60"
image="/assets/images/nopicture.jpg"
alt={productData.name}
sx={{
objectFit: "contain",
borderRadius: 1,
border: "1px solid #e0e0e0"
}}
/>
)}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="body1" sx={{ fontWeight: 500, mb: 0.5 }}>
{cleanProductName(productData.name)}
</Typography>
<Typography variant="body2" color="text.secondary">
{this.props.t('product.articleNumber')}: {productData.articleNumber}
</Typography>
</Box>
<Box sx={{ textAlign: "right" }}>
<Typography variant="h6" sx={{ fontWeight: 600, color: "primary.main" }}>
{new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(productData.price)}
</Typography>
<Typography variant="caption" color="text.secondary">
inkl. MwSt.
</Typography>
</Box>
</Box>
</Box>
);
};
// Load embedded product data by article number
loadEmbeddedProduct = (articleNr) => {
console.log('loadEmbeddedProduct', articleNr);
// Mark as loading
this.setState(prevState => ({
embeddedProducts: {
...prevState.embeddedProducts,
[articleNr]: { loading: true, loaded: false }
}
}));
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
// Fetch product data from API using getProductView (same as komponenten)
window.socketManager.emit('getProductView', {
articleNr: articleNr,
language: currentLanguage,
requestTranslation: currentLanguage === 'de' ? false : true
}, (response) => {
console.log('loadEmbeddedProduct response:', articleNr, response);
if (response.success && response.product) {
// Use translated product if available, otherwise use original product
const product = response.translatedProduct || response.product;
console.log('Successfully loaded embedded product:', articleNr, product.name);
// Update state with loaded product data
this.setState(prevState => ({
embeddedProducts: {
...prevState.embeddedProducts,
[articleNr]: {
...product,
loading: false,
loaded: true
}
}
}));
// Load product image if available
if (product.pictureList && product.pictureList.length > 0) {
const bildId = product.pictureList.split(',')[0];
this.loadEmbeddedProductImage(articleNr, bildId);
}
} else {
console.warn(`Failed to load embedded product ${articleNr}:`, response);
// Mark as failed to load
this.setState(prevState => ({
embeddedProducts: {
...prevState.embeddedProducts,
[articleNr]: {
loading: false,
loaded: false,
error: true,
errorMessage: response.error || 'Unknown error'
}
}
}));
}
});
};
// Load embedded product image
loadEmbeddedProductImage = (articleNr, bildId) => {
console.log('loadEmbeddedProductImage', articleNr, bildId);
window.socketManager.emit('getPic', { bildId, size: 'small' }, (res) => {
console.log('loadEmbeddedProductImage response:', articleNr, res.success);
if (res.success) {
const imageUrl = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
this.setState(prevState => {
console.log('Setting embedded product image for', articleNr);
return {
embeddedProductImages: {
...prevState.embeddedProductImages,
[articleNr]: imageUrl
}
};
});
}
});
};
componentWillUnmount() {
if (this.props.setCurrentProduct) {
this.props.setCurrentProduct(null);
}
}
render() {
const { product, loading, upgrading, error, attributeImages, isSteckling, attributes, komponentenLoaded, komponentenData, komponentenImages, totalKomponentenPrice, totalSavings, shareAnchorEl, sharePopperOpen, snackbarOpen, snackbarMessage, snackbarSeverity } =
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 blank page if no prerender content
return <div style={{ minHeight: "60vh" }} />;
}
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);
let originalPriceWithTax = null;
if (product.rebate && product.rebate > 0) {
const rebatePct = product.rebate / 100;
const originalPrice = Math.round((product.price / (1 - rebatePct)) * 10) / 10;
originalPriceWithTax = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(originalPrice);
}
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: "110px",
sm: "110px",
md: "110px",
lg: "110px",
} /* 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'}:
<Button
variant="text"
size="small"
onClick={() => {
if (this.props.navigate) {
this.props.navigate(`/search?q=${encodeURIComponent(product.manufacturer)}`);
}
}}
sx={{
ml: 0.5,
p: 0,
minWidth: 'auto',
height: 'auto',
fontSize: 'inherit',
fontStyle: 'inherit',
textTransform: 'none',
'&:hover': {
backgroundColor: 'transparent',
textDecoration: 'underline',
}
}}
>
{product.manufacturer}
</Button>
</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" }}
>
{originalPriceWithTax && (
<Typography
component="span"
variant="h6"
sx={{
textDecoration: 'line-through',
color: 'text.secondary',
mr: 1,
display: 'inline-block'
}}
>
{originalPriceWithTax}
</Typography>
)}
{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)",
position: "relative",
}}
>
{/* Share button */}
<Button
onClick={this.handleShareClick}
startIcon={<ShareIcon fontSize="small" />}
sx={{
position: "absolute",
top: 8,
right: 8,
backgroundColor: "#f5f5f5",
"&:hover": {
backgroundColor: "#e0e0e0",
},
zIndex: 1,
fontSize: "0.75rem",
px: 1.5,
py: 0.5,
minWidth: "auto",
textTransform: "none",
}}
size="small"
>
Teilen
</Button>
<Box
sx={{
mt: 2,
lineHeight: 1.7,
"& p": { mt: 0, mb: 2 },
"& strong": { fontWeight: 600 },
}}
>
{product.description ? (() => {
try {
// Sanitize HTML to remove invalid tags, but preserve style attributes and <product> tags
const sanitized = sanitizeHtml(product.description, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'product']),
allowedAttributes: {
'*': ['class', 'style'],
'a': ['href', 'title'],
'img': ['src', 'alt', 'width', 'height'],
'product': ['articlenr']
},
disallowedTagsMode: 'discard'
});
// Parse with custom replace function to handle <product> tags
return parse(sanitized, {
replace: (domNode) => {
if (domNode.type === 'tag' && domNode.name === 'product') {
const articleNr = domNode.attribs && domNode.attribs['articlenr'];
if (articleNr) {
// Render embedded product component
return this.renderEmbeddedProduct(articleNr);
}
}
}
});
} catch (error) {
console.warn('Failed to parse product description HTML:', error);
// Fallback to rendering as plain text if HTML parsing fails
return <span>{product.description}</span>;
}
})() : 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>
)}
{/* Share Popper */}
<Popper
open={sharePopperOpen}
anchorEl={shareAnchorEl}
placement="bottom-end"
sx={{ zIndex: 1300 }}
>
<ClickAwayListener onClickAway={this.handleShareClose}>
<Box
sx={{
bgcolor: "background.paper",
border: "1px solid #ccc",
borderRadius: 1,
boxShadow: "0 4px 8px rgba(0,0,0,0.1)",
minWidth: 200,
}}
>
<MenuList>
<MenuItem onClick={this.handleEmbedShare}>
<ListItemIcon>
<CodeIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Einbetten" />
</MenuItem>
<MenuItem onClick={this.handleWhatsAppShare}>
<ListItemIcon>
<WhatsAppIcon fontSize="small" sx={{ color: "#25D366" }} />
</ListItemIcon>
<ListItemText primary="WhatsApp" />
</MenuItem>
<MenuItem onClick={this.handleTelegramShare}>
<ListItemIcon>
<TelegramIcon fontSize="small" sx={{ color: "#0088CC" }} />
</ListItemIcon>
<ListItemText primary="Telegram" />
</MenuItem>
<MenuItem onClick={this.handleFacebookShare}>
<ListItemIcon>
<FacebookIcon fontSize="small" sx={{ color: "#1877F2" }} />
</ListItemIcon>
<ListItemText primary="Facebook" />
</MenuItem>
<MenuItem onClick={this.handleEmailShare}>
<ListItemIcon>
<EmailIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="E-Mail" />
</MenuItem>
<MenuItem onClick={this.handleLinkCopy}>
<ListItemIcon>
<LinkIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Link kopieren" />
</MenuItem>
</MenuList>
</Box>
</ClickAwayListener>
</Popper>
{/* 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>
)}
{/* Similar Products Section */}
{this.state.similarProducts && this.state.similarProducts.length > 0 && (
<Box sx={{ mt: 4, p: 4, background: "#fff", borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
<Typography variant="h4" component="h2" gutterBottom sx={{ mb: 3 }}>
{this.props.t ? this.props.t('product.similarProducts') : 'Ähnliche Produkte'}
</Typography>
<Box sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
md: "repeat(3, 1fr)",
lg: "repeat(4, 1fr)"
},
gap: 2
}}>
{this.state.similarProducts.map((similarProductData, index) => {
const product = similarProductData.product;
return (
<Box key={product.id} sx={{ display: 'flex', justifyContent: 'center' }}>
<Product
id={product.id}
name={product.name}
seoName={product.seoName}
price={product.price}
currency={product.currency}
available={product.available}
manufacturer={product.manufacturer}
vat={product.vat}
cGrundEinheit={product.cGrundEinheit}
fGrundPreis={product.fGrundPreis}
incoming={product.incomingDate}
neu={product.neu}
thc={product.thc}
floweringWeeks={product.floweringWeeks}
versandklasse={product.versandklasse}
weight={product.weight}
pictureList={product.pictureList}
availableSupplier={product.availableSupplier}
komponenten={product.komponenten}
rebate={product.rebate}
categoryId={product.kategorien ? product.kategorien.split(',')[0] : undefined}
priority={index < 6 ? 'high' : 'auto'}
t={this.props.t}
/>
</Box>
);
})}
</Box>
</Box>
)}
{/* Snackbar for user feedback */}
<Snackbar
open={snackbarOpen}
autoHideDuration={4000}
onClose={this.handleSnackbarClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
onClose={this.handleSnackbarClose}
severity={snackbarSeverity}
sx={{ width: '100%' }}
>
{snackbarMessage}
</Alert>
</Snackbar>
</Container>
);
}
}
export default withI18n()(ProductDetailPage);