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);
// If overlay exists, apply it to the image
if (fs.existsSync(overlayPath)) {
if (false && fs.existsSync(overlayPath)) {
try {
// Get image dimensions to center the overlay
const baseImage = sharp(Buffer.from(imageBuffer));

View File

@@ -9,7 +9,8 @@ const {
Chip,
Stack,
AppBar,
Toolbar
Toolbar,
Button
} = require('@mui/material');
const Footer = require('./components/Footer.js').default;
const { Logo } = require('./components/header/index.js');
@@ -216,59 +217,109 @@ class PrerenderProduct extends React.Component {
},
cleanProductName(product.name)
),
// Manufacturer if available
React.createElement(
// Manufacturer if available - exact match to SPA: only render Box if manufacturer exists
product.manufacturer && React.createElement(
Box,
{ sx: { display: "flex", alignItems: "center", mb: 2 } },
product.manufacturer && React.createElement(
React.createElement(
Typography,
{ variant: 'body2', sx: { fontStyle: "italic" } },
(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(
Box,
{ sx: { display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2, gap: 2 } },
// Left side - attributes as chips (visible)
// Left side - attributes
React.createElement(
Stack,
{ 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(
Chip,
{
key: index,
label: attr.cWert,
{
key: attribute.kMerkmalWert || index,
label: attribute.cWert,
disabled: true,
sx: { mb: 1 }
}
)
)
),
// Right side - invisible action buttons (space-filling only)
// Right side - action buttons (exact replica with invisible versions)
React.createElement(
Stack,
{ direction: 'column', spacing: 1, sx: { flexShrink: 0 } },
// Invisible "Frage zum Artikel" button
// "Frage zum Artikel" button - exact replica but invisible
React.createElement(
Box,
{ sx: { height: "32px", width: "120px", visibility: "hidden" } }
Button,
{
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(
Box,
{ sx: { height: "32px", width: "120px", visibility: "hidden" } }
Button,
{
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(
Box,
{ sx: { height: "32px", width: "140px", visibility: "hidden" } }
Button,
{
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
product.weight && product.weight > 0 && React.createElement(
(product.weight && product.weight > 0) ? React.createElement(
Box,
{ sx: { mb: 2 } },
React.createElement(
@@ -276,7 +327,7 @@ class PrerenderProduct extends React.Component {
{ 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`)
)
),
) : null,
// Price and availability section
React.createElement(
Box,
@@ -299,84 +350,100 @@ class PrerenderProduct extends React.Component {
gap: 2,
}
},
// Left side - Price information
// Left side - Price information (exact match to SPA)
React.createElement(
Box,
{ sx: { flex: 1 } },
null,
React.createElement(
Typography,
{
variant: "h4",
color: "primary",
sx: { fontWeight: "bold", mb: 1 }
sx: { fontWeight: "bold" }
},
priceWithTax
),
// VAT info with fixed height
// VAT info (exact match to SPA - direct Typography, no wrapper)
React.createElement(
Box,
{ sx: { minHeight: "20px", mb: 1 } },
product.vat && React.createElement(
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}` :
"")
),
// Shipping class (exact match to SPA - direct Typography, conditional render)
product.versandklasse &&
product.versandklasse != "standard" &&
product.versandklasse != "kostenlos" && React.createElement(
Typography,
{ variant: 'body2', color: 'text.secondary' },
(this.props.t ? this.props.t('product.inclVat', { vat: product.vat }) : `inkl. ${product.vat}% MwSt.`)
product.versandklasse
)
),
// Shipping class with fixed height
React.createElement(
Box,
{ sx: { minHeight: "20px", mb: 1 } },
product.versandklasse &&
product.versandklasse != "standard" &&
product.versandklasse != "kostenlos" && React.createElement(
Typography,
{ variant: 'body2', color: 'text.secondary' },
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: "column",
minWidth: { xs: "100%", sm: "200px" }
flexDirection: { xs: "column", sm: "row" },
gap: 2,
alignItems: "flex-start",
}
},
// Reserved space for AddToCartButton (will be populated by SPA)
// 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(
Box,
{
sx: {
minHeight: "48px",
mb: 1
}
}
),
// Availability and delivery time info (matching ProductDetailPage)
React.createElement(
Typography,
{
variant: 'caption',
sx: {
fontStyle: "italic",
color: "text.secondary",
textAlign: "center",
minHeight: "28px",
display: "flex",
alignItems: "center",
justifyContent: "center"
flexDirection: "column",
}
},
product.id && 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") :
(product.available ? '✅ Verfügbar' : '❌ Nicht verfügbar')
// AddToCartButton placeholder - invisible button that reserves exact space
React.createElement(
Button,
{
variant: "contained",
size: "large",
sx: {
visibility: "hidden",
pointerEvents: "none",
height: "36px",
width: "140px",
minWidth: "140px",
maxWidth: "140px"
}
},
"In den Warenkorb"
),
// Delivery time Typography (exact match to SPA)
React.createElement(
Typography,
{
variant: 'caption',
sx: {
fontStyle: "italic",
color: "text.secondary",
textAlign: "center",
mt: 1
}
},
product.id && 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") :
""
)
)
)
)

View File

@@ -109,21 +109,28 @@ class Images extends Component {
};
return (
<Box sx={{ position: 'relative', display: 'inline-block' }}>
<CardMedia
component="img"
height="400"
image={getImagePath(this.props.pictureList)}
sx={{
objectFit: 'contain',
cursor: 'pointer',
transition: 'transform 0.2s ease-in-out',
'&:hover': {
transform: 'scale(1.02)'
}
}}
/>
</Box>
<>
<Box sx={{ position: 'relative', display: 'inline-block', width: '499px', maxWidth: '100%' }}>
<CardMedia
component="img"
height="400"
image={getImagePath(this.props.pictureList)}
sx={{
objectFit: 'contain',
cursor: 'pointer',
transition: 'transform 0.2s ease-in-out',
width: '499px',
maxWidth: '100%',
'&:hover': {
transform: 'scale(1.02)'
}
}}
/>
</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 (
<>
{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
component="img"
height="400"
@@ -139,6 +146,8 @@ class Images extends Component {
objectFit: 'contain',
cursor: 'pointer',
transition: 'transform 0.2s ease-in-out',
width: '499px',
maxWidth: '100%',
'&:hover': {
transform: 'scale(1.02)'
}