From 66a1efd87bf11d0c001e4e591b589f7c9cbcc0b5 Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Tue, 21 Apr 2026 16:04:11 +0200 Subject: [PATCH] feat: add Hersteller page with manufacturer data fetching and SEO support --- eslint.config.js | 24 ++++ prerender.cjs | 70 ++++++++++- prerender/data-fetching.cjs | 33 +++++ prerender/renderer.cjs | 51 +++++--- prerender/seo/hersteller.cjs | 116 +++++++++++++++++ prerender/seo/index.cjs | 9 ++ src/App.js | 2 + src/PrerenderHerstellerPage.js | 84 +++++++++++++ src/components/ManufacturerCarousel.js | 168 ++++++++++++++++++++++--- src/pages/HerstellerPage.js | 168 +++++++++++++++++++++++++ 10 files changed, 690 insertions(+), 35 deletions(-) create mode 100644 prerender/seo/hersteller.cjs create mode 100644 src/PrerenderHerstellerPage.js create mode 100644 src/pages/HerstellerPage.js diff --git a/eslint.config.js b/eslint.config.js index 3327350..7c07b2a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,6 +6,30 @@ import babelParser from '@babel/eslint-parser'; export default [ js.configs.recommended, + { + files: ['**/*.cjs'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + globals: { + ...globals.node, + }, + }, + rules: { + 'no-unused-vars': 'warn', + //'no-console': 'warn', + 'no-debugger': 'warn', + 'no-alert': 'warn', + 'no-unused-expressions': 'warn', + 'no-var': 'warn', + 'prefer-const': 'warn', + 'no-trailing-spaces': 'warn', + 'eqeqeq': ['warn', 'always'], + 'no-empty': 'warn', + 'no-eval': 'warn', + 'no-script-url': 'warn', + }, + }, { files: ['**/*.{js,jsx}'], languageOptions: { diff --git a/prerender.cjs b/prerender.cjs index 9c1541d..ae80702 100644 --- a/prerender.cjs +++ b/prerender.cjs @@ -131,6 +131,8 @@ const { generateHomepageJsonLd, generateSitemapJsonLd, generateKonfiguratorMetaTags, + generateHerstellerMetaTags, + generateHerstellerJsonLd, generateXmlSitemap, generateRobotsTxt, generateProductsXml, @@ -142,6 +144,7 @@ const { const { fetchCategoryProducts, fetchProductDetails, + fetchManufacturers, saveProductImages, saveCategoryImages, } = require("./prerender/data-fetching.cjs"); @@ -161,6 +164,7 @@ const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default; const Sitemap = require("./src/pages/Sitemap.js").default; const PrerenderSitemap = require("./src/PrerenderSitemap.js").default; const PrerenderCategoriesPage = require("./src/PrerenderCategoriesPage.js").default; +const PrerenderHerstellerPage = require("./src/PrerenderHerstellerPage.js").default; const AGB = require("./src/pages/AGB.js").default; const NotFound404 = require("./src/pages/NotFound404.js").default; @@ -376,6 +380,29 @@ const renderApp = async (categoryData, socket) => { global.categoryCache = {}; } + // Fetch manufacturers data for Hersteller page + let manufacturerData = null; + console.log("🏭 [renderApp] Starting manufacturer fetch..."); + console.log("🏭 [renderApp] socket exists:", !!socket); + console.log("🏭 [renderApp] socket.connected:", socket ? socket.connected : "N/A"); + + if (!socket) { + console.error("🏭 [renderApp] FATAL: No socket - cannot fetch manufacturers!"); + } else if (!socket.connected) { + console.error("🏭 [renderApp] FATAL: Socket not connected - cannot fetch manufacturers!"); + } else { + try { + console.log("🏭 [renderApp] Calling fetchManufacturers..."); + manufacturerData = await fetchManufacturers(socket); + console.log("🏭 [renderApp] ✅ Fetched " + manufacturerData.length + " manufacturers"); + } catch (error) { + console.error("🏭 [renderApp] ❌ Failed to fetch manufacturers:", error.message); + manufacturerData = []; + } + } + + console.log("🏭 [renderApp] Final manufacturerData:", manufacturerData ? (manufacturerData.length + " items") : "null"); + // Helper to call renderPage with config const render = ( component, @@ -383,8 +410,10 @@ const renderApp = async (categoryData, socket) => { filename, description, metaTags = "", - needsRouter = false + needsRouter = false, + manufacturerDataForPage = null ) => { + console.log(" 📦 [render helper] Calling renderPage for", filename, "with manufacturerData:", manufacturerDataForPage ? (manufacturerDataForPage.length + " items") : "null"); return renderPage( component, location, @@ -392,7 +421,10 @@ const renderApp = async (categoryData, socket) => { description, metaTags, needsRouter, - config + config, + false, // suppressLogs + null, // productData + manufacturerDataForPage // manufacturerData - 10th parameter! ); }; @@ -474,6 +506,13 @@ const renderApp = async (categoryData, socket) => { description: "Categories page", needsCategoryData: true, }, + { + component: PrerenderHerstellerPage, + path: "/Hersteller", + filename: "Hersteller", + description: "Hersteller page", + needsManufacturerData: true, + }, { component: AGB, path: "/agb", filename: "agb", description: "AGB page" }, { component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" }, { @@ -492,8 +531,17 @@ const renderApp = async (categoryData, socket) => { let staticPagesRendered = 0; for (const page of staticPages) { - // Pass category data as props if needed - const pageProps = page.needsCategoryData ? { categoryData } : null; + // Pass category and manufacturer data as props if needed + let pageProps = null; + if (page.needsCategoryData || page.needsManufacturerData) { + pageProps = {}; + if (page.needsCategoryData) { + pageProps.categoryData = categoryData; + } + if (page.needsManufacturerData) { + pageProps.manufacturerData = manufacturerData; + } + } const pageComponent = React.createElement(page.component, pageProps); let metaTags = ""; @@ -509,13 +557,25 @@ const renderApp = async (categoryData, socket) => { metaTags = konfiguratorMetaTags; } + // Special handling for Hersteller page to include SEO tags + if (page.filename === "Hersteller") { + const manufacturerCount = manufacturerData ? manufacturerData.length : 0; + const herstellerMetaTags = generateHerstellerMetaTags(shopConfig.baseUrl, shopConfig, manufacturerCount); + const herstellerJsonLd = generateHerstellerJsonLd(shopConfig.baseUrl, shopConfig); + metaTags = herstellerMetaTags + "\n" + herstellerJsonLd; + } + + // Pass manufacturerData only for Hersteller page + const pageManufacturerData = page.needsManufacturerData ? manufacturerData : null; + const success = render( pageComponent, page.path, page.filename, page.description, metaTags, - true + true, + pageManufacturerData ); if (success) { staticPagesRendered++; diff --git a/prerender/data-fetching.cjs b/prerender/data-fetching.cjs index 941ceab..c53a563 100644 --- a/prerender/data-fetching.cjs +++ b/prerender/data-fetching.cjs @@ -140,6 +140,38 @@ const fetchCategoryImage = (socket, categoryId) => { }); }; +const fetchManufacturers = (socket) => { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timeout fetching manufacturers")); + }, 10000); + + socket.emit("getHerstellerImages", {}, (response) => { + clearTimeout(timeout); + if (response?.success && Array.isArray(response.manufacturers)) { + // Filter and format manufacturers similar to HerstellerPage.js + const manufacturers = response.manufacturers + .filter(m => m.imageBuffer) + .map(m => ({ + id: m.id, + name: m.name || '', + slug: m.slug || '', + imageBuffer: m.imageBuffer, + })) + .sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' })); + + resolve(manufacturers); + } else { + reject( + new Error( + `Invalid manufacturers response: ${JSON.stringify(response)}` + ) + ); + } + }); + }); +}; + const saveProductImages = async (socket, products, categoryName, outputDir) => { if (!products || products.length === 0) return; @@ -383,6 +415,7 @@ module.exports = { fetchProductDetails, fetchProductImage, fetchCategoryImage, + fetchManufacturers, saveProductImages, saveCategoryImages, }; diff --git a/prerender/renderer.cjs b/prerender/renderer.cjs index 8e3180e..8cf914e 100644 --- a/prerender/renderer.cjs +++ b/prerender/renderer.cjs @@ -18,7 +18,8 @@ const renderPage = ( needsRouter = false, config, suppressLogs = false, - productData = null + productData = null, + manufacturerData = null ) => { const { isProduction, @@ -171,22 +172,44 @@ const renderPage = ( `; - // @note Create script to populate window.productCache with ONLY the static category tree + // @note Create script to populate window.productCache with static category tree and herstellerImages let productCacheScript = ''; - if (typeof global !== "undefined" && global.window && global.window.categoryCache) { - // Only include the static categoryTree_209, not any dynamic data that gets added during rendering - const staticCache = {}; - if (global.window.categoryCache["209_de"]) { - staticCache["209_de"] = global.window.categoryCache["209_de"]; + const hasCategoryCache = typeof global !== "undefined" && global.window && global.window.categoryCache; + const hasManufacturerData = manufacturerData && manufacturerData.length > 0; + + console.log(" 📦 [" + filename + "] manufacturerData =", manufacturerData ? (manufacturerData.length + " items") : "null"); + + if (hasCategoryCache || hasManufacturerData) { + const cacheData = {}; + + // Add static categoryTree_209 + if (hasCategoryCache && global.window.categoryCache["209_de"]) { + cacheData["209_de"] = global.window.categoryCache["209_de"]; } - const staticCacheData = JSON.stringify(staticCache); - productCacheScript = ` - - `; + // Add herstellerImages + if (hasManufacturerData) { + cacheData.herstellerImages = manufacturerData; + } + + const cacheDataJson = JSON.stringify(cacheData); + let extraScripts = ''; + + if (hasCategoryCache && cacheData["209_de"]) { + const categoryCacheJson = JSON.stringify({ "209_de": cacheData["209_de"] }); + extraScripts += 'window.categoryCache = ' + categoryCacheJson + ';'; + } + + if (hasManufacturerData) { + const herstellerJson = JSON.stringify(manufacturerData); + extraScripts += 'window.herstellerImages = ' + herstellerJson + ';'; + } + + productCacheScript = ''; } // Create script to populate window.productDetailCache for individual product pages diff --git a/prerender/seo/hersteller.cjs b/prerender/seo/hersteller.cjs new file mode 100644 index 0000000..5f7e1a6 --- /dev/null +++ b/prerender/seo/hersteller.cjs @@ -0,0 +1,116 @@ +/** Safe for double-quoted HTML attributes */ +const escAttr = (str) => + String(str ?? "") + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/ { + const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; + const herstellerUrl = root + "/Hersteller"; + const site = config.siteName || config.brandName; + const desc = manufacturerCount + " Hersteller bei " + config.brandName + ": Top-Marken für Growshop-Produkte. Schnelle Lieferung, Laden Dresden."; + const descShort = desc.length > 160 ? desc.slice(0, 157) + "..." : desc; + const e = escAttr; + const logoUrl = + config.images && config.images.logo + ? root + config.images.logo + : root + "/assets/images/nopicture.jpg"; + + return ` + + + + + + + + + + + + + `; +}; + +const generateHerstellerJsonLd = (baseUrl, config) => { + const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; + const herstellerUrl = root + "/Hersteller"; + + const id = { + business: root + "#business", + website: root + "#website", + breadcrumb: herstellerUrl + "#breadcrumb", + }; + + const logoUrl = + config.images && config.images.logo + ? root + config.images.logo + : undefined; + + const businessNode = { + "@id": id.business, + "@type": ["GardenStore", "LocalBusiness", "Organization"], + name: config.brandName, + url: root, + }; + + if (logoUrl) { + businessNode.logo = { "@type": "ImageObject", url: logoUrl }; + businessNode.image = { "@type": "ImageObject", url: logoUrl }; + } + + const websiteNode = { + "@id": id.website, + "@type": "WebSite", + name: config.siteName || config.brandName, + url: root, + publisher: { "@id": id.business }, + }; + + const breadcrumbNode = { + "@id": id.breadcrumb, + "@type": "BreadcrumbList", + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: "Home", + item: root, + }, + { + "@type": "ListItem", + position: 2, + name: "Hersteller", + item: herstellerUrl, + }, + ], + }; + + const collectionPageNode = { + "@id": herstellerUrl, + "@type": "CollectionPage", + name: "Hersteller", + url: herstellerUrl, + description: "Alle Hersteller und Marken für Growshop-Produkte", + isPartOf: { "@id": id.website }, + breadcrumb: { "@id": id.breadcrumb }, + }; + + const graph = [businessNode, websiteNode, breadcrumbNode, collectionPageNode]; + + const herstellerGraph = { + "@context": "https://schema.org", + "@graph": graph, + }; + + return ""; +}; + +module.exports = { + generateHerstellerMetaTags, + generateHerstellerJsonLd, +}; \ No newline at end of file diff --git a/prerender/seo/index.cjs b/prerender/seo/index.cjs index 003e9da..8fd21e1 100644 --- a/prerender/seo/index.cjs +++ b/prerender/seo/index.cjs @@ -23,6 +23,11 @@ const { generateKonfiguratorMetaTags, } = require('./konfigurator.cjs'); +const { + generateHerstellerMetaTags, + generateHerstellerJsonLd, +} = require('./hersteller.cjs'); + const { generateRobotsTxt, generateProductsXml, @@ -56,6 +61,10 @@ module.exports = { // Konfigurator functions generateKonfiguratorMetaTags, + // Hersteller functions + generateHerstellerMetaTags, + generateHerstellerJsonLd, + // Feed/Export functions generateRobotsTxt, generateProductsXml, diff --git a/src/App.js b/src/App.js index 5175f66..095d341 100644 --- a/src/App.js +++ b/src/App.js @@ -54,6 +54,7 @@ const AGB = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/AGB.js")) //const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); } /> const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js")); const CategoriesPage = lazy(() => import(/* webpackChunkName: "categories" */ "./pages/CategoriesPage.js")); +const HerstellerPage = lazy(() => import(/* webpackChunkName: "hersteller" */ "./pages/HerstellerPage.js")); const Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js")); const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js")); const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js")); @@ -310,6 +311,7 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => { } /> } /> } /> + } /> } /> { + // Use prop data (passed from prerender.cjs) + const manufacturers = manufacturerData; + + // If no manufacturer data, show empty state + if (!manufacturers || manufacturers.length === 0) { + const content = ( + + + Keine Hersteller gefunden. + + + ); + return ; + } + + // Render manufacturers similar to HerstellerPage.js + const content = ( + + {manufacturers.map((manufacturer) => ( + + {manufacturer.imageBuffer && ( + {manufacturer.name} + )} + + ))} + + ); + + return ; +}; + +export default PrerenderHerstellerPage; diff --git a/src/components/ManufacturerCarousel.js b/src/components/ManufacturerCarousel.js index b6693dc..f852b8c 100644 --- a/src/components/ManufacturerCarousel.js +++ b/src/components/ManufacturerCarousel.js @@ -2,17 +2,24 @@ import React from 'react'; import { Link } from 'react-router-dom'; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; +import Paper from "@mui/material/Paper"; +import IconButton from "@mui/material/IconButton"; +import ChevronLeft from "@mui/icons-material/ChevronLeft"; +import ChevronRight from "@mui/icons-material/ChevronRight"; import { withTranslation } from 'react-i18next'; import { withLanguage } from '../i18n/withTranslation.js'; const ITEM_WIDTH = 140 + 16; // 140px width + 16px gap const AUTO_SCROLL_SPEED = 1.0; +const AUTOSCROLL_RESTART_DELAY = 5000; class ManufacturerCarousel extends React.Component { _isMounted = false; originalItems = []; animationFrame = null; + autoScrollActive = true; translateX = 0; + inactivityTimer = null; constructor(props) { super(props); @@ -29,10 +36,8 @@ class ManufacturerCarousel extends React.Component { componentWillUnmount() { this._isMounted = false; - if (this.animationFrame) { - cancelAnimationFrame(this.animationFrame); - this.animationFrame = null; - } + this.stopAutoScroll(); + this.clearInactivityTimer(); // Revoke object URLs to avoid memory leaks for (const item of this.originalItems) { if (item.src) URL.revokeObjectURL(item.src); @@ -66,13 +71,38 @@ class ManufacturerCarousel extends React.Component { }; startAutoScroll = () => { + this.autoScrollActive = true; if (!this.animationFrame) { this.animationFrame = requestAnimationFrame(this.tick); } }; + stopAutoScroll = () => { + this.autoScrollActive = false; + if (this.animationFrame) { + cancelAnimationFrame(this.animationFrame); + this.animationFrame = null; + } + }; + + clearInactivityTimer = () => { + if (this.inactivityTimer) { + clearTimeout(this.inactivityTimer); + this.inactivityTimer = null; + } + }; + + startInactivityTimer = () => { + this.clearInactivityTimer(); + this.inactivityTimer = setTimeout(() => { + if (this._isMounted) { + this.startAutoScroll(); + } + }, AUTOSCROLL_RESTART_DELAY); + }; + tick = () => { - if (!this._isMounted || this.originalItems.length === 0) return; + if (!this._isMounted || !this.autoScrollActive || this.originalItems.length === 0) return; this.translateX -= AUTO_SCROLL_SPEED; @@ -88,6 +118,41 @@ class ManufacturerCarousel extends React.Component { this.animationFrame = requestAnimationFrame(this.tick); }; + updateTrackTransform = () => { + if (this.carouselTrackRef.current) { + this.carouselTrackRef.current.style.transform = `translateX(${this.translateX}px)`; + } + }; + + scrollBy = (direction) => { + if (this.originalItems.length === 0) return; + + const originalItemCount = this.originalItems.length; + const maxScroll = ITEM_WIDTH * originalItemCount; + + this.translateX += direction * ITEM_WIDTH; + + if (this.translateX > 0) { + this.translateX = -(maxScroll - ITEM_WIDTH); + } else if (Math.abs(this.translateX) >= maxScroll) { + this.translateX = 0; + } + + this.updateTrackTransform(); + }; + + handleLeftClick = () => { + this.stopAutoScroll(); + this.scrollBy(1); + this.startInactivityTimer(); + }; + + handleRightClick = () => { + this.stopAutoScroll(); + this.scrollBy(-1); + this.startInactivityTimer(); + }; + render() { const { t } = this.props; const { items } = this.state; @@ -96,19 +161,36 @@ class ManufacturerCarousel extends React.Component { return ( - - {t('product.manufacturer')} - + + {t('product.manufacturer')} + + +
+ {/* Left Arrow */} + + + + + {/* Right Arrow */} + + + +
{items.map((item, index) => ( - - + ))}
diff --git a/src/pages/HerstellerPage.js b/src/pages/HerstellerPage.js new file mode 100644 index 0000000..144a5d0 --- /dev/null +++ b/src/pages/HerstellerPage.js @@ -0,0 +1,168 @@ +import React, { Component } from 'react'; +import { Link } from 'react-router-dom'; +import Box from '@mui/material/Box'; +import Paper from '@mui/material/Paper'; +import LegalPage from './LegalPage.js'; +import { withI18n } from '../i18n/withTranslation.js'; + +class HerstellerPage extends Component { + constructor(props) { + super(props); + this.state = { + loading: true, + manufacturers: [], + }; + this._isMounted = false; + this._objectUrls = []; + } + + componentDidMount() { + this._isMounted = true; + this.loadManufacturers(); + } + + componentWillUnmount() { + this._isMounted = false; + for (const url of this._objectUrls) { + URL.revokeObjectURL(url); + } + this._objectUrls = []; + } + + loadManufacturers = () => { + // Check if manufacturers data is already cached from prerendering + if (window.herstellerImages && Array.isArray(window.herstellerImages) && window.herstellerImages.length > 0) { + if (!this._isMounted) return; + + const manufacturers = window.herstellerImages + .filter(m => m.imageBuffer) + .map(m => { + const blob = new Blob([m.imageBuffer], { type: 'image/avif' }); + const src = URL.createObjectURL(blob); + this._objectUrls.push(src); + return { + id: m.id, + name: m.name || '', + slug: m.slug || '', + src, + }; + }) + .sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' })); + + this.setState({ + loading: false, + manufacturers, + }); + return; + } + + // Fallback: fetch from socket if no cached data + window.socketManager.emit('getHerstellerImages', {}, (res) => { + if (!this._isMounted) return; + + if (!res?.success || !Array.isArray(res.manufacturers)) { + this.setState({ loading: false, manufacturers: [] }); + return; + } + + const manufacturers = res.manufacturers + .filter(m => m.imageBuffer) + .map(m => { + const blob = new Blob([m.imageBuffer], { type: 'image/avif' }); + const src = URL.createObjectURL(blob); + this._objectUrls.push(src); + return { + id: m.id, + name: m.name || '', + slug: m.slug || '', + src, + }; + }) + .sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' })); + + this.setState({ + loading: false, + manufacturers, + }); + }); + }; + + renderManufacturerGrid = () => { + const { manufacturers } = this.state; + + if (!manufacturers.length) { + return null + } + + return ( + + {manufacturers.map((manufacturer) => ( + + {manufacturer.name} + + ))} + + ); + }; + + render() { + const { t } = this.props; + const { loading } = this.state; + + const content = ( + + {loading ? null : ( + this.renderManufacturerGrid() + )} + + ); + + return ; + } +} + +export default withI18n()(HerstellerPage);