feat: add prerender-single-product script for single product rendering with i18n support

This commit is contained in:
sebseb7
2025-07-20 10:12:16 +02:00
parent 1fb92e2df9
commit 19cf475b0e
4 changed files with 366 additions and 88 deletions

View File

@@ -0,0 +1,202 @@
require("@babel/register")({
presets: [
["@babel/preset-env", { targets: { node: "current" } }],
"@babel/preset-react",
],
extensions: [".js", ".jsx"],
ignore: [/node_modules/],
});
// Minimal globals for socket.io-client only - no JSDOM to avoid interference
global.window = {}; // Minimal window object for productCache
global.navigator = { userAgent: "node.js" };
global.URL = require("url").URL;
global.Blob = class MockBlob {
constructor(data, options) {
this.data = data;
this.type = options?.type || "";
}
};
// Import modules
const fs = require("fs");
const path = require("path");
const React = require("react");
const io = require("socket.io-client");
// Initialize i18n for prerendering with German as default
const i18n = require("i18next");
const { initReactI18next } = require("react-i18next");
// Import translation (just German for testing)
const translationDE = require("./src/i18n/locales/de/translation.js").default;
// Initialize i18n
i18n
.use(initReactI18next)
.init({
resources: {
de: { translation: translationDE }
},
lng: 'de',
fallbackLng: 'de',
debug: false,
interpolation: {
escapeValue: false
},
react: {
useSuspense: false
}
});
// Make i18n available globally
global.i18n = i18n;
// Import prerender modules
const config = require("./prerender/config.cjs");
const shopConfig = require("./src/config.js").default;
const { renderPage } = require("./prerender/renderer.cjs");
const { generateProductMetaTags, generateProductJsonLd } = require("./prerender/seo.cjs");
const { fetchProductDetails } = require("./prerender/data-fetching.cjs");
// Import product component
const PrerenderProduct = require("./src/PrerenderProduct.js").default;
const renderSingleProduct = async (productSeoName) => {
const socketUrl = "http://127.0.0.1:9303";
console.log(`🔌 Connecting to socket at ${socketUrl}...`);
const socket = io(socketUrl, {
path: "/socket.io/",
transports: ["polling", "websocket"],
reconnection: false,
timeout: 10000,
});
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
console.error("❌ Timeout: Could not connect to backend after 15 seconds");
socket.disconnect();
reject(new Error("Connection timeout"));
}, 15000);
socket.on("connect", async () => {
console.log(`✅ Socket connected. Fetching product: ${productSeoName}`);
try {
// Fetch product details
const productDetails = await fetchProductDetails(socket, productSeoName);
console.log(`📦 Product found: ${productDetails.product.name}`);
// Set up minimal global cache (empty for single product test)
global.window.productCache = {};
global.productCache = {};
// Create product component
const productComponent = React.createElement(PrerenderProduct, {
productData: productDetails,
t: global.i18n.t.bind(global.i18n),
});
// Generate metadata
const actualSeoName = productDetails.product.seoName || productSeoName;
const filename = `Artikel/${actualSeoName}`;
const location = `/Artikel/${actualSeoName}`;
const description = `Product "${productDetails.product.name}" (seoName: ${productSeoName})`;
const metaTags = generateProductMetaTags({
...productDetails.product,
seoName: actualSeoName,
}, shopConfig.baseUrl, shopConfig);
const jsonLdScript = generateProductJsonLd({
...productDetails.product,
seoName: actualSeoName,
}, shopConfig.baseUrl, shopConfig);
const combinedMetaTags = metaTags + "\n" + jsonLdScript;
// Render the page
console.log(`🎨 Rendering product page...`);
const success = renderPage(
productComponent,
location,
filename,
description,
combinedMetaTags,
true, // needsRouter
config,
false, // suppressLogs
productDetails // productData for cache
);
if (success) {
const outputPath = path.resolve(__dirname, config.outputDir, `${filename}.html`);
console.log(`✅ Product page rendered successfully!`);
console.log(`📄 Output file: ${outputPath}`);
console.log(`🌐 Test URL: http://localhost:3000/Artikel/${actualSeoName}`);
// Show file size
if (fs.existsSync(outputPath)) {
const stats = fs.statSync(outputPath);
console.log(`📊 File size: ${Math.round(stats.size / 1024)}KB`);
}
} else {
console.log(`❌ Failed to render product page`);
}
clearTimeout(timeout);
socket.disconnect();
resolve(success);
} catch (error) {
console.error(`❌ Error fetching/rendering product: ${error.message}`);
clearTimeout(timeout);
socket.disconnect();
reject(error);
}
});
socket.on("connect_error", (err) => {
clearTimeout(timeout);
console.error("❌ Socket connection error:", err);
console.log("💡 Make sure the backend server is running on http://127.0.0.1:9303");
reject(err);
});
socket.on("error", (err) => {
clearTimeout(timeout);
console.error("❌ Socket error:", err);
reject(err);
});
});
};
// Get product seoName from command line arguments
const productSeoName = process.argv[2];
if (!productSeoName) {
console.log("❌ Usage: node prerender-single-product.cjs <product-seo-name>");
console.log("📝 Example: node prerender-single-product.cjs led-grow-light-600w");
process.exit(1);
}
console.log(`🚀 Starting single product prerender test...`);
console.log(`🎯 Product SEO name: ${productSeoName}`);
console.log(`🔧 Mode: ${config.isProduction ? 'PRODUCTION' : 'DEVELOPMENT'}`);
console.log(`📁 Output directory: ${config.outputDir}`);
renderSingleProduct(productSeoName)
.then((success) => {
if (success) {
console.log(`\n🎉 Single product prerender completed successfully!`);
process.exit(0);
} else {
console.log(`\n💥 Single product prerender failed!`);
process.exit(1);
}
})
.catch((error) => {
console.error(`\n💥 Single product prerender failed:`, error.message);
process.exit(1);
});

