Compare commits

...

14 Commits

Author SHA1 Message Date
sebseb7
66a1efd87b feat: add Hersteller page with manufacturer data fetching and SEO support 2026-04-21 16:04:11 +02:00
sebseb7
2c0b7aa84d clickable Herstellerkarousel part 1 2026-04-13 19:47:04 +02:00
sebseb7
a56377a1fd you have to start somewhere 2026-04-13 19:43:34 +02:00
sebseb7
468eb1c3ae button arrange 2026-04-13 19:43:17 +02:00
sebseb7
e699a8003f feat: implement kiosk mode functionality and update UI elements accordingly 2026-04-11 22:58:25 +02:00
sebseb7
b5256d6597 feat: add Outfit Variable font and update global typography settings 2026-04-01 15:13:29 +02:00
sebseb7
18c528302d correct ai assy language 2026-03-31 10:19:47 +02:00
sebseb7
9054c8d2fd Formatting fixed that affected the Czech version. 2026-03-31 10:00:13 +02:00
sebseb7
8bce10e61b refactor: Improve git commit hash retrieval method and enhance webpack configuration for better lazy loading of components 2026-03-28 18:21:39 +01:00
sebseb7
2540d00c8e Revert "refactor: Update webpack configuration to improve git commit hash retrieval and enhance lazy loading of components for better performance"
This reverts commit 52c9888a6a.
2026-03-28 18:04:55 +01:00
sebseb7
52c9888a6a refactor: Update webpack configuration to improve git commit hash retrieval and enhance lazy loading of components for better performance 2026-03-28 17:56:12 +01:00
sebseb7
ab55761411 refactor: Update JSON-LD itemListElement structure in category SEO to include URL field for better clarity and compliance with SEO standards 2026-03-28 17:37:38 +01:00
sebseb7
5e5a733d36 refactor: Simplify JSON-LD generation in category SEO by removing unnecessary product details and focusing on URLs for improved compliance with Google guidelines 2026-03-28 17:34:00 +01:00
sebseb7
36360df648 feat: Add generateCategoryMetaTags function for enhanced SEO in category pages and integrate it into prerender process 2026-03-28 17:21:43 +01:00
29 changed files with 908 additions and 172 deletions

1
docs/README.md Normal file
View File

@@ -0,0 +1 @@
src/components/MainPageLayout.js is the Main gues homepage.

View File

