feat(ProductDetailPage): implement embedded product rendering and loading
- Add functionality to render embedded products from <product> tags in the product description. - Introduce state management for embedded products and their images, including loading and error handling. - Update localization files to include loading messages for embedded products.
This commit is contained in:
@@ -94,6 +94,9 @@ class ProductDetailPage extends Component {
|
|||||||
komponentenImages: {}, // Store tiny pictures for komponenten
|
komponentenImages: {}, // Store tiny pictures for komponenten
|
||||||
totalKomponentenPrice: 0,
|
totalKomponentenPrice: 0,
|
||||||
totalSavings: 0,
|
totalSavings: 0,
|
||||||
|
// Embedded products from <product> tags in description
|
||||||
|
embeddedProducts: {},
|
||||||
|
embeddedProductImages: {},
|
||||||
// Collapsible sections state
|
// Collapsible sections state
|
||||||
showQuestionForm: false,
|
showQuestionForm: false,
|
||||||
showRatingForm: false,
|
showRatingForm: false,
|
||||||
@@ -143,6 +146,9 @@ class ProductDetailPage extends Component {
|
|||||||
komponentenImages: {}, // Store tiny pictures for komponenten
|
komponentenImages: {}, // Store tiny pictures for komponenten
|
||||||
totalKomponentenPrice: 0,
|
totalKomponentenPrice: 0,
|
||||||
totalSavings: 0,
|
totalSavings: 0,
|
||||||
|
// Embedded products from <product> tags in description
|
||||||
|
embeddedProducts: {},
|
||||||
|
embeddedProductImages: {},
|
||||||
// Collapsible sections state
|
// Collapsible sections state
|
||||||
showQuestionForm: false,
|
showQuestionForm: false,
|
||||||
showRatingForm: false,
|
showRatingForm: false,
|
||||||
@@ -174,6 +180,9 @@ class ProductDetailPage extends Component {
|
|||||||
komponentenImages: {}, // Store tiny pictures for komponenten
|
komponentenImages: {}, // Store tiny pictures for komponenten
|
||||||
totalKomponentenPrice: 0,
|
totalKomponentenPrice: 0,
|
||||||
totalSavings: 0,
|
totalSavings: 0,
|
||||||
|
// Embedded products from <product> tags in description
|
||||||
|
embeddedProducts: {},
|
||||||
|
embeddedProductImages: {},
|
||||||
// Collapsible sections state
|
// Collapsible sections state
|
||||||
showQuestionForm: false,
|
showQuestionForm: false,
|
||||||
showRatingForm: false,
|
showRatingForm: false,
|
||||||
@@ -797,6 +806,234 @@ class ProductDetailPage extends Component {
|
|||||||
this.handleShareClose();
|
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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { product, loading, upgrading, error, attributeImages, isSteckling, attributes, komponentenLoaded, komponentenData, komponentenImages, totalKomponentenPrice, totalSavings, shareAnchorEl, sharePopperOpen, snackbarOpen, snackbarMessage, snackbarSeverity } =
|
const { product, loading, upgrading, error, attributeImages, isSteckling, attributes, komponentenLoaded, komponentenData, komponentenImages, totalKomponentenPrice, totalSavings, shareAnchorEl, sharePopperOpen, snackbarOpen, snackbarMessage, snackbarSeverity } =
|
||||||
this.state;
|
this.state;
|
||||||
@@ -1335,16 +1572,30 @@ class ProductDetailPage extends Component {
|
|||||||
>
|
>
|
||||||
{product.description ? (() => {
|
{product.description ? (() => {
|
||||||
try {
|
try {
|
||||||
// Sanitize HTML to remove invalid tags, but preserve style attributes
|
// Sanitize HTML to remove invalid tags, but preserve style attributes and <product> tags
|
||||||
return parse(sanitizeHtml(product.description, {
|
const sanitized = sanitizeHtml(product.description, {
|
||||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
|
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'product']),
|
||||||
allowedAttributes: {
|
allowedAttributes: {
|
||||||
'*': ['class', 'style'],
|
'*': ['class', 'style'],
|
||||||
'a': ['href', 'title'],
|
'a': ['href', 'title'],
|
||||||
'img': ['src', 'alt', 'width', 'height']
|
'img': ['src', 'alt', 'width', 'height'],
|
||||||
|
'product': ['articlenr']
|
||||||
},
|
},
|
||||||
disallowedTagsMode: 'discard'
|
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) {
|
} catch (error) {
|
||||||
console.warn('Failed to parse product description HTML:', error);
|
console.warn('Failed to parse product description HTML:', error);
|
||||||
// Fallback to rendering as plain text if HTML parsing fails
|
// Fallback to rendering as plain text if HTML parsing fails
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default {
|
|||||||
"pickupPrice": "Abholpreis: 19,90 € pro Steckling.",
|
"pickupPrice": "Abholpreis: 19,90 € pro Steckling.",
|
||||||
"consistsOf": "Bestehend aus:",
|
"consistsOf": "Bestehend aus:",
|
||||||
"loadingComponentDetails": "{{index}}. Lädt Komponent-Details...",
|
"loadingComponentDetails": "{{index}}. Lädt Komponent-Details...",
|
||||||
|
"loadingProduct": "Produkt wird geladen...",
|
||||||
"individualPriceTotal": "Einzelpreis gesamt:",
|
"individualPriceTotal": "Einzelpreis gesamt:",
|
||||||
"setPrice": "Set-Preis:",
|
"setPrice": "Set-Preis:",
|
||||||
"yourSavings": "Ihre Ersparnis:",
|
"yourSavings": "Ihre Ersparnis:",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default {
|
|||||||
"pickupPrice": "Pickup price: €19.90 per cutting.", // Abholpreis: 19,90 € pro Steckling.
|
"pickupPrice": "Pickup price: €19.90 per cutting.", // Abholpreis: 19,90 € pro Steckling.
|
||||||
"consistsOf": "Consists of:", // Bestehend aus:
|
"consistsOf": "Consists of:", // Bestehend aus:
|
||||||
"loadingComponentDetails": "{{index}}. Loading component details...", // {{index}}. Lädt Komponent-Details...
|
"loadingComponentDetails": "{{index}}. Loading component details...", // {{index}}. Lädt Komponent-Details...
|
||||||
|
"loadingProduct": "Product is loading...", // Produkt wird geladen...
|
||||||
"individualPriceTotal": "Total individual price:", // Einzelpreis gesamt:
|
"individualPriceTotal": "Total individual price:", // Einzelpreis gesamt:
|
||||||
"setPrice": "Set price:", // Set-Preis:
|
"setPrice": "Set price:", // Set-Preis:
|
||||||
"yourSavings": "Your savings:", // Ihre Ersparnis:
|
"yourSavings": "Your savings:", // Ihre Ersparnis:
|
||||||
|
|||||||
Reference in New Issue
Block a user