View File

@@ -187,7 +187,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
const imageBuffer = await fetchProductImage(socket, bildId); const imageBuffer = await fetchProductImage(socket, bildId);
// If overlay exists, apply it to the image // If overlay exists, apply it to the image
if (fs.existsSync(overlayPath)) { if (false && fs.existsSync(overlayPath)) {
try { try {
// Get image dimensions to center the overlay // Get image dimensions to center the overlay
const baseImage = sharp(Buffer.from(imageBuffer)); const baseImage = sharp(Buffer.from(imageBuffer));

View File

@@ -9,7 +9,8 @@ const {
Chip, Chip,
Stack, Stack,
AppBar, AppBar,
Toolbar Toolbar,
Button
} = require('@mui/material'); } = require('@mui/material');
const Footer = require('./components/Footer.js').default; const Footer = require('./components/Footer.js').default;
const { Logo } = require('./components/header/index.js'); const { Logo } = require('./components/header/index.js');
@@ -216,59 +217,109 @@ class PrerenderProduct extends React.Component {
}, },
cleanProductName(product.name) cleanProductName(product.name)
), ),
// Manufacturer if available // Manufacturer if available - exact match to SPA: only render Box if manufacturer exists
React.createElement( product.manufacturer && React.createElement(
Box, Box,
{ sx: { display: "flex", alignItems: "center", mb: 2 } }, { sx: { display: "flex", alignItems: "center", mb: 2 } },
product.manufacturer && React.createElement( React.createElement(
Typography, Typography,
{ variant: 'body2', sx: { fontStyle: "italic" } }, { variant: 'body2', sx: { fontStyle: "italic" } },
(this.props.t ? this.props.t('product.manufacturer') : 'Hersteller')+': '+product.manufacturer (this.props.t ? this.props.t('product.manufacturer') : 'Hersteller')+': '+product.manufacturer
) )
), ),
// Attribute images and chips with action buttons section // Attribute images and chips with action buttons section - exact replica of SPA version
// SPA condition: (attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert]))
// This essentially means "if there are any attributes at all"
// For products with no attributes (like Vakuumbeutel), this section should NOT render
(attributes.length > 0) && React.createElement( (attributes.length > 0) && React.createElement(
Box, Box,
{ sx: { display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2, gap: 2 } }, { sx: { display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2, gap: 2 } },
// Left side - attributes as chips (visible) // Left side - attributes
React.createElement( React.createElement(
Stack, Stack,
{ direction: 'row', spacing: 2, sx: { flexWrap: "wrap", gap: 1, flex: 1 } }, { direction: 'row', spacing: 2, sx: { flexWrap: "wrap", gap: 1, flex: 1 } },
...attributes.map((attr, index) => // In prerender: attributes.filter(attribute => attributeImages[attribute.kMerkmalWert]) = [] (empty)
// Then: attributes.filter(attribute => !attributeImages[attribute.kMerkmalWert]) = all attributes as Chips
...attributes.map((attribute, index) =>
React.createElement( React.createElement(
Chip, Chip,
{ {
key: index, key: attribute.kMerkmalWert || index,
label: attr.cWert, label: attribute.cWert,
disabled: true, disabled: true,
sx: { mb: 1 } sx: { mb: 1 }
} }
) )
) )
), ),
// Right side - invisible action buttons (space-filling only) // Right side - action buttons (exact replica with invisible versions)
React.createElement( React.createElement(
Stack, Stack,
{ direction: 'column', spacing: 1, sx: { flexShrink: 0 } }, { direction: 'column', spacing: 1, sx: { flexShrink: 0 } },
// Invisible "Frage zum Artikel" button // "Frage zum Artikel" button - exact replica but invisible
React.createElement( React.createElement(
Box, Button,
{ sx: { height: "32px", width: "120px", visibility: "hidden" } } {
variant: "outlined",
size: "small",
sx: {
fontSize: "0.75rem",
px: 1.5,
py: 0.5,
minWidth: "auto",
whiteSpace: "nowrap",
visibility: "hidden",
pointerEvents: "none"
}
},
"Frage zum Artikel"
), ),
// Invisible "Artikel Bewerten" button // "Artikel Bewerten" button - exact replica but invisible
React.createElement( React.createElement(
Box, Button,
{ sx: { height: "32px", width: "120px", visibility: "hidden" } } {
variant: "outlined",
size: "small",
sx: {
fontSize: "0.75rem",
px: 1.5,
py: 0.5,
minWidth: "auto",
whiteSpace: "nowrap",
visibility: "hidden",
pointerEvents: "none"
}
},
"Artikel Bewerten"
), ),
// Invisible "Verfügbarkeit anfragen" button (conditional) // "Verfügbarkeit anfragen" button - conditional, exact replica but invisible
(product.available !== 1 && product.availableSupplier !== 1) && React.createElement( (product.available !== 1 && product.availableSupplier !== 1) && React.createElement(
Box, Button,
{ sx: { height: "32px", width: "140px", visibility: "hidden" } } {
variant: "outlined",
size: "small",
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"
},
visibility: "hidden",
pointerEvents: "none"
}
},
"Verfügbarkeit anfragen"
) )
) )
), ),
// Weight // Weight
product.weight && product.weight > 0 && React.createElement( (product.weight && product.weight > 0) ? React.createElement(
Box, Box,
{ sx: { mb: 2 } }, { sx: { mb: 2 } },
React.createElement( React.createElement(
@@ -276,7 +327,7 @@ class PrerenderProduct extends React.Component {
{ variant: 'body2', color: 'text.secondary' }, { 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`) (this.props.t ? this.props.t('product.weight', { weight: product.weight.toFixed(1).replace(".", ",") }) : `Gewicht: ${product.weight.toFixed(1).replace(".", ",")} kg`)
) )
), ) : null,
// Price and availability section // Price and availability section
React.createElement( React.createElement(
Box, Box,
@@ -299,33 +350,29 @@ class PrerenderProduct extends React.Component {
gap: 2, gap: 2,
} }
}, },
// Left side - Price information // Left side - Price information (exact match to SPA)
React.createElement( React.createElement(
Box, Box,
{ sx: { flex: 1 } }, null,
React.createElement( React.createElement(
Typography, Typography,
{ {
variant: "h4", variant: "h4",
color: "primary", color: "primary",
sx: { fontWeight: "bold", mb: 1 } sx: { fontWeight: "bold" }
}, },
priceWithTax priceWithTax
), ),
// VAT info with fixed height // VAT info (exact match to SPA - direct Typography, no wrapper)
React.createElement( React.createElement(
Box,
{ sx: { minHeight: "20px", mb: 1 } },
product.vat && React.createElement(
Typography, Typography,
{ variant: 'body2', color: 'text.secondary' }, { variant: 'body2', color: 'text.secondary' },
(this.props.t ? this.props.t('product.inclVat', { vat: product.vat }) : `inkl. ${product.vat}% MwSt.`) (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}` :
"")
), ),
// Shipping class with fixed height // Shipping class (exact match to SPA - direct Typography, conditional render)
React.createElement(
Box,
{ sx: { minHeight: "20px", mb: 1 } },
product.versandklasse && product.versandklasse &&
product.versandklasse != "standard" && product.versandklasse != "standard" &&
product.versandklasse != "kostenlos" && React.createElement( product.versandklasse != "kostenlos" && React.createElement(
@@ -333,29 +380,51 @@ class PrerenderProduct extends React.Component {
{ variant: 'body2', color: 'text.secondary' }, { variant: 'body2', color: 'text.secondary' },
product.versandklasse product.versandklasse
) )
)
), ),
// Right side - Cart button area with availability info // Right side - Complex cart button area structure (matching SPA exactly)
React.createElement(
Box,
{
sx: {
display: "flex",
flexDirection: { xs: "column", sm: "row" },
gap: 2,
alignItems: "flex-start",
}
},
// Empty steckling column placeholder - maintains flex positioning
React.createElement(
Box,
{ sx: { display: "flex", flexDirection: "column" } }
// Empty - no steckling for this product
),
// Main cart button column (exact match to SPA structure)
React.createElement( React.createElement(
Box, Box,
{ {
sx: { sx: {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
minWidth: { xs: "100%", sm: "200px" }
} }
}, },
// Reserved space for AddToCartButton (will be populated by SPA) // AddToCartButton placeholder - invisible button that reserves exact space
React.createElement( React.createElement(
Box, Button,
{ {
variant: "contained",
size: "large",
sx: { sx: {
minHeight: "48px", visibility: "hidden",
mb: 1 pointerEvents: "none",
} height: "36px",
width: "140px",
minWidth: "140px",
maxWidth: "140px"
} }
},
"In den Warenkorb"
), ),
// Availability and delivery time info (matching ProductDetailPage) // Delivery time Typography (exact match to SPA)
React.createElement( React.createElement(
Typography, Typography,
{ {
@@ -364,10 +433,7 @@ class PrerenderProduct extends React.Component {
fontStyle: "italic", fontStyle: "italic",
color: "text.secondary", color: "text.secondary",
textAlign: "center", textAlign: "center",
minHeight: "28px", mt: 1
display: "flex",
alignItems: "center",
justifyContent: "center"
} }
}, },
product.id && product.id.toString().endsWith("steckling") ? product.id && product.id.toString().endsWith("steckling") ?
@@ -376,7 +442,8 @@ class PrerenderProduct extends React.Component {
(this.props.t ? this.props.t('delivery.times.standard2to3Days') : "Lieferzeit: 2-3 Tage") : (this.props.t ? this.props.t('delivery.times.standard2to3Days') : "Lieferzeit: 2-3 Tage") :
product.availableSupplier == 1 ? product.availableSupplier == 1 ?
(this.props.t ? this.props.t('delivery.times.supplier7to9Days') : "Lieferzeit: 7-9 Tage") : (this.props.t ? this.props.t('delivery.times.supplier7to9Days') : "Lieferzeit: 7-9 Tage") :
(product.available ? '✅ Verfügbar' : '❌ Nicht verfügbar') ""
)
) )
) )
) )