@@ -6,6 +6,30 @@ import babelParser from '@babel/eslint-parser';
export default [ export default [
js.configs.recommended, 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}'], files: ['**/*.{js,jsx}'],
languageOptions: { languageOptions: {

10
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@fontsource-variable/outfit": "^5.2.8",
"@mui/icons-material": "^7.1.1", "@mui/icons-material": "^7.1.1",
"@mui/material": "^7.1.1", "@mui/material": "^7.1.1",
"@stripe/react-stripe-js": "^3.7.0", "@stripe/react-stripe-js": "^3.7.0",
@@ -2222,6 +2223,15 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@fontsource-variable/outfit": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/outfit/-/outfit-5.2.8.tgz",
"integrity": "sha512-4oUDCZx/Tcz6HZP423w/niqEH31Gks5IsqHV2ZZz1qKHaVIZdj2f0/S1IK2n8jl6Xo0o3N+3RjNHlV9R73ozQA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",

View File

@@ -30,6 +30,7 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@fontsource-variable/outfit": "^5.2.8",
"@mui/icons-material": "^7.1.1", "@mui/icons-material": "^7.1.1",
"@mui/material": "^7.1.1", "@mui/material": "^7.1.1",
"@stripe/react-stripe-js": "^3.7.0", "@stripe/react-stripe-js": "^3.7.0",

View File

@@ -125,11 +125,14 @@ const {
const { const {
generateProductMetaTags, generateProductMetaTags,
generateProductJsonLd, generateProductJsonLd,
generateCategoryMetaTags,
generateCategoryJsonLd, generateCategoryJsonLd,
generateHomepageMetaTags, generateHomepageMetaTags,
generateHomepageJsonLd, generateHomepageJsonLd,
generateSitemapJsonLd, generateSitemapJsonLd,
generateKonfiguratorMetaTags, generateKonfiguratorMetaTags,
generateHerstellerMetaTags,
generateHerstellerJsonLd,
generateXmlSitemap, generateXmlSitemap,
generateRobotsTxt, generateRobotsTxt,
generateProductsXml, generateProductsXml,
@@ -141,6 +144,7 @@ const {
const { const {
fetchCategoryProducts, fetchCategoryProducts,
fetchProductDetails, fetchProductDetails,
fetchManufacturers,
saveProductImages, saveProductImages,
saveCategoryImages, saveCategoryImages,
} = require("./prerender/data-fetching.cjs"); } = require("./prerender/data-fetching.cjs");
@@ -160,6 +164,7 @@ const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default;
const Sitemap = require("./src/pages/Sitemap.js").default; const Sitemap = require("./src/pages/Sitemap.js").default;
const PrerenderSitemap = require("./src/PrerenderSitemap.js").default; const PrerenderSitemap = require("./src/PrerenderSitemap.js").default;
const PrerenderCategoriesPage = require("./src/PrerenderCategoriesPage.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 AGB = require("./src/pages/AGB.js").default;
const NotFound404 = require("./src/pages/NotFound404.js").default; const NotFound404 = require("./src/pages/NotFound404.js").default;
@@ -375,6 +380,29 @@ const renderApp = async (categoryData, socket) => {
global.categoryCache = {}; 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 // Helper to call renderPage with config
const render = ( const render = (
component, component,
@@ -382,8 +410,10 @@ const renderApp = async (categoryData, socket) => {
filename, filename,
description, description,
metaTags = "", metaTags = "",
needsRouter = false needsRouter = false,
manufacturerDataForPage = null
) => { ) => {
console.log(" 📦 [render helper] Calling renderPage for", filename, "with manufacturerData:", manufacturerDataForPage ? (manufacturerDataForPage.length + " items") : "null");
return renderPage( return renderPage(
component, component,
location, location,
@@ -391,7 +421,10 @@ const renderApp = async (categoryData, socket) => {
description, description,
metaTags, metaTags,
needsRouter, needsRouter,
config config,
false, // suppressLogs
null, // productData
manufacturerDataForPage // manufacturerData - 10th parameter!
); );
}; };
@@ -473,6 +506,13 @@ const renderApp = async (categoryData, socket) => {
description: "Categories page", description: "Categories page",
needsCategoryData: true, needsCategoryData: true,
}, },
{
component: PrerenderHerstellerPage,
path: "/Hersteller",
filename: "Hersteller",
description: "Hersteller page",
needsManufacturerData: true,
},
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" }, { component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
{ component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" }, { component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" },
{ {
@@ -491,8 +531,17 @@ const renderApp = async (categoryData, socket) => {
let staticPagesRendered = 0; let staticPagesRendered = 0;
for (const page of staticPages) { for (const page of staticPages) {
// Pass category data as props if needed // Pass category and manufacturer data as props if needed
const pageProps = page.needsCategoryData ? { categoryData } : null; 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); const pageComponent = React.createElement(page.component, pageProps);
let metaTags = ""; let metaTags = "";
@@ -508,13 +557,25 @@ const renderApp = async (categoryData, socket) => {
metaTags = konfiguratorMetaTags; 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( const success = render(
pageComponent, pageComponent,
page.path, page.path,
page.filename, page.filename,
page.description, page.description,
metaTags, metaTags,
true true,
pageManufacturerData
); );
if (success) { if (success) {
staticPagesRendered++; staticPagesRendered++;
@@ -621,19 +682,25 @@ const renderApp = async (categoryData, socket) => {
const filename = `Kategorie/${category.seoName}`; const filename = `Kategorie/${category.seoName}`;
const location = `/Kategorie/${category.seoName}`; const location = `/Kategorie/${category.seoName}`;
const description = `Category "${category.name}" (ID: ${category.id})`; const description = `Category "${category.name}" (ID: ${category.id})`;
const categoryMetaTags = generateCategoryMetaTags(
category,
shopConfig.baseUrl,
shopConfig
);
const categoryJsonLd = generateCategoryJsonLd( const categoryJsonLd = generateCategoryJsonLd(
category, category,
productData?.products || [], productData?.products || [],
shopConfig.baseUrl, shopConfig.baseUrl,
shopConfig shopConfig
); );
const combinedCategoryHead = categoryMetaTags + "\n" + categoryJsonLd;
const success = render( const success = render(
categoryComponent, categoryComponent,
location, location,
filename, filename,
description, description,
categoryJsonLd, combinedCategoryHead,
true true
); );
if (success) { if (success) {

View File

@@ -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) => { const saveProductImages = async (socket, products, categoryName, outputDir) => {
if (!products || products.length === 0) return; if (!products || products.length === 0) return;
@@ -383,6 +415,7 @@ module.exports = {
fetchProductDetails, fetchProductDetails,
fetchProductImage, fetchProductImage,
fetchCategoryImage, fetchCategoryImage,
fetchManufacturers,
saveProductImages, saveProductImages,
saveCategoryImages, saveCategoryImages,
}; };

View File

@@ -18,7 +18,8 @@ const renderPage = (
needsRouter = false, needsRouter = false,
config, config,
suppressLogs = false, suppressLogs = false,
productData = null productData = null,
manufacturerData = null
) => { ) => {
const { const {
isProduction, isProduction,
@@ -171,22 +172,44 @@ const renderPage = (
</script> </script>
`; `;
// @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 = ''; let productCacheScript = '';
if (typeof global !== "undefined" && global.window && global.window.categoryCache) { const hasCategoryCache = typeof global !== "undefined" && global.window && global.window.categoryCache;
// Only include the static categoryTree_209, not any dynamic data that gets added during rendering const hasManufacturerData = manufacturerData && manufacturerData.length > 0;
const staticCache = {};
if (global.window.categoryCache["209_de"]) { console.log(" 📦 [" + filename + "] manufacturerData =", manufacturerData ? (manufacturerData.length + " items") : "null");
staticCache["209_de"] = global.window.categoryCache["209_de"];
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); // Add herstellerImages
productCacheScript = ` if (hasManufacturerData) {
<script> cacheData.herstellerImages = manufacturerData;
// Populate window.categoryCache with static category tree only }
window.categoryCache = ${staticCacheData};
</script> 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 = '<script>' +
'if (!window.productCache) { window.productCache = {}; }' +
'Object.assign(window.productCache, ' + cacheDataJson + ');' +
extraScripts +
'</script>';
} }
// Create script to populate window.productDetailCache for individual product pages // Create script to populate window.productDetailCache for individual product pages

View File

@@ -1,3 +1,43 @@
/** Safe for double-quoted HTML attributes */
const escAttr = (str) =>
String(str ?? "")
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;");
/**
* Head tags for prerendered category URLs — explicit canonical per /Kategorie/{slug}
* so Google does not cluster different listing pages (e.g. neu vs Seeds) as duplicates.
*/
const generateCategoryMetaTags = (category, baseUrl, config) => {
const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
const categoryUrl = `${root}/Kategorie/${category.seoName}`;
const name = category.name || `Kategorie ${category.seoName}`;
const site = config.siteName || config.brandName;
const desc = `${name} bei ${config.brandName}: Growshop-Sortiment online kaufen. 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 `
<meta name="description" content="${e(descShort)}">
<meta property="og:title" content="${e(`${name} | ${site}`)}">
<meta property="og:description" content="${e(descShort)}">
<meta property="og:url" content="${categoryUrl}">
<meta property="og:type" content="website">
<meta property="og:image" content="${e(logoUrl)}">
<meta property="og:site_name" content="${e(site)}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${e(`${name} | ${site}`)}">
<meta name="twitter:description" content="${e(descShort)}">
<meta name="robots" content="index, follow">
<link rel="canonical" href="${categoryUrl}">
`;
};
const generateCategoryJsonLd = (category, products = [], baseUrl, config) => { const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
// Category IDs to skip (seeds, plants, headshop items) // Category IDs to skip (seeds, plants, headshop items)
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258]; const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258];
@@ -10,11 +50,6 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; const root = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
const categoryUrl = `${root}/Kategorie/${category.seoName}`; const categoryUrl = `${root}/Kategorie/${category.seoName}`;
// Calculate price valid date (current date + 3 months)
const priceValidDate = new Date();
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
const priceValidUntil = priceValidDate.toISOString().split("T")[0];
const id = { const id = {
business: `${root}#business`, business: `${root}#business`,
website: `${root}#website`, website: `${root}#website`,
@@ -77,88 +112,24 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
const graph = [businessNode, websiteNode, breadcrumbNode, collectionPageNode]; const graph = [businessNode, websiteNode, breadcrumbNode, collectionPageNode];
// Add product list if products are available // ItemList: URLs only — full Product/Offer markup belongs on each /Artikel/… page (Google guidelines).
if (products && products.length > 0) { const withUrls = (products || []).filter((p) => p && p.seoName);
if (withUrls.length > 0) {
collectionPageNode.mainEntity = { "@id": id.itemList }; collectionPageNode.mainEntity = { "@id": id.itemList };
graph.push({ graph.push({
"@id": id.itemList, "@id": id.itemList,
"@type": "ItemList", "@type": "ItemList",
numberOfItems: products.length, numberOfItems: withUrls.length,
itemListElement: products.slice(0, 20).map((product, index) => ({ itemListElement: withUrls.map((product, index) => {
"@type": "ListItem", const productPageUrl = `${root}/Artikel/${product.seoName}`;
position: index + 1, return {
item: { "@type": "ListItem",
"@type": "Product", position: index + 1,
name: product.name, url: productPageUrl,
url: `${root}/Artikel/${product.seoName}`, item: productPageUrl,
image: };
product.pictureList && product.pictureList.trim() }),
? `${root}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.avif`
: `${root}/assets/images/nopicture.jpg`,
description: product.description
? product.description.replace(/<[^>]*>/g, "").substring(0, 200)
: `${product.name} - Hochwertiges Growshop Produkt`,
sku: product.articleNumber || product.seoName,
brand: {
"@type": "Brand",
name: product.manufacturer || config.brandName,
},
offers: {
"@type": "Offer",
url: `${root}/Artikel/${product.seoName}`,
price:
product.price && !isNaN(product.price)
? product.price.toString()
: "0.00",
priceCurrency: config.currency,
priceValidUntil: priceValidUntil,
availability: product.available
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
seller: { "@id": id.business },
itemCondition: "https://schema.org/NewCondition",
hasMerchantReturnPolicy: {
"@type": "MerchantReturnPolicy",
applicableCountry: "DE",
returnPolicyCategory:
"https://schema.org/MerchantReturnFiniteReturnWindow",
merchantReturnDays: 14,
returnMethod: "https://schema.org/ReturnByMail",
returnFees: "https://schema.org/FreeReturn",
},
shippingDetails: {
"@type": "OfferShippingDetails",
shippingRate: {
"@type": "MonetaryAmount",
value: 5.9,
currency: "EUR",
},
shippingDestination: {
"@type": "DefinedRegion",
addressCountry: "DE",
},
deliveryTime: {
"@type": "ShippingDeliveryTime",
handlingTime: {
"@type": "QuantitativeValue",
minValue: 0,
maxValue: 1,
unitCode: "DAY",
},
transitTime: {
"@type": "QuantitativeValue",
minValue: 2,
maxValue: 3,
unitCode: "DAY",
},
},
},
},
},
})),
}); });
} }
@@ -173,5 +144,6 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
}; };
module.exports = { module.exports = {
generateCategoryMetaTags,
generateCategoryJsonLd, generateCategoryJsonLd,
}; };

