diff --git a/prerender-single-product.cjs b/prerender-single-product.cjs new file mode 100644 index 0000000..7afdbfb --- /dev/null +++ b/prerender-single-product.cjs @@ -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 "); + 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); + }); \ No newline at end of file diff --git a/prerender/data-fetching.cjs b/prerender/data-fetching.cjs index 5ba0332..9a6a77a 100644 --- a/prerender/data-fetching.cjs +++ b/prerender/data-fetching.cjs @@ -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)); diff --git a/src/PrerenderProduct.js b/src/PrerenderProduct.js index 4b49c59..be93237 100644 --- a/src/PrerenderProduct.js +++ b/src/PrerenderProduct.js @@ -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") : + "" + ) ) ) ) diff --git a/src/components/Images.js b/src/components/Images.js index 1727641..8855334 100644 --- a/src/components/Images.js +++ b/src/components/Images.js @@ -109,21 +109,28 @@ class Images extends Component { }; return ( - - - + <> + + + + + {/* Empty thumbnail gallery for prerender - reserves the mt+mb spacing (16px) */} + + ); } @@ -131,7 +138,7 @@ class Images extends Component { return ( <> {this.state.pics[this.state.mainPic] && ( - +