View File

@@ -109,7 +109,8 @@ class Images extends Component {
}; };
return ( return (
<Box sx={{ position: 'relative', display: 'inline-block' }}> <>
<Box sx={{ position: 'relative', display: 'inline-block', width: '499px', maxWidth: '100%' }}>
<CardMedia <CardMedia
component="img" component="img"
height="400" height="400"
@@ -118,12 +119,18 @@ class Images extends Component {
objectFit: 'contain', objectFit: 'contain',
cursor: 'pointer', cursor: 'pointer',
transition: 'transform 0.2s ease-in-out', transition: 'transform 0.2s ease-in-out',
width: '499px',
maxWidth: '100%',
'&:hover': { '&:hover': {
transform: 'scale(1.02)' transform: 'scale(1.02)'
} }
}} }}
/> />
</Box> </Box>
<Stack direction="row" spacing={2} sx={{ justifyContent: 'flex-start', mt: 1, mb: 1 }}>
{/* Empty thumbnail gallery for prerender - reserves the mt+mb spacing (16px) */}
</Stack>
</>
); );
} }
@@ -131,7 +138,7 @@ class Images extends Component {
return ( return (
<> <>
{this.state.pics[this.state.mainPic] && ( {this.state.pics[this.state.mainPic] && (
<Box sx={{ position: 'relative', display: 'inline-block' }}> <Box sx={{ position: 'relative', display: 'inline-block', width: '499px', maxWidth: '100%' }}>
<CardMedia <CardMedia
component="img" component="img"
height="400" height="400"
@@ -139,6 +146,8 @@ class Images extends Component {
objectFit: 'contain', objectFit: 'contain',
cursor: 'pointer', cursor: 'pointer',
transition: 'transform 0.2s ease-in-out', transition: 'transform 0.2s ease-in-out',
width: '499px',
maxWidth: '100%',
'&:hover': { '&:hover': {
transform: 'scale(1.02)' transform: 'scale(1.02)'
} }