View File

@@ -0,0 +1,116 @@
/** Safe for double-quoted HTML attributes */
const escAttr = (str) =>
String(str ?? "")
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;");
/**
* Head tags for prerendered Hersteller (Manufacturers) page
*/
const generateHerstellerMetaTags = (baseUrl, config, manufacturerCount = 0) => {
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 `
<meta name="description" content="${e(descShort)}">
<meta property="og:title" content="${e("Hersteller | " + site)}">
<meta property="og:description" content="${e(descShort)}">
<meta property="og:url" content="${herstellerUrl}">
<meta property="og:type" content="website">
<meta property="og:image" content="${e(logoUrl)}">
<meta property="og:site_name" content="${e(site)}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${e("Hersteller | " + site)}">
<meta name="twitter:description" content="${e(descShort)}">
<meta name="robots" content="index, follow">
<link rel="canonical" href="${herstellerUrl}">
`;
};
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 "<script type=\"application/ld+json\">" + JSON.stringify(herstellerGraph) + "</script>";
};
module.exports = {
generateHerstellerMetaTags,
generateHerstellerJsonLd,
};

View File

@@ -5,6 +5,7 @@ const {
} = require('./product.cjs'); } = require('./product.cjs');
const { const {
generateCategoryMetaTags,
generateCategoryJsonLd, generateCategoryJsonLd,
} = require('./category.cjs'); } = require('./category.cjs');
@@ -22,6 +23,11 @@ const {
generateKonfiguratorMetaTags, generateKonfiguratorMetaTags,
} = require('./konfigurator.cjs'); } = require('./konfigurator.cjs');
const {
generateHerstellerMetaTags,
generateHerstellerJsonLd,
} = require('./hersteller.cjs');
const { const {
generateRobotsTxt, generateRobotsTxt,
generateProductsXml, generateProductsXml,
@@ -41,6 +47,7 @@ module.exports = {
generateProductJsonLd, generateProductJsonLd,
// Category functions // Category functions
generateCategoryMetaTags,
generateCategoryJsonLd, generateCategoryJsonLd,
// Homepage functions // Homepage functions
@@ -54,6 +61,10 @@ module.exports = {
// Konfigurator functions // Konfigurator functions
generateKonfiguratorMetaTags, generateKonfiguratorMetaTags,
// Hersteller functions
generateHerstellerMetaTags,
generateHerstellerJsonLd,
// Feed/Export functions // Feed/Export functions
generateRobotsTxt, generateRobotsTxt,
generateProductsXml, generateProductsXml,

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -54,6 +54,7 @@ const AGB = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/AGB.js"))
//const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); <Route path="/404" element={<NotFound404 />} /> //const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); <Route path="/404" element={<NotFound404 />} />
const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js")); const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js"));
const CategoriesPage = lazy(() => import(/* webpackChunkName: "categories" */ "./pages/CategoriesPage.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 Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js")); const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js")); const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js"));
@@ -267,6 +268,11 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
path="/Kategorie/:categoryId" path="/Kategorie/:categoryId"
element={<Content />} element={<Content />}
/> />
{/* Manufacturer page - Render Content in parallel */}
<Route
path="/Hersteller/:categoryId"
element={<Content />}
/>
{/* Single product page */} {/* Single product page */}
<Route <Route
path="/Artikel/:seoName" path="/Artikel/:seoName"
@@ -305,6 +311,7 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
<Route path="/agb" element={<AGB />} /> <Route path="/agb" element={<AGB />} />
<Route path="/sitemap" element={<Sitemap />} /> <Route path="/sitemap" element={<Sitemap />} />
<Route path="/Kategorien" element={<CategoriesPage />} /> <Route path="/Kategorien" element={<CategoriesPage />} />
<Route path="/Hersteller" element={<HerstellerPage />} />
<Route path="/impressum" element={<Impressum />} /> <Route path="/impressum" element={<Impressum />} />
<Route <Route
path="/batteriegesetzhinweise" path="/batteriegesetzhinweise"

View File

@@ -0,0 +1,84 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import Link from '@mui/material/Link';
import LegalPage from './pages/LegalPage.js';
const PrerenderHerstellerPage = ({ manufacturerData }) => {
// Use prop data (passed from prerender.cjs)
const manufacturers = manufacturerData;
// If no manufacturer data, show empty state
if (!manufacturers || manufacturers.length === 0) {
const content = (
<Box>
<Typography variant="body1" paragraph>
Keine Hersteller gefunden.
</Typography>
</Box>
);
return <LegalPage title="Hersteller" content={content} />;
}
// Render manufacturers similar to HerstellerPage.js
const content = (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: 2,
}}
>
{manufacturers.map((manufacturer) => (
<Paper
key={manufacturer.id}
component={Link}
href={`/Hersteller/${encodeURIComponent(manufacturer.slug || '')}`}
elevation={3}
style={{
width: '140px',
height: '140px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
userSelect: 'none',
textDecoration: 'none',
cursor: 'pointer',
borderRadius: '8px',
position: 'relative',
zIndex: 10,
backgroundColor: '#f0f0f0',
boxShadow: '0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,0.12)'
}}
sx={{
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: 8,
},
}}
>
{manufacturer.imageBuffer && (
<img
src={`data:image/avif;base64,${Buffer.from(manufacturer.imageBuffer).toString('base64')}`}
alt={manufacturer.name}
draggable={false}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
display: 'block',
}}
/>
)}
</Paper>
))}
</Box>
);
return <LegalPage title="Hersteller" content={content} />;
};
export default PrerenderHerstellerPage;

View File

@@ -551,7 +551,7 @@ class PrerenderProduct extends React.Component {
}) })
}, },
style: { style: {
fontFamily: '"Roboto","Helvetica","Arial",sans-serif', fontFamily: '"Outfit Variable","Roboto","Helvetica","Arial",sans-serif',
fontSize: '1rem', fontSize: '1rem',
lineHeight: '1.7', lineHeight: '1.7',
color: '#333' color: '#333'

View File

@@ -385,7 +385,7 @@ class AddToCartButton extends Component {
startIcon={<ShoppingCartIcon />} startIcon={<ShoppingCartIcon />}
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
fontWeight: "bold", whiteSpace: "nowrap",
backgroundColor: "#9ccc65", // yellowish green backgroundColor: "#9ccc65", // yellowish green
color: "#000000", color: "#000000",
"&:hover": { "&:hover": {
@@ -539,7 +539,7 @@ class AddToCartButton extends Component {
startIcon={<ShoppingCartIcon />} startIcon={<ShoppingCartIcon />}
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
fontWeight: "bold", whiteSpace: "nowrap",
"&:hover": { "&:hover": {
backgroundColor: "primary.dark", backgroundColor: "primary.dark",
}, },

View File

@@ -55,7 +55,10 @@ class ChatAssistant extends Component {
buildPrivacyPromptHtml = () => { buildPrivacyPromptHtml = () => {
const { t } = this.props; const { t } = this.props;
return `${t('chat.privacyPromptBefore')}<a href="/datenschutz" target="_blank" rel="noopener noreferrer">${t('chat.privacyPolicyLink')}</a>${t('chat.privacyPromptAfter')}<button data-confirm-privacy="true">${t('chat.privacyRead')}</button>`; return `<div style="display: flex; flex-direction: column; gap: 8px; line-height: 1.5;">
<div>${t('chat.privacyPromptBefore')}<a href="/datenschutz" target="_blank" rel="noopener noreferrer">${t('chat.privacyPolicyLink')}</a>${t('chat.privacyPromptAfter')}</div>
<div><button data-confirm-privacy="true">${t('chat.privacyRead')}</button></div>
</div>`;
}; };
/** Keep stored privacy bubble in sync with i18n (language switcher, lazy bundle load). */ /** Keep stored privacy bubble in sync with i18n (language switcher, lazy bundle load). */
@@ -228,7 +231,7 @@ class ChatAssistant extends Component {
}, () => { }, () => {
// Emit message to socket server after state is updated // Emit message to socket server after state is updated
if (userMessage.trim()) { if (userMessage.trim()) {
window.socketManager.emit('aiassyMessage', userMessage); window.socketManager.emit('aiassyMessage', { message: userMessage, lang: this.props.i18n?.language });
} }
}); });
} }

View File

@@ -11,7 +11,7 @@ import ProductList from './ProductList.js';
import CategoryBoxGrid from './CategoryBoxGrid.js'; import CategoryBoxGrid from './CategoryBoxGrid.js';
import CategoryBox from './CategoryBox.js'; import CategoryBox from './CategoryBox.js';
import { useParams, useSearchParams } from 'react-router-dom'; import { useParams, useSearchParams, useLocation } from 'react-router-dom';
import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js'; import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js';
import { withI18n } from '../i18n/withTranslation.js'; import { withI18n } from '../i18n/withTranslation.js';
import { withCategory } from '../context/CategoryContext.js'; import { withCategory } from '../context/CategoryContext.js';
@@ -24,17 +24,19 @@ const withRouter = (ClassComponent) => {
return (props) => { return (props) => {
const params = useParams(); const params = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
return <ClassComponent {...props} params={params} searchParams={searchParams} />; const location = useLocation();
const isHersteller = location.pathname.startsWith('/Hersteller/');
return <ClassComponent {...props} params={params} searchParams={searchParams} isHersteller={isHersteller} />;
}; };
}; };
function getCachedCategoryData(categoryId, language = 'de') { function getCachedCategoryData(categoryId, language = 'de', isHersteller = false) {
if (!window.productCache) { if (!window.productCache) {
window.productCache = {}; window.productCache = {};
} }
try { try {
const cacheKey = `categoryProducts_${categoryId}_${language}`; const cacheKey = `${isHersteller ? 'manufacturer' : 'category'}Products_${categoryId}_${language}`;
const cachedData = window.productCache[cacheKey]; const cachedData = window.productCache[cacheKey];
if (cachedData) { if (cachedData) {
@@ -166,7 +168,7 @@ function getFilteredProducts(unfilteredProducts, attributes, t) {
return { filteredProducts, activeAttributeFilters: activeAttributeFiltersWithNames, activeManufacturerFilters: activeManufacturerFiltersWithNames, activeAvailabilityFilters }; return { filteredProducts, activeAttributeFilters: activeAttributeFiltersWithNames, activeManufacturerFilters: activeManufacturerFiltersWithNames, activeAvailabilityFilters };
} }
function setCachedCategoryData(categoryId, data, language = 'de') { function setCachedCategoryData(categoryId, data, language = 'de', isHersteller = false) {
if (!window.productCache) { if (!window.productCache) {
window.productCache = {}; window.productCache = {};
} }
@@ -175,7 +177,7 @@ function setCachedCategoryData(categoryId, data, language = 'de') {
} }
try { try {
const cacheKey = `categoryProducts_${categoryId}_${language}`; const cacheKey = `${isHersteller ? 'manufacturer' : 'category'}Products_${categoryId}_${language}`;
if (data.products) for (const product of data.products) { if (data.products) for (const product of data.products) {
const productCacheKey = `product_${product.id}_${language}`; const productCacheKey = `product_${product.id}_${language}`;
window.productDetailCache[productCacheKey] = product; window.productDetailCache[productCacheKey] = product;
@@ -221,9 +223,10 @@ class Content extends Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const currentLanguage = this.props.i18n?.language || 'de'; const currentLanguage = this.props.i18n?.language || 'de';
const categoryChanged = this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId); const categoryChanged = this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId);
const routeTypeChanged = !!prevProps.isHersteller !== !!this.props.isHersteller;
const searchChanged = this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q')); const searchChanged = this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q'));
if (categoryChanged) { if (categoryChanged || routeTypeChanged) {
// Clear context for new category loading // Clear context for new category loading
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) { if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
this.props.categoryContext.setCurrentCategory(null); this.props.categoryContext.setCurrentCategory(null);
@@ -233,7 +236,7 @@ class Content extends Component {
this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => { this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
this.fetchCategoryData(this.props.params.categoryId); this.fetchCategoryData(this.props.params.categoryId);
}); });
return; // Don't check language change if category changed return; // Don't check language change if category or route type changed
} }
else if (searchChanged) { else if (searchChanged) {
this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => { this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
@@ -345,7 +348,8 @@ class Content extends Component {
sessionStorage.setItem('filter_availability', '1'); sessionStorage.setItem('filter_availability', '1');
} }
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de'; const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
const cachedData = getCachedCategoryData(categoryId, currentLanguage); const isHersteller = !!this.props.isHersteller;
const cachedData = getCachedCategoryData(categoryId, currentLanguage, isHersteller);
if (cachedData) { if (cachedData) {
this.processDataWithCategoryTree(cachedData, categoryId); this.processDataWithCategoryTree(cachedData, categoryId);
return; return;
@@ -360,7 +364,7 @@ class Content extends Component {
window.socketManager.on(`productList:${categoryId}`, (response) => { window.socketManager.on(`productList:${categoryId}`, (response) => {
console.log("getCategoryProducts full response", response); console.log("getCategoryProducts full response", response);
receivedFullResponse = true; receivedFullResponse = true;
setCachedCategoryData(categoryId, response, currentLanguage); setCachedCategoryData(categoryId, response, currentLanguage, isHersteller);
if (response && response.products !== undefined) { if (response && response.products !== undefined) {
this.processDataWithCategoryTree(response, categoryId); this.processDataWithCategoryTree(response, categoryId);
} else { } else {
@@ -370,12 +374,17 @@ class Content extends Component {
window.socketManager.emit( window.socketManager.emit(
"getCategoryProducts", "getCategoryProducts",
{ categoryId: categoryId, language: currentLanguage, requestTranslation: currentLanguage === 'de' ? false : true }, {
categoryId: categoryId,
language: currentLanguage,
requestTranslation: currentLanguage === 'de' ? false : true,
isHersteller,
},
(response) => { (response) => {
console.log("getCategoryProducts stub response", response); console.log("getCategoryProducts stub response", response);
// Only process stub response if we haven't received the full response yet // Only process stub response if we haven't received the full response yet
if (!receivedFullResponse) { if (!receivedFullResponse) {
setCachedCategoryData(categoryId, response, currentLanguage); setCachedCategoryData(categoryId, response, currentLanguage, isHersteller);
if (response && response.products !== undefined) { if (response && response.products !== undefined) {
this.processDataWithCategoryTree(response, categoryId); this.processDataWithCategoryTree(response, categoryId);
} else { } else {
@@ -454,7 +463,7 @@ class Content extends Component {
const n = typeof v === 'number' ? v : parseInt(String(v), 10); const n = typeof v === 'number' ? v : parseInt(String(v), 10);
return Number.isFinite(n) && n > 0; return Number.isFinite(n) && n > 0;
}; };
if (categoryId !== 'neu' && categoryId !== 'bald' && !isValidJtlCategoryId(enhancedResponse.dataParam)) { if (!this.props.isHersteller && categoryId !== 'neu' && categoryId !== 'bald' && !isValidJtlCategoryId(enhancedResponse.dataParam)) {
try { try {
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de'; const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage); const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);

View File

@@ -16,6 +16,7 @@ const StyledRouterLink = styled(RouterLink)(() => ({
lineHeight: '1.5', lineHeight: '1.5',
display: 'block', display: 'block',
padding: '4px 8px', padding: '4px 8px',
whiteSpace: 'nowrap',
'&:hover': { '&:hover': {
textDecoration: 'underline', textDecoration: 'underline',
}, },
@@ -223,25 +224,13 @@ class Footer extends Component {
alignItems={{ xs: 'center', md: 'flex-end' }} alignItems={{ xs: 'center', md: 'flex-end' }}
> >
{/* Legal Links Section */} {/* Legal Links Section */}
<Stack <Stack direction="column" spacing={0.5} justifyContent="center" alignItems="flex-start">
direction={{ xs: 'row', md: 'column' }}
spacing={{ xs: 2, md: 0.5 }}
justifyContent="center"
alignItems={{ xs: 'center', md: 'left' }}
flexWrap="wrap"
>
<StyledRouterLink to="/datenschutz">{this.props.t ? this.props.t('footer.legal.datenschutz') : 'Datenschutz'}</StyledRouterLink> <StyledRouterLink to="/datenschutz">{this.props.t ? this.props.t('footer.legal.datenschutz') : 'Datenschutz'}</StyledRouterLink>
<StyledRouterLink to="/agb">{this.props.t ? this.props.t('footer.legal.agb') : 'AGB'}</StyledRouterLink> <StyledRouterLink to="/agb">{this.props.t ? this.props.t('footer.legal.agb') : 'AGB'}</StyledRouterLink>
<StyledRouterLink to="/sitemap">{this.props.t ? this.props.t('footer.legal.sitemap') : 'Sitemap'}</StyledRouterLink> <StyledRouterLink to="/sitemap">{this.props.t ? this.props.t('footer.legal.sitemap') : 'Sitemap'}</StyledRouterLink>
</Stack> </Stack>
<Stack <Stack direction="column" spacing={0.5} justifyContent="center" alignItems="flex-start">
direction={{ xs: 'row', md: 'column' }}
spacing={{ xs: 2, md: 0.5 }}
justifyContent="center"
alignItems={{ xs: 'center', md: 'left' }}
flexWrap="wrap"
>
<StyledRouterLink to="/impressum">{this.props.t ? this.props.t('footer.legal.impressum') : 'Impressum'}</StyledRouterLink> <StyledRouterLink to="/impressum">{this.props.t ? this.props.t('footer.legal.impressum') : 'Impressum'}</StyledRouterLink>
<StyledRouterLink to="/batteriegesetzhinweise">{this.props.t ? this.props.t('footer.legal.batteriegesetzhinweise') : 'Batteriegesetzhinweise'}</StyledRouterLink> <StyledRouterLink to="/batteriegesetzhinweise">{this.props.t ? this.props.t('footer.legal.batteriegesetzhinweise') : 'Batteriegesetzhinweise'}</StyledRouterLink>
<StyledRouterLink to="/widerrufsrecht">{this.props.t ? this.props.t('footer.legal.widerrufsrecht') : 'Widerrufsrecht'}</StyledRouterLink> <StyledRouterLink to="/widerrufsrecht">{this.props.t ? this.props.t('footer.legal.widerrufsrecht') : 'Widerrufsrecht'}</StyledRouterLink>
@@ -346,7 +335,7 @@ class Footer extends Component {
{/* Copyright Section */} {/* Copyright Section */}
<Box sx={{ pb: 0, textAlign: 'center', filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))', opacity: 0.7 }}> <Box sx={{ pb: 0, textAlign: 'center', filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))', opacity: 0.7 }}>
<Typography variant="body2" sx={{ mb: 1, fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}> <Typography variant="body2" sx={{ mb: 1, fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5, whiteSpace: 'nowrap' }}>
{this.props.t ? this.props.t('footer.allPricesIncl') : '* Alle Preise inkl. gesetzlicher USt., zzgl. Versand'} {this.props.t ? this.props.t('footer.allPricesIncl') : '* Alle Preise inkl. gesetzlicher USt., zzgl. Versand'}
</Typography> </Typography>
<Typography <Typography

View File

@@ -291,8 +291,22 @@ const MainPageLayout = () => {
const currentPath = location.pathname; const currentPath = location.pathname;
const { t } = useTranslation(); const { t } = useTranslation();
const [starHovered, setStarHovered] = React.useState(false); const [starHovered, setStarHovered] = React.useState(false);
// State to track kiosk mode
const [isKiosk, setIsKiosk] = React.useState(() => window.growheadskiosk === true);
// Listen for the custom event
React.useEffect(() => {
const handleKioskChange = () => {
setIsKiosk(window.growheadskiosk === true);
};
window.addEventListener('growheadskiosk-change', handleKioskChange);
return () => window.removeEventListener('growheadskiosk-change', handleKioskChange);
}, []);
const translatedContent = { const translatedContent = {
buildYourSet: t('sections.buildYourSet'), buildYourSet: isKiosk ? 'Schau in den Stecklingskatalog' : t('sections.buildYourSet'),
selectSeedRate: t('sections.selectSeedRate'), selectSeedRate: t('sections.selectSeedRate'),
outdoorSeason: t('sections.outdoorSeason') outdoorSeason: t('sections.outdoorSeason')
}; };
@@ -317,7 +331,7 @@ const MainPageLayout = () => {
const allContentBoxes = { const allContentBoxes = {
home: [ home: [
{ title: t('sections.seeds'), image: "/assets/images/seeds.avif", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" }, { title: t('sections.seeds'), image: "/assets/images/seeds.avif", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" },
{ title: t('sections.konfigurator'), image: "/assets/images/konfigurator.avif", bgcolor: "#e8f5d6", link: "/Konfigurator" } { title: isKiosk ? 'Stecklingskatalog' : t('sections.konfigurator'), image: isKiosk ? "/assets/images/cutlings2.avif" : "/assets/images/konfigurator.avif", bgcolor: "#e8f5d6", link: isKiosk ? "https://cloneheads.de" : "/Konfigurator" }
], ],
aktionen: [ aktionen: [
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/Artikel/Graveda-10t-presse-tagesmiete-inkl-prepress-vorpressform" }, { title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/Artikel/Graveda-10t-presse-tagesmiete-inkl-prepress-vorpressform" },

View File

@@ -1,17 +1,25 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom';
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; 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 { withTranslation } from 'react-i18next';
import { withLanguage } from '../i18n/withTranslation.js'; import { withLanguage } from '../i18n/withTranslation.js';
const ITEM_WIDTH = 140 + 16; // 140px width + 16px gap const ITEM_WIDTH = 140 + 16; // 140px width + 16px gap
const AUTO_SCROLL_SPEED = 1.0; const AUTO_SCROLL_SPEED = 1.0;
const AUTOSCROLL_RESTART_DELAY = 5000;
class ManufacturerCarousel extends React.Component { class ManufacturerCarousel extends React.Component {
_isMounted = false; _isMounted = false;
originalItems = []; originalItems = [];
animationFrame = null; animationFrame = null;
autoScrollActive = true;
translateX = 0; translateX = 0;
inactivityTimer = null;
constructor(props) { constructor(props) {
super(props); super(props);
@@ -28,10 +36,8 @@ class ManufacturerCarousel extends React.Component {
componentWillUnmount() { componentWillUnmount() {
this._isMounted = false; this._isMounted = false;
if (this.animationFrame) { this.stopAutoScroll();
cancelAnimationFrame(this.animationFrame); this.clearInactivityTimer();
this.animationFrame = null;
}
// Revoke object URLs to avoid memory leaks // Revoke object URLs to avoid memory leaks
for (const item of this.originalItems) { for (const item of this.originalItems) {
if (item.src) URL.revokeObjectURL(item.src); if (item.src) URL.revokeObjectURL(item.src);
@@ -46,7 +52,12 @@ class ManufacturerCarousel extends React.Component {
.filter(m => m.imageBuffer) .filter(m => m.imageBuffer)
.map(m => { .map(m => {
const blob = new Blob([m.imageBuffer], { type: 'image/avif' }); const blob = new Blob([m.imageBuffer], { type: 'image/avif' });
return { id: m.id, name: m.name || '', src: URL.createObjectURL(blob) }; return {
id: m.id,
name: m.name || '',
slug: m.slug || '',
src: URL.createObjectURL(blob),
};
}) })
.sort(() => Math.random() - 0.5); .sort(() => Math.random() - 0.5);
@@ -60,13 +71,38 @@ class ManufacturerCarousel extends React.Component {
}; };
startAutoScroll = () => { startAutoScroll = () => {
this.autoScrollActive = true;
if (!this.animationFrame) { if (!this.animationFrame) {
this.animationFrame = requestAnimationFrame(this.tick); 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 = () => { tick = () => {
if (!this._isMounted || this.originalItems.length === 0) return; if (!this._isMounted || !this.autoScrollActive || this.originalItems.length === 0) return;
this.translateX -= AUTO_SCROLL_SPEED; this.translateX -= AUTO_SCROLL_SPEED;
@@ -82,6 +118,41 @@ class ManufacturerCarousel extends React.Component {
this.animationFrame = requestAnimationFrame(this.tick); 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() { render() {
const { t } = this.props; const { t } = this.props;
const { items } = this.state; const { items } = this.state;
@@ -90,19 +161,36 @@ class ManufacturerCarousel extends React.Component {
return ( return (
<Box sx={{ mt: 4, mb: 4 }}> <Box sx={{ mt: 4, mb: 4 }}>
<Typography <Box
variant="h4" component={Link}
component="div" to="/Hersteller"
sx={{ sx={{
fontFamily: 'SwashingtonCP', display: 'flex',
textShadow: '3px 3px 10px rgba(0, 0, 0, 0.4)', alignItems: 'center',
textAlign: 'center', justifyContent: 'center',
mb: 2, textDecoration: 'none',
color: 'primary.main', color: 'primary.main',
mb: 2,
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateX(5px)',
color: 'primary.dark',
},
}} }}
> >
{t('product.manufacturer')} <Typography
</Typography> variant="h4"
component="span"
sx={{
fontFamily: 'SwashingtonCP',
textShadow: '3px 3px 10px rgba(0, 0, 0, 0.4)',
textAlign: 'center',
}}
>
{t('product.manufacturer')}
</Typography>
<ChevronRight sx={{ fontSize: '2.5rem', ml: 1 }} />
</Box>
<div <div
style={{ style={{
@@ -129,6 +217,46 @@ class ManufacturerCarousel extends React.Component {
zIndex: 2, pointerEvents: 'none', zIndex: 2, pointerEvents: 'none',
}} /> }} />
{/* Left Arrow */}
<IconButton
aria-label="Vorherige Hersteller anzeigen"
onClick={this.handleLeftClick}
style={{
position: 'absolute',
top: '50%',
left: '8px',
transform: 'translateY(-50%)',
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
height: '48px',
borderRadius: '50%'
}}
>
<ChevronLeft />
</IconButton>
{/* Right Arrow */}
<IconButton
aria-label="Nächste Hersteller anzeigen"
onClick={this.handleRightClick}
style={{
position: 'absolute',
top: '50%',
right: '8px',
transform: 'translateY(-50%)',
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
height: '48px',
borderRadius: '50%'
}}
>
<ChevronRight />
</IconButton>
<div <div
style={{ style={{
position: 'relative', position: 'relative',
@@ -151,8 +279,11 @@ class ManufacturerCarousel extends React.Component {
}} }}
> >
{items.map((item, index) => ( {items.map((item, index) => (
<div <Paper
key={`${item.id}-${index}`} key={`${item.id}-${index}`}
component={Link}
to={`/Hersteller/${encodeURIComponent(item.slug || '')}`}
elevation={3}
style={{ style={{
flex: '0 0 140px', flex: '0 0 140px',
width: '140px', width: '140px',
@@ -162,7 +293,20 @@ class ManufacturerCarousel extends React.Component {
justifyContent: 'center', justifyContent: 'center',
overflow: 'hidden', overflow: 'hidden',
userSelect: 'none', userSelect: 'none',
pointerEvents: 'none', textDecoration: 'none',
cursor: 'pointer',
borderRadius: '8px',
position: 'relative',
zIndex: 10,
backgroundColor: '#f0f0f0',
boxShadow: '0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,0.12)'
}}
sx={{
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: 8,
},
}} }}
> >
<img <img
@@ -176,7 +320,7 @@ class ManufacturerCarousel extends React.Component {
display: 'block', display: 'block',
}} }}
/> />
</div> </Paper>
))} ))}
</div> </div>
</div> </div>

View File

@@ -85,7 +85,7 @@ class Stripe extends Component {
colorWarning: '#FF9800', // Orange for warnings colorWarning: '#FF9800', // Orange for warnings
// Typography matching your Roboto setup // Typography matching your Roboto setup
fontFamily: "'Roboto', 'Helvetica', 'Arial', sans-serif", fontFamily: "'Outfit Variable', 'Roboto', 'Helvetica', 'Arial', sans-serif",
fontSizeBase: '16px', // Base font size for mobile compatibility fontSizeBase: '16px', // Base font size for mobile compatibility
fontWeightNormal: '400', // Normal Roboto weight fontWeightNormal: '400', // Normal Roboto weight
fontWeightMedium: '500', // Medium Roboto weight fontWeightMedium: '500', // Medium Roboto weight

View File

@@ -71,7 +71,7 @@ const ThemeCustomizerDialog = ({ open, onClose, theme, onThemeChange }) => {
}, },
}, },
typography: { typography: {
fontFamily: "'Roboto', 'Helvetica', 'Arial', sans-serif", fontFamily: "'Outfit Variable', 'Roboto', 'Helvetica', 'Arial', sans-serif",
h4: { h4: {
fontWeight: 600, fontWeight: 600,
color: '#33691E', color: '#33691E',

View File

@@ -183,7 +183,7 @@ class CategoryList extends Component {
display: "flex", display: "flex",
justifyContent: "flex-start", justifyContent: "flex-start",
alignItems: "center", alignItems: "center",
flexWrap: "wrap", flexWrap: isMobile ? "wrap" : "nowrap",
overflowX: "visible", overflowX: "visible",
flexDirection: isMobile ? "column" : "row", flexDirection: isMobile ? "column" : "row",
py: 0.5, // Add vertical padding to prevent border clipping py: 0.5, // Add vertical padding to prevent border clipping
@@ -197,7 +197,7 @@ class CategoryList extends Component {
aria-label="Zur Startseite" aria-label="Zur Startseite"
onClick={isMobile ? this.handleMobileCategoryClick : undefined} onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{ sx={{
fontSize: "0.75rem", fontSize: "0.85rem",
textTransform: "none", textTransform: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
opacity: 0.9, opacity: 0.9,
@@ -275,7 +275,7 @@ class CategoryList extends Component {
aria-label="Neuheiten" aria-label="Neuheiten"
onClick={isMobile ? this.handleMobileCategoryClick : undefined} onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{ sx={{
fontSize: "0.75rem", fontSize: "0.85rem",
textTransform: "none", textTransform: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
opacity: 0.9, opacity: 0.9,
@@ -353,7 +353,7 @@ class CategoryList extends Component {
aria-label={this.props.t ? this.props.t('navigation.soon') : 'Demnächst'} aria-label={this.props.t ? this.props.t('navigation.soon') : 'Demnächst'}
onClick={isMobile ? this.handleMobileCategoryClick : undefined} onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{ sx={{
fontSize: "0.75rem", fontSize: "0.85rem",
textTransform: "none", textTransform: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
opacity: 0.9, opacity: 0.9,
@@ -435,7 +435,7 @@ class CategoryList extends Component {
size="small" size="small"
onClick={isMobile ? this.handleMobileCategoryClick : undefined} onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{ sx={{
fontSize: "0.75rem", fontSize: "0.85rem",
textTransform: "none", textTransform: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
opacity: 0.9, opacity: 0.9,
@@ -506,7 +506,7 @@ class CategoryList extends Component {
alignItems: "center", alignItems: "center",
height: "33px", // Match small button height height: "33px", // Match small button height
px: 1, px: 1,
fontSize: "0.75rem", fontSize: "0.85rem",
opacity: 0.9, opacity: 0.9,
}} }}
> >
@@ -522,7 +522,7 @@ class CategoryList extends Component {
aria-label={this.props.t ? this.props.t('navigation.konfiguratorAria') : 'Zum Konfigurator'} aria-label={this.props.t ? this.props.t('navigation.konfiguratorAria') : 'Zum Konfigurator'}
onClick={isMobile ? this.handleMobileCategoryClick : undefined} onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{ sx={{
fontSize: "0.75rem", fontSize: "0.85rem",
textTransform: "none", textTransform: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
opacity: 0.9, opacity: 0.9,

View File

@@ -41,7 +41,7 @@
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
font-family: Roboto, Helvetica, Arial, sans-serif; font-family: 'Outfit Variable', Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
overflow-y: scroll; /* Always show vertical scrollbar */ overflow-y: scroll; /* Always show vertical scrollbar */

View File

@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import "@fontsource-variable/outfit";
import "./index.css"; import "./index.css";
import App from "./App.js"; import App from "./App.js";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";

168
src/pages/HerstellerPage.js Normal file
View File

@@ -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 (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: 2,
}}
>
{manufacturers.map((manufacturer) => (
<Paper
key={manufacturer.id}
component={Link}
to={`/Hersteller/${encodeURIComponent(manufacturer.slug || '')}`}
elevation={3}
style={{
width: '140px',
height: '140px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
userSelect: 'none',
textDecoration: 'none',
cursor: 'pointer',
borderRadius: '8px',
position: 'relative',
zIndex: 10,
backgroundColor: '#f0f0f0',
boxShadow: '0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,0.12)'
}}
sx={{
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: 8,
},
}}
>
<img
src={manufacturer.src}
alt={manufacturer.name}
draggable={false}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
display: 'block',
}}
/>
</Paper>
))}
</Box>
);
};
render() {
const { t } = this.props;
const { loading } = this.state;
const content = (
<Box>
{loading ? null : (
this.renderManufacturerGrid()
)}
</Box>
);
return <LegalPage title={t ? t('product.manufacturer') : 'Hersteller'} content={content} />;
}
}
export default withI18n()(HerstellerPage);

View File

@@ -153,6 +153,12 @@ class SocketManager {
auth: this._buildSocketAuth() auth: this._buildSocketAuth()
}); });
this._socket.on('kiosk', () => {
window.growheadskiosk = true;
window.dispatchEvent(new Event('growheadskiosk-change'));
console.warn('Kiosk mode enabled via socket event');
});
// Always refresh auth data before reconnect attempts. // Always refresh auth data before reconnect attempts.
if (this._socket.io && this._socket.io.on) { if (this._socket.io && this._socket.io.on) {
this._socket.io.on('reconnect_attempt', () => { this._socket.io.on('reconnect_attempt', () => {

View File

@@ -29,7 +29,7 @@ const theme = createTheme({
}, },
}, },
typography: { typography: {
fontFamily: "'Roboto', 'Helvetica', 'Arial', sans-serif", fontFamily: "'Outfit Variable', 'Roboto', 'Helvetica', 'Arial', sans-serif",
h4: { h4: {
fontWeight: 600, fontWeight: 600,
color: '#33691E', color: '#33691E',

View File

@@ -5,17 +5,31 @@ import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import ESLintPlugin from 'eslint-webpack-plugin'; import ESLintPlugin from 'eslint-webpack-plugin';
import { cpSync } from 'fs'; import { cpSync } from 'fs';
import { execSync } from 'child_process'; import { execFileSync } from 'child_process';
import webpack from 'webpack'; import webpack from 'webpack';
import fs from 'fs'; import fs from 'fs';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
// Get git commit hash // Git hash for meta tag / currentHash.json — prefer env (CI), then git without shell (avoids EPERM on spawn /bin/sh in some sandboxes)
const getGitCommitHash = () => { const getGitCommitHash = () => {
const fromEnv =
process.env.GIT_COMMIT ||
process.env.VERCEL_GIT_COMMIT_SHA ||
process.env.CI_COMMIT_SHA ||
process.env.GITHUB_SHA ||
'';
if (fromEnv) return String(fromEnv).trim();
try { try {
return execSync('git rev-parse HEAD').toString().trim(); return execFileSync('git', ['rev-parse', 'HEAD'], {
encoding: 'utf8',
maxBuffer: 1024 * 1024,
}).trim();
} catch (e) { } catch (e) {
console.error('Failed to get git commit hash:', e); console.warn(
'Git commit hash unavailable (set GIT_COMMIT or run build in a git repo):',
e && e.message ? e.message : e
);
return 'unknown'; return 'unknown';
} }
}; };
@@ -301,14 +315,53 @@ export default {
priority: 20, priority: 20,
reuseExistingChunk: true, reuseExistingChunk: true,
}, },
// Keep Stripe checkout out of the initial vendor bundle (loaded with payment routes)
stripe: {
test: /[\\/]node_modules[\\/]@stripe[\\/]/,
name: 'stripe',
priority: 19,
chunks: 'async',
reuseExistingChunk: true,
enforce: true,
},
// QR / SEPA helpers — checkout & profile only (not htmlParser: that caused CLS on product pages)
payments: {
test: /[\\/]node_modules[\\/](qrcode|sepa-payment-qr-code|iban)[\\/]/,
name: 'payments',
priority: 17,
chunks: 'async',
reuseExistingChunk: true,
enforce: true,
},
// socket.io-client and its dependencies — always async, never initial // socket.io-client and its dependencies — always async, never initial
socketio: { socketio: {
test: /[\\/]node_modules[\\/](socket\.io-client|engine\.io-client|@socket\.io|socket\.io-parser|socket\.io-msgpack-parser)[\\/]/, test: /[\\/]node_modules[\\/](socket\.io-client|engine\.io-client|engine\.io-parser|@socket\.io|socket\.io-parser|socket\.io-msgpack-parser)[\\/]/,
name: 'socketio', name: 'socketio',
priority: 15, priority: 15,
chunks: 'async', chunks: 'async',
reuseExistingChunk: true, reuseExistingChunk: true,
}, },
// Language switcher flags: dynamic import() must stay async-only. The catch-all `vendor`
// group below uses chunks: 'all' and would otherwise merge this into vendor → no extra
// network request on menu open (flags shipped in initial load).
countryFlagIcons: {
test: /[\\/]node_modules[\\/]country-flag-icons[\\/]/,
name: 'country-flag-icons',
priority: 12,
chunks: 'async',
reuseExistingChunk: true,
enforce: true,
},
// LazySanitizedHtml (product HTML etc.): keep parser stack out of initial vendor.
// Do NOT include postcss here — it is used by other tools and forced async caused CLS before.
htmlSanitizeAsync: {
test: /[\\/]node_modules[\\/](html-react-parser|sanitize-html|htmlparser2|domhandler|dom-serializer|entities|react-property|parse-srcset)[\\/]/,
name: 'html-sanitize-async',
priority: 11,
chunks: 'async',
reuseExistingChunk: true,
enforce: true,
},
// Other vendor libraries // Other vendor libraries
vendor: { vendor: {
test: /[\\/]node_modules[\\/]/, test: /[\\/]node_modules[\\/]/,