Compare commits

...

41 Commits

Author SHA1 Message Date
seb
ccceb8fe78 Add Mollie payment integration to CartTab and OrderProcessingService
- Introduced Mollie payment method in CartTab, allowing users to select it alongside existing payment options.
- Implemented loading and error handling for the Mollie component.
- Updated OrderProcessingService to create Mollie payment intents.
- Adjusted PaymentMethodSelector to switch to Mollie when specific delivery methods are selected.
- Enhanced CartTab to store cart items for Mollie payments and calculate total amounts accordingly.
2025-07-06 02:21:52 +02:00
seb
ea5ac762b2 Update .gitignore to exclude 404 page and enhance prerendering logic to support pagination for category-specific LLM files. Implemented generateAllCategoryLlmsPages function to create multiple paginated files, improving product catalog clarity and organization. 2025-07-05 16:20:00 +02:00
seb
40ec0287fd Refactor prerendering logic to include PrerenderSitemap component, enabling category data handling for the sitemap page. Update Sitemap component to initialize categories and loading state more efficiently, improving performance and clarity. 2025-07-05 16:13:38 +02:00
seb
47364d3ad8 Implement caching mechanism in Sitemap component to optimize category data retrieval. Added functions to check for cached product data and initialize categories from cache, improving performance for prerendered environments and reducing unnecessary socket requests. 2025-07-05 16:06:42 +02:00
seb
a6d7ed3e27 Refactor Sitemap component to improve context usage by replacing direct socket reference with context destructuring. This change enhances code clarity and maintains functionality for fetching category data. 2025-07-05 15:57:00 +02:00
seb
f8f03b45b8 Refactor SEO module to utilize a modular structure by re-exporting all SEO functions from a new index file. This change maintains backward compatibility while streamlining the codebase for future enhancements. 2025-07-05 15:52:34 +02:00
seb
eb0d5621e6 Enhance SEO text generation for product categories by implementing pagination support. Updated generateCategoryLlmsTxt to include page navigation and product count details, improving clarity for users accessing product catalogs. Added helper function generateAllCategoryLlmsPages to facilitate the creation of multiple category pages. 2025-07-05 15:41:36 +02:00
seb
f81b9d12df Refactor pagination visibility in ProductList component to prevent layout shifts when no products are available. Updated logic to use CSS visibility instead of conditional rendering. 2025-07-05 15:21:38 +02:00
seb
8ea3b1b6a3 Enhance ProductList component by adding conditional rendering for pagination and no products message based on active filters. Implement helper function for product count text to improve clarity in product display. 2025-07-05 15:18:55 +02:00
seb
fb3450aa23 Implement availability filters in Content and ProductList components to enhance product filtering functionality. Active filters for "auf Lager", "Neu", and "Bald verfügbar" are now dynamically displayed based on product availability, improving user experience and filtering accuracy. 2025-07-05 13:57:29 +02:00
seb
11f5b2cbfd Update package dependencies to latest versions, enhancing compatibility and performance. Adjust start scripts in package.json to include NODE_OPTIONS for deprecation warnings. Add new launch configuration in VSCode for easier development setup. Enhance product filtering logic in Content component to ensure new product filters are only applied when applicable. 2025-07-05 13:48:42 +02:00
seb
5fc0c3213b Enhance responsive design in profile components by adjusting padding and layout properties for improved mobile usability. Updated styles in ButtonGroup, CartTab, OrdersTab, SettingsTab, and ProfilePage to ensure better visual consistency across different screen sizes. 2025-07-04 05:01:39 +02:00
seb
8abf64ca38 Enhance responsive design across components by adjusting padding, margins, and layout properties for better mobile usability. Updated styles in Content, Header, Product, ProductFilters, ProductList, and SearchBar components to improve visual consistency and user experience on various screen sizes. 2025-07-04 04:51:15 +02:00
seb
e16ae9f5a6 Refactor CategoryList component to improve text styling and hover effects. Removed bold font weight, added text shadow for better visual hierarchy, and implemented a dual-text approach for category names to enhance readability and user experience on mobile devices. 2025-07-04 04:00:24 +02:00
seb
63a8d7b81b Update CategoryList component styles to enhance visual hierarchy and consistency. Changed font weight to bold and adjusted background and text colors for active and hover states, improving overall user experience. 2025-07-04 03:52:38 +02:00
seb
d2d5d884d9 Refactor CategoryList component to improve styling and hover effects, enhancing visual consistency and responsiveness for mobile users. Adjusted background colors and removed unnecessary border properties for a cleaner design. 2025-07-04 03:28:56 +02:00
seb
9fc4286b8e Refactor LoginComponent button text from 'Anmelden' to 'Login' and enhance CategoryList with mobile menu functionality, including collapsible categories and improved layout for mobile screens. Adjust Home component card heights for better responsiveness across devices. 2025-07-04 01:56:10 +02:00
seb
6afe2ca90f Update sh.png image to enhance visual quality and maintain consistency with recent improvements. 2025-07-03 09:18:59 +02:00
seb
c2758d6dd4 Update sh.png image to improve visual content quality. 2025-07-03 09:15:24 +02:00
seb
f4787ad523 Update language attribute in HTML and correct query-input key in JSON-LD generation for improved SEO accuracy. 2025-07-03 09:08:31 +02:00
seb
6b0b54dc16 Refactor SEO meta tag generation to ensure proper URL formatting across homepage and configurator, enhancing SEO accuracy and consistency. 2025-07-03 09:03:42 +02:00
seb
ceed9f0714 Refactor GTIN/EAN generation in generateProductsXml to simplify logic and handle missing values more gracefully, enhancing data integrity in XML output. 2025-07-03 07:58:20 +02:00
seb
0d92495914 Remove hasOfferCatalog from JSON-LD generation in homepage, streamlining the structure and improving SEO data accuracy. 2025-07-03 07:17:23 +02:00
seb
341d575c83 Enhance JSON-LD generation for products and homepage by adding detailed product attributes, updating contact email, and refining category descriptions, improving overall SEO structure and data accuracy. 2025-07-03 07:15:14 +02:00
seb
db966bae12 Update contact information and address in JSON-LD for homepage, improving accuracy of SEO data 2025-07-03 07:09:46 +02:00
seb
20ea55705e Add Organization and FAQ JSON-LD schemas to enhance SEO for homepage 2025-07-03 07:03:40 +02:00
seb
569f053757 Enhance product rendering by incorporating category information into JSON-LD generation and parallel rendering functions, improving SEO structure and breadcrumb support. 2025-07-03 07:00:13 +02:00
seb
5d5c09abbf Remove redundant category name and URL from JSON-LD generation in homepage, streamlining the structure for improved SEO. 2025-07-03 06:54:22 +02:00
seb
2dde151f87 Refactor JSON-LD generation for homepage to use ItemList instead of BreadcrumbList, enhancing category representation and SEO structure. 2025-07-03 06:51:16 +02:00
seb
379f75947a Enhance JSON-LD generation for homepage and sitemap by including category data, improving SEO structure and breadcrumb support. 2025-07-03 06:45:55 +02:00
seb
f24429caef Implement product price validation in generateProductsXml to skip products with a price of zero, improving data integrity in XML generation. 2025-07-03 06:35:51 +02:00
seb
e4d077e402 Refactor socket context usage in CategoryBox, SearchBar, and Home components for improved clarity and consistency in data fetching. 2025-07-03 05:55:36 +02:00
seb
245f5067ed Refactor socket handling across components to support dual socket connections, enhancing data fetching capabilities and improving overall communication. 2025-07-03 05:36:41 +02:00
seb
1ed06804a0 Enhance category data fetching by adding full response handling and improving socket communication in Content component 2025-07-03 04:01:10 +02:00
sebseb7
f326596f48 license in package-lock.json 2025-07-03 02:29:11 +02:00
sebseb7
fca10153fc domain change 2025-07-03 02:17:06 +02:00
seb
8884255696 Enhance NotFound404 component with centered 404 image and updated styling 2025-07-03 00:12:12 +02:00
seb
de51f5f409 Add 404 Not Found page component and update routing and sitemap configuration 2025-07-03 00:09:45 +02:00
seb
94b274b10d Update license from ISC to 0BSD and add license section to README.md 2025-07-02 14:23:24 +02:00
seb
d6a92a79a3 Refactor layout of fixed elements in App.js to enhance visual consistency 2025-07-02 14:23:24 +02:00
seb
f1860e0c8b add vscode launch file 2025-07-02 13:51:55 +02:00
47 changed files with 2844 additions and 1256 deletions

2
.gitignore vendored
View File

@@ -27,6 +27,7 @@
/public/index.prerender.html
/public/Konfigurator
/public/profile
/public/404
/public/products.xml
/public/llms*
@@ -45,7 +46,6 @@ yarn-debug.log*
yarn-error.log*
# Editor directories and files
.vscode
.idea
*.suo
*.ntvs*

22
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,22 @@
{
// Launch configuration for starting the project in VSCode
// This will install dependencies before starting the dev server
"version": "0.2.0",
"configurations": [
{
"type": "node-terminal",
"name": "Start with API propxy to seedheads.de (Install Deps)",
"request": "launch",
"command": "npm run start:seedheads",
"preLaunchTask": "npm: install",
"cwd": "${workspaceFolder}"
}, {
"type": "node-terminal",
"name": "Start",
"request": "launch",
"command": "npm run start",
"cwd": "${workspaceFolder}"
}
]
}

14
LICENSE Normal file
View File

@@ -0,0 +1,14 @@
BSD Zero Clause License
Copyright (c) 2025 Growheads.de
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.

329
package-lock.json generated
View File

@@ -7,7 +7,7 @@
"": {
"name": "reactshop",
"version": "1.0.0",
"license": "ISC",
"license": "0BSD",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
@@ -106,9 +106,9 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.27.5",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.5.tgz",
"integrity": "sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
"integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -116,22 +116,22 @@
}
},
"node_modules/@babel/core": {
"version": "7.27.4",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz",
"integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.27.3",
"@babel/generator": "^7.28.0",
"@babel/helper-compilation-targets": "^7.27.2",
"@babel/helper-module-transforms": "^7.27.3",
"@babel/helpers": "^7.27.4",
"@babel/parser": "^7.27.4",
"@babel/helpers": "^7.27.6",
"@babel/parser": "^7.28.0",
"@babel/template": "^7.27.2",
"@babel/traverse": "^7.27.4",
"@babel/types": "^7.27.3",
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.28.0",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -147,9 +147,9 @@
}
},
"node_modules/@babel/eslint-parser": {
"version": "7.27.5",
"resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.27.5.tgz",
"integrity": "sha512-HLkYQfRICudzcOtjGwkPvGc5nF1b4ljLZh1IRDj50lRZ718NAKVgQpIAUX8bfg6u/yuSKY3L7E0YzIV+OxrB8Q==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.0.tgz",
"integrity": "sha512-N4ntErOlKvcbTt01rr5wj3y55xnIdx1ymrfIr8C2WnM1Y9glFgWaGDEULJIazOX3XM9NRzhfJ6zZnQ1sBNWU+w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -166,15 +166,15 @@
}
},
"node_modules/@babel/generator": {
"version": "7.27.5",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz",
"integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.27.5",
"@babel/types": "^7.27.3",
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25",
"@babel/parser": "^7.28.0",
"@babel/types": "^7.28.0",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
},
"engines": {
@@ -252,22 +252,31 @@
}
},
"node_modules/@babel/helper-define-polyfill-provider": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz",
"integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==",
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz",
"integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-compilation-targets": "^7.22.6",
"@babel/helper-plugin-utils": "^7.22.5",
"debug": "^4.1.1",
"@babel/helper-compilation-targets": "^7.27.2",
"@babel/helper-plugin-utils": "^7.27.1",
"debug": "^4.4.1",
"lodash.debounce": "^4.0.8",
"resolve": "^1.14.2"
"resolve": "^1.22.10"
},
"peerDependencies": {
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-member-expression-to-functions": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz",
@@ -444,12 +453,12 @@
}
},
"node_modules/@babel/parser": {
"version": "7.27.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz",
"integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.27.3"
"@babel/types": "^7.28.0"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -637,15 +646,15 @@
}
},
"node_modules/@babel/plugin-transform-async-generator-functions": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.27.1.tgz",
"integrity": "sha512-eST9RrwlpaoJBDHShc+DS2SG4ATTi2MYNb4OxYkf3n+7eb49LWpnS+HSpVfW4x927qQwgk8A2hGNVaajAEw0EA==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz",
"integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-remap-async-to-generator": "^7.27.1",
"@babel/traverse": "^7.27.1"
"@babel/traverse": "^7.28.0"
},
"engines": {
"node": ">=6.9.0"
@@ -689,9 +698,9 @@
}
},
"node_modules/@babel/plugin-transform-block-scoping": {
"version": "7.27.5",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.5.tgz",
"integrity": "sha512-JF6uE2s67f0y2RZcm2kpAUEbD50vH62TyWVebxwHAlbSdM49VqPz8t4a1uIjp4NIOIZ4xzLfjY5emt/RCyC7TQ==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.0.tgz",
"integrity": "sha512-gKKnwjpdx5sER/wl0WN0efUBFzF/56YZO0RJrSYP4CljXnP31ByY7fol89AzomdlLNzI36AvOTmYHsnZTCkq8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -739,18 +748,18 @@
}
},
"node_modules/@babel/plugin-transform-classes": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.27.1.tgz",
"integrity": "sha512-7iLhfFAubmpeJe/Wo2TVuDrykh/zlWXLzPNdL0Jqn/Xu8R3QQ8h9ff8FQoISZOsw74/HFqFI7NX63HN7QFIHKA==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.0.tgz",
"integrity": "sha512-IjM1IoJNw72AZFlj33Cu8X0q2XK/6AaVC3jQu+cgQ5lThWD5ajnuUAml80dqRmOhmPkTH8uAwnpMu9Rvj0LTRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.1",
"@babel/helper-compilation-targets": "^7.27.1",
"@babel/helper-annotate-as-pure": "^7.27.3",
"@babel/helper-compilation-targets": "^7.27.2",
"@babel/helper-globals": "^7.28.0",
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-replace-supers": "^7.27.1",
"@babel/traverse": "^7.27.1",
"globals": "^11.1.0"
"@babel/traverse": "^7.28.0"
},
"engines": {
"node": ">=6.9.0"
@@ -759,16 +768,6 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-classes/node_modules/globals": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/plugin-transform-computed-properties": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz",
@@ -787,13 +786,14 @@
}
},
"node_modules/@babel/plugin-transform-destructuring": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.27.3.tgz",
"integrity": "sha512-s4Jrok82JpiaIprtY2nHsYmrThKvvwgHwjgd7UMiYhZaN0asdXNLr0y+NjTfkA7SyQE5i2Fb7eawUOZmLvyqOA==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.0.tgz",
"integrity": "sha512-v1nrSMBiKcodhsyJ4Gf+Z0U/yawmJDBOTpEB3mcQY52r9RIyPneGyAS/yM6seP/8I+mWI3elOMtT5dB8GJVs+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/traverse": "^7.28.0"
},
"engines": {
"node": ">=6.9.0"
@@ -868,6 +868,23 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-explicit-resource-management": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz",
"integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.28.0"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-exponentiation-operator": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz",
@@ -1135,16 +1152,17 @@
}
},
"node_modules/@babel/plugin-transform-object-rest-spread": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.27.3.tgz",
"integrity": "sha512-7ZZtznF9g4l2JCImCo5LNKFHB5eXnN39lLtLY5Tg+VkR0jwOt7TBciMckuiQIOIW7L5tkQOCh3bVGYeXgMx52Q==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.0.tgz",
"integrity": "sha512-9VNGikXxzu5eCiQjdE4IZn8sb9q7Xsk5EXLDBKUYg1e/Tve8/05+KJEtcxGxAgCY5t/BpKQM+JEL/yT4tvgiUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-compilation-targets": "^7.27.2",
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.27.3",
"@babel/plugin-transform-parameters": "^7.27.1"
"@babel/plugin-transform-destructuring": "^7.28.0",
"@babel/plugin-transform-parameters": "^7.27.7",
"@babel/traverse": "^7.28.0"
},
"engines": {
"node": ">=6.9.0"
@@ -1204,9 +1222,9 @@
}
},
"node_modules/@babel/plugin-transform-parameters": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.1.tgz",
"integrity": "sha512-018KRk76HWKeZ5l4oTj2zPpSh+NbGdt0st5S6x0pga6HgrjBOJb24mMDHorFopOOd6YHkLgOZ+zaCjZGPO4aKg==",
"version": "7.27.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz",
"integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1340,9 +1358,9 @@
}
},
"node_modules/@babel/plugin-transform-regenerator": {
"version": "7.27.5",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.5.tgz",
"integrity": "sha512-uhB8yHerfe3MWnuLAhEbeQ4afVoqv8BQsPqrTv7e/jZ9y00kJL6l9a/f4OWaKxotmjzewfEyXE1vgDJenkQ2/Q==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.0.tgz",
"integrity": "sha512-LOAozRVbqxEVjSKfhGnuLoE4Kz4Oc5UJzuvFUhSsQzdCdaAQu06mG8zDv2GFSerM62nImUZ7K92vxnQcLSDlCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1537,13 +1555,13 @@
}
},
"node_modules/@babel/preset-env": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.27.2.tgz",
"integrity": "sha512-Ma4zSuYSlGNRlCLO+EAzLnCmJK2vdstgv+n7aUP+/IKZrOfWHOJVdSJtuub8RzHTj3ahD37k5OKJWvzf16TQyQ==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.0.tgz",
"integrity": "sha512-VmaxeGOwuDqzLl5JUkIRM1X2Qu2uKGxHEQWh+cvvbl7JuJRgKGJSfsEF/bUaxFhJl/XAyxBe7q7qSuTbKFuCyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.27.2",
"@babel/compat-data": "^7.28.0",
"@babel/helper-compilation-targets": "^7.27.2",
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-validator-option": "^7.27.1",
@@ -1557,19 +1575,20 @@
"@babel/plugin-syntax-import-attributes": "^7.27.1",
"@babel/plugin-syntax-unicode-sets-regex": "^7.18.6",
"@babel/plugin-transform-arrow-functions": "^7.27.1",
"@babel/plugin-transform-async-generator-functions": "^7.27.1",
"@babel/plugin-transform-async-generator-functions": "^7.28.0",
"@babel/plugin-transform-async-to-generator": "^7.27.1",
"@babel/plugin-transform-block-scoped-functions": "^7.27.1",
"@babel/plugin-transform-block-scoping": "^7.27.1",
"@babel/plugin-transform-block-scoping": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-class-static-block": "^7.27.1",
"@babel/plugin-transform-classes": "^7.27.1",
"@babel/plugin-transform-classes": "^7.28.0",
"@babel/plugin-transform-computed-properties": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.28.0",
"@babel/plugin-transform-dotall-regex": "^7.27.1",
"@babel/plugin-transform-duplicate-keys": "^7.27.1",
"@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1",
"@babel/plugin-transform-dynamic-import": "^7.27.1",
"@babel/plugin-transform-explicit-resource-management": "^7.28.0",
"@babel/plugin-transform-exponentiation-operator": "^7.27.1",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/plugin-transform-for-of": "^7.27.1",
@@ -1586,15 +1605,15 @@
"@babel/plugin-transform-new-target": "^7.27.1",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
"@babel/plugin-transform-numeric-separator": "^7.27.1",
"@babel/plugin-transform-object-rest-spread": "^7.27.2",
"@babel/plugin-transform-object-rest-spread": "^7.28.0",
"@babel/plugin-transform-object-super": "^7.27.1",
"@babel/plugin-transform-optional-catch-binding": "^7.27.1",
"@babel/plugin-transform-optional-chaining": "^7.27.1",
"@babel/plugin-transform-parameters": "^7.27.1",
"@babel/plugin-transform-parameters": "^7.27.7",
"@babel/plugin-transform-private-methods": "^7.27.1",
"@babel/plugin-transform-private-property-in-object": "^7.27.1",
"@babel/plugin-transform-property-literals": "^7.27.1",
"@babel/plugin-transform-regenerator": "^7.27.1",
"@babel/plugin-transform-regenerator": "^7.28.0",
"@babel/plugin-transform-regexp-modifiers": "^7.27.1",
"@babel/plugin-transform-reserved-words": "^7.27.1",
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
@@ -1607,10 +1626,10 @@
"@babel/plugin-transform-unicode-regex": "^7.27.1",
"@babel/plugin-transform-unicode-sets-regex": "^7.27.1",
"@babel/preset-modules": "0.1.6-no-external-plugins",
"babel-plugin-polyfill-corejs2": "^0.4.10",
"babel-plugin-polyfill-corejs3": "^0.11.0",
"babel-plugin-polyfill-regenerator": "^0.6.1",
"core-js-compat": "^3.40.0",
"babel-plugin-polyfill-corejs2": "^0.4.14",
"babel-plugin-polyfill-corejs3": "^0.13.0",
"babel-plugin-polyfill-regenerator": "^0.6.5",
"core-js-compat": "^3.43.0",
"semver": "^6.3.1"
},
"engines": {
@@ -1700,36 +1719,27 @@
}
},
"node_modules/@babel/traverse": {
"version": "7.27.4",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz",
"integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.27.3",
"@babel/parser": "^7.27.4",
"@babel/generator": "^7.28.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.28.0",
"@babel/template": "^7.27.2",
"@babel/types": "^7.27.3",
"debug": "^4.3.1",
"globals": "^11.1.0"
"@babel/types": "^7.28.0",
"debug": "^4.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse/node_modules/globals": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/types": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz",
"integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz",
"integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -2708,17 +2718,13 @@
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
"version": "0.3.12",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
"integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
"license": "MIT",
"dependencies": {
"@jridgewell/set-array": "^1.2.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
@@ -2730,15 +2736,6 @@
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/set-array": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/source-map": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
@@ -2757,9 +2754,9 @@
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
"version": "0.3.29",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
"integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -3118,9 +3115,9 @@
}
},
"node_modules/@pmmmwh/react-refresh-webpack-plugin": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.6.0.tgz",
"integrity": "sha512-AAc+QWfZ1KQ/e1C6OHWVlxU+ks6zFGOA44IJUlvju7RlDS8nsX6poPFOIlsg/rTofO9vKov12+WCjMhKkRKD5g==",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.6.1.tgz",
"integrity": "sha512-95DXXJxNkpYu+sqmpDp7vbw9JCyiNpHuCsvuMuOgVFrKQlwEIn9Y1+NNIQJq+zFL+eWyxw6htthB5CtdwJupNA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4188,14 +4185,14 @@
}
},
"node_modules/babel-plugin-polyfill-corejs2": {
"version": "0.4.13",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz",
"integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==",
"version": "0.4.14",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
"integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.22.6",
"@babel/helper-define-polyfill-provider": "^0.6.4",
"@babel/compat-data": "^7.27.7",
"@babel/helper-define-polyfill-provider": "^0.6.5",
"semver": "^6.3.1"
},
"peerDependencies": {
@@ -4203,27 +4200,27 @@
}
},
"node_modules/babel-plugin-polyfill-corejs3": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz",
"integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==",
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz",
"integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-define-polyfill-provider": "^0.6.3",
"core-js-compat": "^3.40.0"
"@babel/helper-define-polyfill-provider": "^0.6.5",
"core-js-compat": "^3.43.0"
},
"peerDependencies": {
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
}
},
"node_modules/babel-plugin-polyfill-regenerator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz",
"integrity": "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==",
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz",
"integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-define-polyfill-provider": "^0.6.4"
"@babel/helper-define-polyfill-provider": "^0.6.5"
},
"peerDependencies": {
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
@@ -4317,9 +4314,9 @@
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4341,9 +4338,9 @@
}
},
"node_modules/browserslist": {
"version": "4.24.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
"integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
"version": "4.25.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
"integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==",
"dev": true,
"funding": [
{
@@ -4361,10 +4358,10 @@
],
"license": "MIT",
"dependencies": {
"caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73",
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
"node-releases": "^2.0.19",
"update-browserslist-db": "^1.1.1"
"update-browserslist-db": "^1.1.3"
},
"bin": {
"browserslist": "cli.js"
@@ -4477,9 +4474,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001715",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001715.tgz",
"integrity": "sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==",
"version": "1.0.30001726",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz",
"integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==",
"dev": true,
"funding": [
{
@@ -4815,13 +4812,13 @@
"license": "MIT"
},
"node_modules/core-js-compat": {
"version": "3.41.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz",
"integrity": "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==",
"version": "3.43.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.43.0.tgz",
"integrity": "sha512-2GML2ZsCc5LR7hZYz4AXmjQw8zuy2T//2QntwdnpuYI7jteT6GVYJL7F6C2C57R7gSYrcqVW3lAALefdbhBLDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"browserslist": "^4.24.4"
"browserslist": "^4.25.0"
},
"funding": {
"type": "opencollective",
@@ -5141,9 +5138,9 @@
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -5478,9 +5475,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.139",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.139.tgz",
"integrity": "sha512-GGnRYOTdN5LYpwbIr0rwP/ZHOQSvAF6TG0LSzp28uCBb9JiXHJGmaaKw29qjNJc5bGnnp6kXJqRnGMQoELwi5w==",
"version": "1.5.179",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.179.tgz",
"integrity": "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==",
"dev": true,
"license": "ISC"
},

View File

@@ -4,8 +4,8 @@
"type": "module",
"main": "index.js",
"scripts": {
"start": "webpack serve --progress --mode development --no-open",
"start:seedheads": "cross-env PROXY_TARGET=https://seedheads.de webpack serve --progress --mode development --no-open",
"start": "cross-env NODE_OPTIONS=\"--no-deprecation\" webpack serve --progress --mode development --no-open",
"start:seedheads": "cross-env PROXY_TARGET=https://seedheads.de NODE_OPTIONS=\"--no-deprecation\" webpack serve --progress --mode development --no-open",
"prod": "webpack serve --progress --mode production --no-client-overlay --no-client --no-web-socket-server --no-open --no-live-reload --no-hot --compress --no-devtool",
"build:client": "cross-env NODE_ENV=production webpack --progress --mode production && shx cp dist/index.html dist/index_template.html",
"build": "npm run build:client",
@@ -17,7 +17,7 @@
},
"keywords": [],
"author": "",
"license": "ISC",
"license": "0BSD",
"description": "",
"dependencies": {
"@emotion/react": "^11.14.0",

View File

@@ -50,6 +50,7 @@ const {
generateProductsXml,
generateLlmsTxt,
generateCategoryLlmsTxt,
generateAllCategoryLlmsPages,
} = require("./prerender/seo.cjs");
const {
fetchCategoryProducts,
@@ -71,10 +72,12 @@ const Batteriegesetzhinweise =
require("./src/pages/Batteriegesetzhinweise.js").default;
const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default;
const Sitemap = require("./src/pages/Sitemap.js").default;
const PrerenderSitemap = require("./src/PrerenderSitemap.js").default;
const AGB = require("./src/pages/AGB.js").default;
const NotFound404 = require("./src/pages/NotFound404.js").default;
// Worker function for parallel product rendering
const renderProductWorker = async (productSeoNames, workerId, progressCallback) => {
const renderProductWorker = async (productSeoNames, workerId, progressCallback, categoryMap = {}) => {
const socketUrl = "http://127.0.0.1:9303";
const workerSocket = io(socketUrl, {
path: "/socket.io/",
@@ -113,10 +116,13 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback)
...productDetails.product,
seoName: actualSeoName,
}, shopConfig.baseUrl, shopConfig);
// Get category info from categoryMap if available
const categoryInfo = productDetails.product.categoryId ? categoryMap[productDetails.product.categoryId] : null;
const jsonLdScript = generateProductJsonLd({
...productDetails.product,
seoName: actualSeoName,
}, shopConfig.baseUrl, shopConfig);
}, shopConfig.baseUrl, shopConfig, categoryInfo);
const combinedMetaTags = metaTags + "\n" + jsonLdScript;
const success = renderPage(
@@ -182,7 +188,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback)
};
// Function to render products in parallel
const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProducts) => {
const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProducts, categoryMap = {}) => {
// Shared progress tracking
let completedProducts = 0;
let totalSuccessCount = 0;
@@ -244,7 +250,7 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
// Update progress bar with worker stats
updateProgressBar(completedProducts, totalProducts, lastProductName);
});
}, categoryMap);
workerPromises.push(promise);
}
@@ -312,7 +318,8 @@ const renderApp = async (categoryData, socket) => {
? "index.html"
: "index.prerender.html";
const homeMetaTags = generateHomepageMetaTags(shopConfig.baseUrl, shopConfig);
const homeJsonLd = generateHomepageJsonLd(shopConfig.baseUrl, shopConfig);
const homepageCategories = categoryData ? collectAllCategories(categoryData) : [];
const homeJsonLd = generateHomepageJsonLd(shopConfig.baseUrl, shopConfig, homepageCategories);
const combinedHomeMeta = homeMetaTags + "\n" + homeJsonLd;
const homeSuccess = render(
homeComponent,
@@ -356,12 +363,14 @@ const renderApp = async (categoryData, socket) => {
description: "Widerrufsrecht page",
},
{
component: Sitemap,
component: PrerenderSitemap,
path: "/sitemap",
filename: "sitemap",
description: "Sitemap page",
needsCategoryData: true,
},
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
{ component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" },
{
component: PrerenderKonfigurator,
path: "/Konfigurator",
@@ -378,13 +387,15 @@ const renderApp = async (categoryData, socket) => {
let staticPagesRendered = 0;
for (const page of staticPages) {
const pageComponent = React.createElement(page.component, null);
// Pass category data as props if needed
const pageProps = page.needsCategoryData ? { categoryData } : null;
const pageComponent = React.createElement(page.component, pageProps);
let metaTags = "";
// Special handling for Sitemap page to include category data
if (page.filename === "sitemap" && categoryData) {
const allCategories = collectAllCategories(categoryData);
metaTags = generateSitemapJsonLd(allCategories, shopConfig.baseUrl, shopConfig);
const sitemapCategories = collectAllCategories(categoryData);
metaTags = generateSitemapJsonLd(sitemapCategories, shopConfig.baseUrl, shopConfig);
}
// Special handling for Konfigurator page to include SEO tags
@@ -531,6 +542,15 @@ const renderApp = async (categoryData, socket) => {
const numCPUs = os.cpus().length;
const maxWorkers = Math.min(numCPUs, totalProducts, 8); // Cap at 8 workers to avoid overwhelming the server
// Create category map for breadcrumbs
const categoryMap = {};
allCategories.forEach(category => {
categoryMap[category.id] = {
name: category.name,
seoName: category.seoName
};
});
console.log(
`\n📦 Rendering ${totalProducts} individual product pages using ${maxWorkers} parallel workers...`
);
@@ -538,7 +558,8 @@ const renderApp = async (categoryData, socket) => {
const productPagesRendered = await renderProductsInParallel(
Array.from(allProducts),
maxWorkers,
totalProducts
totalProducts,
categoryMap
);
console.log(
@@ -641,27 +662,39 @@ const renderApp = async (categoryData, socket) => {
productsByCategory[categoryId].push(product);
});
// Generate category-specific LLM files
// Generate category-specific LLM files with pagination
let categoryFilesGenerated = 0;
let totalCategoryProducts = 0;
let totalPaginatedFiles = 0;
for (const category of allCategories) {
if (category.seoName) {
const categoryProducts = productsByCategory[category.id] || [];
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
const categoryLlmsTxt = generateCategoryLlmsTxt(category, categoryProducts, shopConfig.baseUrl, shopConfig);
const categoryLlmsTxtPath = path.resolve(__dirname, config.outputDir, `llms-${categorySlug}.txt`);
// Generate all paginated files for this category
const categoryPages = generateAllCategoryLlmsPages(category, categoryProducts, shopConfig.baseUrl, shopConfig);
fs.writeFileSync(categoryLlmsTxtPath, categoryLlmsTxt, { encoding: 'utf8' });
// Write each paginated file
for (const page of categoryPages) {
const pagePath = path.resolve(__dirname, config.outputDir, page.fileName);
fs.writeFileSync(pagePath, page.content, { encoding: 'utf8' });
totalPaginatedFiles++;
}
console.log(` ✅ llms-${categorySlug}.txt - ${categoryProducts.length} products (${Math.round(categoryLlmsTxt.length / 1024)}KB)`);
const pageCount = categoryPages.length;
const totalSize = categoryPages.reduce((sum, page) => sum + page.content.length, 0);
console.log(` ✅ llms-${categorySlug}-page-*.txt - ${categoryProducts.length} products across ${pageCount} pages (${Math.round(totalSize / 1024)}KB total)`);
categoryFilesGenerated++;
totalCategoryProducts += categoryProducts.length;
}
}
console.log(` 📄 Total paginated files generated: ${totalPaginatedFiles}`);
console.log(` 📦 Total products across all categories: ${totalCategoryProducts}`);
try {
const verification = fs.readFileSync(llmsTxtPath, 'utf8');
console.log(` - File verification: ✅ All files valid UTF-8`);

View File

@@ -39,7 +39,7 @@ const fetchCategoryProducts = (socket, categoryId) => {
socket.emit(
"getCategoryProducts",
{ categoryId: parseInt(categoryId) },
{ full:true, categoryId: parseInt(categoryId) },
(response) => {
clearTimeout(timeout);
if (response && response.products !== undefined) {

View File

@@ -1,881 +1,4 @@
const generateProductMetaTags = (product, baseUrl, config) => {
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
const imageUrl =
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.jpg`
: `${baseUrl}/assets/images/nopicture.jpg`;
// Re-export all SEO functions from the new modular structure
// This maintains backward compatibility while using the new split files
// Clean description for meta (remove HTML tags and limit length)
const cleanDescription = product.description
? product.description
.replace(/<[^>]*>/g, "")
.replace(/\n/g, " ")
.substring(0, 160)
: `${product.name} - Art.-Nr.: ${product.articleNumber}`;
return `
<!-- SEO Meta Tags -->
<meta name="description" content="${cleanDescription}">
<meta name="keywords" content="${product.name}, ${
product.manufacturer || ""
}, ${product.articleNumber}">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="${product.name}">
<meta property="og:description" content="${cleanDescription}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:url" content="${productUrl}">
<meta property="og:type" content="product">
<meta property="og:site_name" content="${config.siteName}">
<meta property="product:price:amount" content="${product.price}">
<meta property="product:price:currency" content="${config.currency}">
<meta property="product:availability" content="${
product.available ? "in stock" : "out of stock"
}">
${product.gtin ? `<meta property="product:gtin" content="${product.gtin}">` : ''}
${product.articleNumber ? `<meta property="product:retailer_item_id" content="${product.articleNumber}">` : ''}
${product.manufacturer ? `<meta property="product:brand" content="${product.manufacturer}">` : ''}
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${product.name}">
<meta name="twitter:description" content="${cleanDescription}">
<meta name="twitter:image" content="${imageUrl}">
<!-- Additional Meta Tags -->
<meta name="robots" content="index, follow">
<link rel="canonical" href="${productUrl}">
`;
};
const generateProductJsonLd = (product, baseUrl, config) => {
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
const imageUrl =
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.jpg`
: `${baseUrl}/assets/images/nopicture.jpg`;
// Clean description for JSON-LD (remove HTML tags)
const cleanDescription = product.description
? product.description.replace(/<[^>]*>/g, "").replace(/\n/g, " ")
: product.name;
// Calculate price valid date (current date + 3 months)
const priceValidDate = new Date();
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
const jsonLd = {
"@context": "https://schema.org/",
"@type": "Product",
name: product.name,
image: [imageUrl],
description: cleanDescription,
sku: product.articleNumber,
...(product.gtin && { gtin: product.gtin }),
brand: {
"@type": "Brand",
name: product.manufacturer || "Unknown",
},
offers: {
"@type": "Offer",
url: productUrl,
priceCurrency: config.currency,
price: product.price.toString(),
priceValidUntil: priceValidDate.toISOString().split("T")[0],
itemCondition: "https://schema.org/NewCondition",
availability: product.available
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
seller: {
"@type": "Organization",
name: config.brandName,
},
},
};
return `<script type="application/ld+json">${JSON.stringify(
jsonLd
)}</script>`;
};
const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
const categoryUrl = `${baseUrl}/Kategorie/${category.seoName}`;
const jsonLd = {
"@context": "https://schema.org/",
"@type": "CollectionPage",
name: category.name,
url: categoryUrl,
description: `${category.name} - Entdecken Sie unsere Auswahl an hochwertigen Produkten`,
breadcrumb: {
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: baseUrl,
},
{
"@type": "ListItem",
position: 2,
name: category.name,
item: categoryUrl,
},
],
},
};
// Add product list if products are available
if (products && products.length > 0) {
jsonLd.mainEntity = {
"@type": "ItemList",
numberOfItems: products.length,
itemListElement: products.slice(0, 20).map((product, index) => ({
"@type": "ListItem",
position: index + 1,
item: {
"@type": "Product",
name: product.name,
url: `${baseUrl}/Artikel/${product.seoName}`,
image:
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.jpg`
: `${baseUrl}/assets/images/nopicture.jpg`,
offers: {
"@type": "Offer",
price: product.price.toString(),
priceCurrency: config.currency,
availability: product.available
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
},
},
})),
};
}
return `<script type="application/ld+json">${JSON.stringify(
jsonLd
)}</script>`;
};
const generateHomepageMetaTags = (baseUrl, config) => {
const description = config.descriptions.long;
const keywords = config.keywords;
const imageUrl = `${baseUrl}${config.images.logo}`;
return `
<!-- SEO Meta Tags -->
<meta name="description" content="${description}">
<meta name="keywords" content="${keywords}">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="${config.descriptions.short}">
<meta property="og:description" content="${description}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:url" content="${baseUrl}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="${config.siteName}">
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${config.descriptions.short}">
<meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${imageUrl}">
<!-- Additional Meta Tags -->
<meta name="robots" content="index, follow">
<link rel="canonical" href="${baseUrl}">
`;
};
const generateHomepageJsonLd = (baseUrl, config) => {
const jsonLd = {
"@context": "https://schema.org/",
"@type": "WebSite",
name: config.brandName,
url: baseUrl,
description: config.descriptions.long,
publisher: {
"@type": "Organization",
name: config.brandName,
url: baseUrl,
logo: {
"@type": "ImageObject",
url: `${baseUrl}${config.images.logo}`,
},
},
potentialAction: {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: `${baseUrl}/search?q={search_term_string}`,
},
"query-input": "required name=search_term_string",
},
mainEntity: {
"@type": "WebPage",
name: "Sitemap",
url: `${baseUrl}/sitemap`,
description: "Vollständige Sitemap mit allen Kategorien und Seiten",
},
sameAs: [
// Add your social media URLs here if available
],
};
return `<script type="application/ld+json">${JSON.stringify(
jsonLd
)}</script>`;
};
const generateSitemapJsonLd = (allCategories = [], baseUrl, config) => {
const jsonLd = {
"@context": "https://schema.org/",
"@type": "WebPage",
name: "Sitemap",
url: `${baseUrl}/sitemap`,
description: `Sitemap - Übersicht aller Kategorien und Seiten auf ${config.siteName}`,
breadcrumb: {
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: baseUrl,
},
{
"@type": "ListItem",
position: 2,
name: "Sitemap",
item: `${baseUrl}/sitemap`,
},
],
},
};
// Add all categories as site navigation elements
if (allCategories && allCategories.length > 0) {
jsonLd.mainEntity = {
"@type": "SiteNavigationElement",
name: "Kategorien",
hasPart: allCategories.map((category) => ({
"@type": "SiteNavigationElement",
name: category.name,
url: `${baseUrl}/Kategorie/${category.seoName}`,
description: `${category.name} Kategorie`,
})),
};
}
return `<script type="application/ld+json">${JSON.stringify(
jsonLd
)}</script>`;
};
const generateXmlSitemap = (allCategories = [], allProducts = [], baseUrl) => {
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
let sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
`;
// Homepage
sitemap += ` <url>
<loc>${baseUrl}/</loc>
<lastmod>${currentDate}</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
`;
// Static pages
const staticPages = [
{ path: "/datenschutz", changefreq: "monthly", priority: "0.3" },
{ path: "/impressum", changefreq: "monthly", priority: "0.3" },
{ path: "/batteriegesetzhinweise", changefreq: "monthly", priority: "0.3" },
{ path: "/widerrufsrecht", changefreq: "monthly", priority: "0.3" },
{ path: "/sitemap", changefreq: "weekly", priority: "0.5" },
{ path: "/agb", changefreq: "monthly", priority: "0.3" },
{ path: "/Konfigurator", changefreq: "weekly", priority: "0.8" },
];
staticPages.forEach((page) => {
sitemap += ` <url>
<loc>${baseUrl}${page.path}</loc>
<lastmod>${currentDate}</lastmod>
<changefreq>${page.changefreq}</changefreq>
<priority>${page.priority}</priority>
</url>
`;
});
// Category pages
allCategories.forEach((category) => {
if (category.seoName) {
sitemap += ` <url>
<loc>${baseUrl}/Kategorie/${category.seoName}</loc>
<lastmod>${currentDate}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
`;
}
});
// Product pages
allProducts.forEach((productSeoName) => {
sitemap += ` <url>
<loc>${baseUrl}/Artikel/${productSeoName}</loc>
<lastmod>${currentDate}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
</url>
`;
});
sitemap += `</urlset>`;
return sitemap;
};
const generateKonfiguratorMetaTags = (baseUrl, config) => {
const description = "Unser interaktiver Growbox Konfigurator hilft dir dabei, das perfekte Indoor Growing Setup zusammenzustellen. Wähle aus verschiedenen Growbox-Größen, Beleuchtung, Belüftung und Extras. Bundle-Rabatte bis 36%!";
const keywords = "Growbox Konfigurator, Indoor Growing, Growzelt, Beleuchtung, Belüftung, Growbox Setup, Indoor Garden";
const imageUrl = `${baseUrl}${config.images.placeholder}`; // Placeholder image
return `
<!-- SEO Meta Tags -->
<meta name="description" content="${description}">
<meta name="keywords" content="${keywords}">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="Growbox Konfigurator - Stelle dein perfektes Indoor Grow Setup zusammen">
<meta property="og:description" content="${description}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:url" content="${baseUrl}/Konfigurator">
<meta property="og:type" content="website">
<meta property="og:site_name" content="${config.siteName}">
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Growbox Konfigurator - Indoor Grow Setup">
<meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${imageUrl}">
<!-- Additional Meta Tags -->
<meta name="robots" content="index, follow">
<link rel="canonical" href="${baseUrl}/Konfigurator">
`;
};
const generateRobotsTxt = (baseUrl) => {
const robotsTxt = `User-agent: *
Allow: /
Sitemap: ${baseUrl}/sitemap.xml
Crawl-delay: 0
`;
return robotsTxt;
};
const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const currentDate = new Date().toISOString();
// Validate input
if (!Array.isArray(allProductsData) || allProductsData.length === 0) {
throw new Error("No valid product data provided");
}
// Category mapping function
const getGoogleProductCategory = (categoryId) => {
const categoryMappings = {
// Seeds & Plants
689: "Home & Garden > Plants > Seeds",
706: "Home & Garden > Plants", // Stecklinge (cuttings)
376: "Home & Garden > Plants > Plant & Herb Growing Kits", // Grow-Sets
// Headshop & Accessories
709: "Arts & Entertainment > Hobbies & Creative Arts", // Headshop
711: "Arts & Entertainment > Hobbies & Creative Arts", // Bongs
714: "Arts & Entertainment > Hobbies & Creative Arts", // Zubehör
748: "Arts & Entertainment > Hobbies & Creative Arts", // Köpfe
749: "Arts & Entertainment > Hobbies & Creative Arts", // Chillums / Diffusoren / Kupplungen
896: "Electronics > Electronics Accessories", // Vaporizer
710: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Grinder
// Measuring & Packaging
186: "Business & Industrial", // Wiegen & Verpacken
187: "Business & Industrial > Science & Laboratory > Lab Equipment", // Waagen
346: "Home & Garden > Kitchen & Dining > Food Storage", // Vakuumbeutel
355: "Home & Garden > Kitchen & Dining > Food Storage", // Boveda & Integra Boost
407: "Home & Garden > Kitchen & Dining > Food Storage", // Grove Bags
449: "Home & Garden > Kitchen & Dining > Food Storage", // Cliptütchen
539: "Home & Garden > Kitchen & Dining > Food Storage", // Gläser & Dosen
// Lighting & Equipment
694: "Home & Garden > Lighting", // Lampen
261: "Home & Garden > Lighting", // Lampenzubehör
// Plants & Growing
691: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger
692: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger - Zubehör
693: "Sporting Goods > Outdoor Recreation > Camping & Hiking > Tents", // Zelte
// Pots & Containers
219: "Home & Garden > Decor > Planters & Pots", // Töpfe
220: "Home & Garden > Decor > Planters & Pots", // Untersetzer
301: "Home & Garden > Decor > Planters & Pots", // Stofftöpfe
317: "Home & Garden > Decor > Planters & Pots", // Air-Pot
364: "Home & Garden > Decor > Planters & Pots", // Kunststofftöpfe
292: "Home & Garden > Decor > Planters & Pots", // Trays & Fluttische
// Ventilation & Climate
703: "Home & Garden > Outdoor Power Tools", // Abluft-Sets
247: "Home & Garden > Outdoor Power Tools", // Belüftung
214: "Home & Garden > Outdoor Power Tools", // Umluft-Ventilatoren
308: "Home & Garden > Outdoor Power Tools", // Ab- und Zuluft
609: "Home & Garden > Outdoor Power Tools", // Schalldämpfer
248: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Aktivkohlefilter
392: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Zuluftfilter
658: "Home & Garden > Climate Control > Dehumidifiers", // Luftbe- und entfeuchter
310: "Home & Garden > Climate Control > Heating", // Heizmatten
379: "Home & Garden > Household Supplies > Air Fresheners", // Geruchsneutralisation
// Irrigation & Watering
221: "Home & Garden > Lawn & Garden > Watering Equipment", // Bewässerung
250: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
297: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpen
354: "Home & Garden > Lawn & Garden > Watering Equipment", // Sprüher
372: "Home & Garden > Lawn & Garden > Watering Equipment", // AutoPot
389: "Home & Garden > Lawn & Garden > Watering Equipment", // Blumat
405: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
425: "Home & Garden > Lawn & Garden > Watering Equipment", // Wassertanks
480: "Home & Garden > Lawn & Garden > Watering Equipment", // Tropfer
519: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpsprüher
// Growing Media & Soils
242: "Home & Garden > Lawn & Garden > Fertilizers", // Böden
243: "Home & Garden > Lawn & Garden > Fertilizers", // Erde
269: "Home & Garden > Lawn & Garden > Fertilizers", // Kokos
580: "Home & Garden > Lawn & Garden > Fertilizers", // Perlite & Blähton
// Propagation & Starting
286: "Home & Garden > Plants", // Anzucht
298: "Home & Garden > Plants", // Steinwolltrays
421: "Home & Garden > Plants", // Vermehrungszubehör
489: "Home & Garden > Plants", // EazyPlug & Jiffy
359: "Home & Garden > Outdoor Structures > Greenhouses", // Gewächshäuser
// Tools & Equipment
373: "Home & Garden > Tools > Hand Tools", // GrowTool
403: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Messbecher & mehr
259: "Home & Garden > Tools > Hand Tools", // Pressen
280: "Home & Garden > Tools > Hand Tools", // Erntescheeren
258: "Home & Garden > Tools", // Ernte & Verarbeitung
278: "Home & Garden > Tools", // Extraktion
302: "Home & Garden > Tools", // Erntemaschinen
// Hardware & Plumbing
222: "Hardware > Plumbing", // PE-Teile
374: "Hardware > Plumbing > Plumbing Fittings", // Verbindungsteile
// Electronics & Control
314: "Electronics > Electronics Accessories", // Steuergeräte
408: "Electronics > Electronics Accessories", // GrowControl
344: "Business & Industrial > Science & Laboratory > Lab Equipment", // Messgeräte
555: "Business & Industrial > Science & Laboratory > Lab Equipment > Microscopes", // Mikroskope
// Camping & Outdoor
226: "Sporting Goods > Outdoor Recreation > Camping & Hiking", // Zeltzubehör
// Plant Care & Protection
239: "Home & Garden > Lawn & Garden > Pest Control", // Pflanzenschutz
240: "Home & Garden > Plants", // Anbauzubehör
// Office & Media
424: "Office Supplies > Labels", // Etiketten & Schilder
387: "Media > Books", // Literatur
// General categories
705: "Home & Garden", // Set-Konfigurator
686: "Home & Garden", // Zubehör
741: "Home & Garden", // Zubehör
294: "Home & Garden", // Zubehör
695: "Home & Garden", // Zubehör
293: "Home & Garden", // Trockennetze
4: "Home & Garden", // Sonstiges
450: "Home & Garden", // Restposten
};
return categoryMappings[categoryId] || "Home & Garden > Plants";
};
let productsXml = `<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:g="http://base.google.com/ns/1.0" version="2.0">
<channel>
<title>${config.descriptions.short}</title>
<link>${baseUrl}</link>
<description>${config.descriptions.short}</description>
<lastBuildDate>${currentDate}</lastBuildDate>
<language>${config.language}</language>`;
// Helper function to clean text content of problematic characters
const cleanTextContent = (text) => {
if (!text) return "";
return text.toString()
// Remove HTML tags
.replace(/<[^>]*>/g, "")
// Remove non-printable characters and control characters
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, '')
// Remove BOM and other Unicode formatting characters
.replace(/[\uFEFF\u200B-\u200D\u2060]/g, '')
// Replace multiple whitespace with single space
.replace(/\s+/g, ' ')
// Remove leading/trailing whitespace
.trim();
};
// Helper function to properly escape XML content and remove invalid characters
const escapeXml = (unsafe) => {
if (!unsafe) return "";
// Convert to string and remove invalid XML characters
const cleaned = unsafe.toString()
// Remove control characters except tab (0x09), newline (0x0A), and carriage return (0x0D)
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
// Remove invalid Unicode characters and surrogates
.replace(/[\uD800-\uDFFF]/g, '')
// Remove other problematic characters
.replace(/[\uFFFE\uFFFF]/g, '')
// Normalize whitespace
.replace(/\s+/g, ' ')
.trim();
// Escape XML entities
return cleaned
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
};
let processedCount = 0;
let skippedCount = 0;
// Category IDs to skip (seeds, plants, headshop items)
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710];
// Add each product as an item
allProductsData.forEach((product, index) => {
try {
// Skip products without essential data
if (!product || !product.seoName) {
skippedCount++;
return;
}
// Skip products from excluded categories
const productCategoryId = product.categoryId || product.category_id || product.category || null;
if (productCategoryId && skipCategoryIds.includes(parseInt(productCategoryId))) {
skippedCount++;
return;
}
// Skip products without GTIN
if (!product.gtin || !product.gtin.toString().trim()) {
skippedCount++;
return;
}
// Skip products without pictures
if (!product.pictureList || !product.pictureList.trim()) {
skippedCount++;
return;
}
// Clean description for feed (remove HTML tags and limit length)
const rawDescription = product.description
? cleanTextContent(product.description).substring(0, 500)
: `${product.name || 'Product'} - Art.-Nr.: ${product.articleNumber || 'N/A'}`;
const cleanDescription = escapeXml(rawDescription) || "Produktbeschreibung nicht verfügbar";
// Clean product name
const rawName = product.name || "Unnamed Product";
const cleanName = escapeXml(cleanTextContent(rawName)) || "Unnamed Product";
// Validate essential fields
if (!cleanName || cleanName.length < 2) {
skippedCount++;
return;
}
// Generate product URL
const productUrl = `${baseUrl}/Artikel/${encodeURIComponent(product.seoName)}`;
// Generate image URL
const imageUrl = product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.jpg`
: `${baseUrl}/assets/images/nopicture.jpg`;
// Generate brand (manufacturer)
const rawBrand = product.manufacturer || config.brandName;
const brand = escapeXml(cleanTextContent(rawBrand));
// Generate condition (always new for this type of shop)
const condition = "new";
// Generate availability
const availability = product.available ? "in stock" : "out of stock";
// Generate price (ensure it's a valid number)
const price = product.price && !isNaN(product.price)
? `${parseFloat(product.price).toFixed(2)} ${config.currency}`
: `0.00 ${config.currency}`;
// Generate GTIN/EAN if available (using articleNumber as fallback)
const rawGtin = product.gtin || "";
const gtin = escapeXml(rawGtin.toString().trim());
// Generate product ID (using articleNumber or seoName)
const rawProductId = product.articleNumber || product.seoName || `product_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
const productId = escapeXml(rawProductId.toString().trim()) || `fallback_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
// Get Google product category based on product's category ID
const categoryId = product.categoryId || product.category_id || product.category || null;
const googleCategory = getGoogleProductCategory(categoryId);
const escapedGoogleCategory = escapeXml(googleCategory);
// Build item XML with proper formatting
productsXml += `
<item>
<g:id>${productId}</g:id>
<g:title>${cleanName}</g:title>
<g:description>${cleanDescription}</g:description>
<g:link>${productUrl}</g:link>
<g:image_link>${imageUrl}</g:image_link>
<g:condition>${condition}</g:condition>
<g:availability>${availability}</g:availability>
<g:price>${price}</g:price>
<g:shipping>
<g:country>${config.country}</g:country>
<g:service>${config.shipping.defaultService}</g:service>
<g:price>${config.shipping.defaultCost}</g:price>
</g:shipping>
<g:brand>${brand}</g:brand>
<g:google_product_category>${escapedGoogleCategory}</g:google_product_category>
<g:product_type>Gartenbedarf</g:product_type>`;
// Add GTIN if available
if (gtin && gtin.trim()) {
productsXml += `
<g:gtin>${gtin}</g:gtin>`;
}
// Add weight if available
if (product.weight && !isNaN(product.weight)) {
productsXml += `
<g:shipping_weight>${parseFloat(product.weight).toFixed(2)} g</g:shipping_weight>`;
}
productsXml += `
</item>`;
processedCount++;
} catch (itemError) {
console.log(` ⚠️ Skipped product ${index + 1}: ${itemError.message}`);
skippedCount++;
}
});
productsXml += `
</channel>
</rss>`;
console.log(` 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
return productsXml;
};
const generateLlmsTxt = (allCategories = [], allProductsData = [], baseUrl, config) => {
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
// Group products by category for statistics
const productsByCategory = {};
allProductsData.forEach((product) => {
const categoryId = product.categoryId || 'uncategorized';
if (!productsByCategory[categoryId]) {
productsByCategory[categoryId] = [];
}
productsByCategory[categoryId].push(product);
});
// Find category names for organization
const categoryMap = {};
allCategories.forEach((cat) => {
categoryMap[cat.id] = cat.name;
});
let llmsTxt = `# ${config.siteName} - Site Map for LLMs
Generated: ${currentDate}
Base URL: ${baseUrl}
## About ${config.brandName}
SeedHeads is a German online shop specializing in high-quality seeds, plants, and gardening supplies. We offer a comprehensive range of products for indoor and outdoor growing, including seeds, cuttings, grow equipment, lighting, ventilation, fertilizers, and accessories.
## Site Structure
### Static Pages
- **Home** - ${baseUrl}/
- **Datenschutz (Privacy Policy)** - ${baseUrl}/datenschutz
- **Impressum (Legal Notice)** - ${baseUrl}/impressum
- **AGB (Terms & Conditions)** - ${baseUrl}/agb
- **Widerrufsrecht (Right of Withdrawal)** - ${baseUrl}/widerrufsrecht
- **Batteriegesetzhinweise (Battery Law Notice)** - ${baseUrl}/batteriegesetzhinweise
- **Sitemap** - ${baseUrl}/sitemap
- **Growbox Konfigurator** - ${baseUrl}/Konfigurator - Interactive tool to configure grow box setups with bundle discounts
- **Profile** - ${baseUrl}/profile - User account and order management
### Site Features
- **Language**: German (${config.language})
- **Currency**: ${config.currency} (Euro)
- **Shipping**: ${config.country}
- **Payment Methods**: Credit Cards, PayPal, Bank Transfer, Cash on Delivery, Cash on Pickup
### Product Categories (${allCategories.length} categories)
`;
// Add categories with links to their detailed LLM files
allCategories.forEach((category) => {
if (category.seoName) {
const productCount = productsByCategory[category.id]?.length || 0;
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
llmsTxt += `#### ${category.name} (${productCount} products)
- **Product Catalog**: ${baseUrl}/llms-${categorySlug}.txt
`;
}
});
llmsTxt += `
---
*This sitemap is automatically generated during the site build process and includes all publicly accessible content. For technical inquiries, please refer to our contact information in the Impressum.*
`;
return llmsTxt;
};
const generateCategoryLlmsTxt = (category, categoryProducts = [], baseUrl, config) => {
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
let categoryLlmsTxt = `# ${category.name} - Product Catalog
Generated: ${currentDate}
Base URL: ${baseUrl}
Category: ${category.name} (ID: ${category.id})
Category URL: ${baseUrl}/Kategorie/${category.seoName}
## Category Overview
This file contains all products in the "${category.name}" category from ${config.siteName}.
**Statistics:**
- **Total Products**: ${categoryProducts.length}
- **Category ID**: ${category.id}
- **Category URL**: ${baseUrl}/Kategorie/${category.seoName}
- **Back to Main Sitemap**: ${baseUrl}/llms.txt
`;
if (categoryProducts.length > 0) {
categoryProducts.forEach((product, index) => {
if (product.seoName) {
// Clean description for markdown (remove HTML tags and limit length)
const cleanDescription = product.description
? product.description
.replace(/<[^>]*>/g, "")
.replace(/\n/g, " ")
.trim()
.substring(0, 300)
: "";
categoryLlmsTxt += `## ${index + 1}. ${product.name}
- **Product URL**: ${baseUrl}/Artikel/${product.seoName}
- **Article Number**: ${product.articleNumber || 'N/A'}
- **Price**: €${product.price || '0.00'}
- **Brand**: ${product.manufacturer || config.brandName}
- **Availability**: ${product.available ? 'In Stock' : 'Out of Stock'}`;
if (product.gtin) {
categoryLlmsTxt += `
- **GTIN**: ${product.gtin}`;
}
if (product.weight && !isNaN(product.weight)) {
categoryLlmsTxt += `
- **Weight**: ${product.weight}g`;
}
if (cleanDescription) {
categoryLlmsTxt += `
**Description:**
${cleanDescription}${product.description && product.description.length > 300 ? '...' : ''}`;
}
categoryLlmsTxt += `
---
`;
}
});
} else {
categoryLlmsTxt += `## No Products Available
This category currently contains no products.
`;
}
categoryLlmsTxt += `---
*This category product list is automatically generated during the site build process. Product availability and pricing are updated in real-time on the main website.*
`;
return categoryLlmsTxt;
};
module.exports = {
generateProductMetaTags,
generateProductJsonLd,
generateCategoryJsonLd,
generateHomepageMetaTags,
generateHomepageJsonLd,
generateSitemapJsonLd,
generateKonfiguratorMetaTags,
generateXmlSitemap,
generateRobotsTxt,
generateProductsXml,
generateLlmsTxt,
generateCategoryLlmsTxt,
};
module.exports = require('./seo/index.cjs');

View File

@@ -0,0 +1,81 @@
const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
const categoryUrl = `${baseUrl}/Kategorie/${category.seoName}`;
const jsonLd = {
"@context": "https://schema.org/",
"@type": "CollectionPage",
name: category.name,
url: categoryUrl,
description: `${category.name} - Entdecken Sie unsere Auswahl an hochwertigen Produkten`,
breadcrumb: {
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: baseUrl,
},
{
"@type": "ListItem",
position: 2,
name: category.name,
item: categoryUrl,
},
],
},
};
// Add product list if products are available
if (products && products.length > 0) {
jsonLd.mainEntity = {
"@type": "ItemList",
numberOfItems: products.length,
itemListElement: products.slice(0, 20).map((product, index) => ({
"@type": "ListItem",
position: index + 1,
item: {
"@type": "Product",
name: product.name,
url: `${baseUrl}/Artikel/${product.seoName}`,
image:
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.jpg`
: `${baseUrl}/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: `${baseUrl}/Artikel/${product.seoName}`,
price: product.price && !isNaN(product.price) ? product.price.toString() : "0.00",
priceCurrency: config.currency,
availability: product.available
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
seller: {
"@type": "Organization",
name: config.brandName,
},
itemCondition: "https://schema.org/NewCondition",
},
},
})),
};
}
return `<script type="application/ld+json">${JSON.stringify(
jsonLd
)}</script>`;
};
module.exports = {
generateCategoryJsonLd,
};

344
prerender/seo/feeds.cjs Normal file
View File

@@ -0,0 +1,344 @@
const generateRobotsTxt = (baseUrl) => {
// Ensure URLs are properly formatted
const canonicalUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const robotsTxt = `User-agent: *
Allow: /
Sitemap: ${canonicalUrl}/sitemap.xml
Crawl-delay: 0
`;
return robotsTxt;
};
const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const currentDate = new Date().toISOString();
// Validate input
if (!Array.isArray(allProductsData) || allProductsData.length === 0) {
throw new Error("No valid product data provided");
}
// Category mapping function
const getGoogleProductCategory = (categoryId) => {
const categoryMappings = {
// Seeds & Plants
689: "Home & Garden > Plants > Seeds",
706: "Home & Garden > Plants", // Stecklinge (cuttings)
376: "Home & Garden > Plants > Plant & Herb Growing Kits", // Grow-Sets
// Headshop & Accessories
709: "Arts & Entertainment > Hobbies & Creative Arts", // Headshop
711: "Arts & Entertainment > Hobbies & Creative Arts", // Bongs
714: "Arts & Entertainment > Hobbies & Creative Arts", // Zubehör
748: "Arts & Entertainment > Hobbies & Creative Arts", // Köpfe
749: "Arts & Entertainment > Hobbies & Creative Arts", // Chillums / Diffusoren / Kupplungen
896: "Electronics > Electronics Accessories", // Vaporizer
710: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Grinder
// Measuring & Packaging
186: "Business & Industrial", // Wiegen & Verpacken
187: "Business & Industrial > Science & Laboratory > Lab Equipment", // Waagen
346: "Home & Garden > Kitchen & Dining > Food Storage", // Vakuumbeutel
355: "Home & Garden > Kitchen & Dining > Food Storage", // Boveda & Integra Boost
407: "Home & Garden > Kitchen & Dining > Food Storage", // Grove Bags
449: "Home & Garden > Kitchen & Dining > Food Storage", // Cliptütchen
539: "Home & Garden > Kitchen & Dining > Food Storage", // Gläser & Dosen
// Lighting & Equipment
694: "Home & Garden > Lighting", // Lampen
261: "Home & Garden > Lighting", // Lampenzubehör
// Plants & Growing
691: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger
692: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger - Zubehör
693: "Sporting Goods > Outdoor Recreation > Camping & Hiking > Tents", // Zelte
// Pots & Containers
219: "Home & Garden > Decor > Planters & Pots", // Töpfe
220: "Home & Garden > Decor > Planters & Pots", // Untersetzer
301: "Home & Garden > Decor > Planters & Pots", // Stofftöpfe
317: "Home & Garden > Decor > Planters & Pots", // Air-Pot
364: "Home & Garden > Decor > Planters & Pots", // Kunststofftöpfe
292: "Home & Garden > Decor > Planters & Pots", // Trays & Fluttische
// Ventilation & Climate
703: "Home & Garden > Outdoor Power Tools", // Abluft-Sets
247: "Home & Garden > Outdoor Power Tools", // Belüftung
214: "Home & Garden > Outdoor Power Tools", // Umluft-Ventilatoren
308: "Home & Garden > Outdoor Power Tools", // Ab- und Zuluft
609: "Home & Garden > Outdoor Power Tools", // Schalldämpfer
248: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Aktivkohlefilter
392: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Zuluftfilter
658: "Home & Garden > Climate Control > Dehumidifiers", // Luftbe- und entfeuchter
310: "Home & Garden > Climate Control > Heating", // Heizmatten
379: "Home & Garden > Household Supplies > Air Fresheners", // Geruchsneutralisation
// Irrigation & Watering
221: "Home & Garden > Lawn & Garden > Watering Equipment", // Bewässerung
250: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
297: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpen
354: "Home & Garden > Lawn & Garden > Watering Equipment", // Sprüher
372: "Home & Garden > Lawn & Garden > Watering Equipment", // AutoPot
389: "Home & Garden > Lawn & Garden > Watering Equipment", // Blumat
405: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
425: "Home & Garden > Lawn & Garden > Watering Equipment", // Wassertanks
480: "Home & Garden > Lawn & Garden > Watering Equipment", // Tropfer
519: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpsprüher
// Growing Media & Soils
242: "Home & Garden > Lawn & Garden > Fertilizers", // Böden
243: "Home & Garden > Lawn & Garden > Fertilizers", // Erde
269: "Home & Garden > Lawn & Garden > Fertilizers", // Kokos
580: "Home & Garden > Lawn & Garden > Fertilizers", // Perlite & Blähton
// Propagation & Starting
286: "Home & Garden > Plants", // Anzucht
298: "Home & Garden > Plants", // Steinwolltrays
421: "Home & Garden > Plants", // Vermehrungszubehör
489: "Home & Garden > Plants", // EazyPlug & Jiffy
359: "Home & Garden > Outdoor Structures > Greenhouses", // Gewächshäuser
// Tools & Equipment
373: "Home & Garden > Tools > Hand Tools", // GrowTool
403: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Messbecher & mehr
259: "Home & Garden > Tools > Hand Tools", // Pressen
280: "Home & Garden > Tools > Hand Tools", // Erntescheeren
258: "Home & Garden > Tools", // Ernte & Verarbeitung
278: "Home & Garden > Tools", // Extraktion
302: "Home & Garden > Tools", // Erntemaschinen
// Hardware & Plumbing
222: "Hardware > Plumbing", // PE-Teile
374: "Hardware > Plumbing > Plumbing Fittings", // Verbindungsteile
// Electronics & Control
314: "Electronics > Electronics Accessories", // Steuergeräte
408: "Electronics > Electronics Accessories", // GrowControl
344: "Business & Industrial > Science & Laboratory > Lab Equipment", // Messgeräte
555: "Business & Industrial > Science & Laboratory > Lab Equipment > Microscopes", // Mikroskope
// Camping & Outdoor
226: "Sporting Goods > Outdoor Recreation > Camping & Hiking", // Zeltzubehör
// Plant Care & Protection
239: "Home & Garden > Lawn & Garden > Pest Control", // Pflanzenschutz
240: "Home & Garden > Plants", // Anbauzubehör
// Office & Media
424: "Office Supplies > Labels", // Etiketten & Schilder
387: "Media > Books", // Literatur
// General categories
705: "Home & Garden", // Set-Konfigurator
686: "Home & Garden", // Zubehör
741: "Home & Garden", // Zubehör
294: "Home & Garden", // Zubehör
695: "Home & Garden", // Zubehör
293: "Home & Garden", // Trockennetze
4: "Home & Garden", // Sonstiges
450: "Home & Garden", // Restposten
};
return categoryMappings[categoryId] || "Home & Garden > Plants";
};
let productsXml = `<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:g="http://base.google.com/ns/1.0" version="2.0">
<channel>
<title>${config.descriptions.short}</title>
<link>${baseUrl}</link>
<description>${config.descriptions.short}</description>
<lastBuildDate>${currentDate}</lastBuildDate>
<language>${config.language}</language>`;
// Helper function to clean text content of problematic characters
const cleanTextContent = (text) => {
if (!text) return "";
return text.toString()
// Remove HTML tags
.replace(/<[^>]*>/g, "")
// Remove non-printable characters and control characters
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, '')
// Remove BOM and other Unicode formatting characters
.replace(/[\uFEFF\u200B-\u200D\u2060]/g, '')
// Replace multiple whitespace with single space
.replace(/\s+/g, ' ')
// Remove leading/trailing whitespace
.trim();
};
// Helper function to properly escape XML content and remove invalid characters
const escapeXml = (unsafe) => {
if (!unsafe) return "";
// Convert to string and remove invalid XML characters
const cleaned = unsafe.toString()
// Remove control characters except tab (0x09), newline (0x0A), and carriage return (0x0D)
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
// Remove invalid Unicode characters and surrogates
.replace(/[\uD800-\uDFFF]/g, '')
// Remove other problematic characters
.replace(/[\uFFFE\uFFFF]/g, '')
// Normalize whitespace
.replace(/\s+/g, ' ')
.trim();
// Escape XML entities
return cleaned
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
};
let processedCount = 0;
let skippedCount = 0;
// Category IDs to skip (seeds, plants, headshop items)
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710];
// Add each product as an item
allProductsData.forEach((product, index) => {
try {
// Skip products without essential data
if (!product || !product.seoName) {
skippedCount++;
return;
}
// Skip products from excluded categories
const productCategoryId = product.categoryId || product.category_id || product.category || null;
if (productCategoryId && skipCategoryIds.includes(parseInt(productCategoryId))) {
skippedCount++;
return;
}
// Skip products without GTIN
if (!product.gtin || !product.gtin.toString().trim()) {
skippedCount++;
return;
}
// Skip products without pictures
if (!product.pictureList || !product.pictureList.trim()) {
skippedCount++;
return;
}
// Clean description for feed (remove HTML tags and limit length)
const rawDescription = product.description
? cleanTextContent(product.description).substring(0, 500)
: `${product.name || 'Product'} - Art.-Nr.: ${product.articleNumber || 'N/A'}`;
const cleanDescription = escapeXml(rawDescription) || "Produktbeschreibung nicht verfügbar";
// Clean product name
const rawName = product.name || "Unnamed Product";
const cleanName = escapeXml(cleanTextContent(rawName)) || "Unnamed Product";
// Validate essential fields
if (!cleanName || cleanName.length < 2) {
skippedCount++;
return;
}
// Generate product URL
const productUrl = `${baseUrl}/Artikel/${encodeURIComponent(product.seoName)}`;
// Generate image URL
const imageUrl = product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.jpg`
: `${baseUrl}/assets/images/nopicture.jpg`;
// Generate brand (manufacturer)
const rawBrand = product.manufacturer || config.brandName;
const brand = escapeXml(cleanTextContent(rawBrand));
// Generate condition (always new for this type of shop)
const condition = "new";
// Generate availability
const availability = product.available ? "in stock" : "out of stock";
// Generate price (ensure it's a valid number)
const price = product.price && !isNaN(product.price)
? `${parseFloat(product.price).toFixed(2)} ${config.currency}`
: `0.00 ${config.currency}`;
// Skip products with price == 0
if (!product.price || parseFloat(product.price) === 0) {
skippedCount++;
return;
}
// Generate GTIN/EAN if available
const gtin = product.gtin ? escapeXml(product.gtin.toString().trim()) : null;
// Generate product ID (using articleNumber or seoName)
const rawProductId = product.articleNumber || product.seoName || `product_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
const productId = escapeXml(rawProductId.toString().trim()) || `fallback_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
// Get Google product category based on product's category ID
const categoryId = product.categoryId || product.category_id || product.category || null;
const googleCategory = getGoogleProductCategory(categoryId);
const escapedGoogleCategory = escapeXml(googleCategory);
// Build item XML with proper formatting
productsXml += `
<item>
<g:id>${productId}</g:id>
<g:title>${cleanName}</g:title>
<g:description>${cleanDescription}</g:description>
<g:link>${productUrl}</g:link>
<g:image_link>${imageUrl}</g:image_link>
<g:condition>${condition}</g:condition>
<g:availability>${availability}</g:availability>
<g:price>${price}</g:price>
<g:shipping>
<g:country>${config.country}</g:country>
<g:service>${config.shipping.defaultService}</g:service>
<g:price>${config.shipping.defaultCost}</g:price>
</g:shipping>
<g:brand>${brand}</g:brand>
<g:google_product_category>${escapedGoogleCategory}</g:google_product_category>
<g:product_type>Gartenbedarf</g:product_type>`;
// Add GTIN if available
if (gtin && gtin.trim()) {
productsXml += `
<g:gtin>${gtin}</g:gtin>`;
}
// Add weight if available
if (product.weight && !isNaN(product.weight)) {
productsXml += `
<g:shipping_weight>${parseFloat(product.weight).toFixed(2)} g</g:shipping_weight>`;
}
productsXml += `
</item>`;
processedCount++;
} catch (itemError) {
console.log(` ⚠️ Skipped product ${index + 1}: ${itemError.message}`);
skippedCount++;
}
});
productsXml += `
</channel>
</rss>`;
console.log(` 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
return productsXml;
};
module.exports = {
generateRobotsTxt,
generateProductsXml,
};

215
prerender/seo/homepage.cjs Normal file
View File

@@ -0,0 +1,215 @@
const generateHomepageMetaTags = (baseUrl, config) => {
const description = config.descriptions.long;
const keywords = config.keywords;
const imageUrl = `${baseUrl}${config.images.logo}`;
// Ensure URLs are properly formatted
const canonicalUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
return `
<!-- SEO Meta Tags -->
<meta name="description" content="${description}">
<meta name="keywords" content="${keywords}">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="${config.descriptions.short}">
<meta property="og:description" content="${description}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:url" content="${canonicalUrl}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="${config.siteName}">
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${config.descriptions.short}">
<meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${imageUrl}">
<!-- Additional Meta Tags -->
<meta name="robots" content="index, follow">
<link rel="canonical" href="${canonicalUrl}">
`;
};
const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
// Ensure URLs are properly formatted
const canonicalUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const logoUrl = `${canonicalUrl}${config.images.logo}`;
const websiteJsonLd = {
"@context": "https://schema.org/",
"@type": "WebSite",
name: config.brandName,
url: canonicalUrl,
description: config.descriptions.long,
publisher: {
"@type": "Organization",
name: config.brandName,
url: canonicalUrl,
logo: {
"@type": "ImageObject",
url: logoUrl,
},
},
potentialAction: {
"@type": "SearchAction",
target: `${canonicalUrl}/search?q={search_term_string}`,
query: "required name=search_term_string"
},
mainEntity: {
"@type": "WebPage",
name: "Sitemap",
url: `${canonicalUrl}/sitemap`,
description: "Vollständige Sitemap mit allen Kategorien und Seiten",
},
sameAs: [
// Add your social media URLs here if available
],
};
// Organization/LocalBusiness Schema for rich results
const organizationJsonLd = {
"@context": "https://schema.org",
"@type": "LocalBusiness",
"name": config.brandName,
"alternateName": config.siteName,
"description": config.descriptions.long,
"url": canonicalUrl,
"logo": logoUrl,
"image": logoUrl,
"telephone": "015208491860",
"email": "service@growheads.de",
"address": {
"@type": "PostalAddress",
"streetAddress": "Trachenberger Strasse 14",
"addressLocality": "Dresden",
"postalCode": "01129",
"addressCountry": "DE",
"addressRegion": "Sachsen"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": "51.083675",
"longitude": "13.727215"
},
"openingHours": [
"Mo-Fr 10:00:00-20:00:00",
"Sa 11:00:00-19:00:00"
],
"paymentAccepted": "Cash, Credit Card, PayPal, Bank Transfer",
"currenciesAccepted": "EUR",
"priceRange": "€€",
"areaServed": {
"@type": "Country",
"name": "Germany"
},
"contactPoint": [
{
"@type": "ContactPoint",
"telephone": "015208491860",
"contactType": "customer service",
"availableLanguage": "German",
"hoursAvailable": {
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
"opens": "10:00:00",
"closes": "20:00:00"
}
},
{
"@type": "ContactPoint",
"email": "service@growheads.de",
"contactType": "customer service",
"availableLanguage": "German"
}
],
"sameAs": [
// Add social media URLs when available
// "https://www.facebook.com/growheads",
// "https://www.instagram.com/growheads"
]
};
// FAQPage Schema for common questions
const faqJsonLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "Welche Zahlungsmethoden akzeptiert GrowHeads?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Wir akzeptieren Kreditkarten, PayPal, Banküberweisung, Nachnahme und Barzahlung bei Abholung in unserem Laden in Dresden."
}
},
{
"@type": "Question",
"name": "Liefert GrowHeads deutschlandweit?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Ja, wir liefern deutschlandweit. Zusätzlich haben wir einen Laden in Dresden (Trachenberger Strasse 14) für lokale Kunden."
}
},
{
"@type": "Question",
"name": "Welche Produkte bietet GrowHeads?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Wir bieten ein komplettes Sortiment für den Indoor-Anbau: Beleuchtung, Belüftung, Dünger, Töpfe, Zelte, Messgeräte und vieles mehr für professionelle Zuchtanlagen."
}
},
{
"@type": "Question",
"name": "Hat GrowHeads einen physischen Laden?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Ja, unser Laden befindet sich in Dresden, Trachenberger Strasse 14. Öffnungszeiten: Mo-Fr 10-20 Uhr, Sa 11-19 Uhr. Sie können auch online bestellen."
}
},
{
"@type": "Question",
"name": "Bietet GrowHeads Beratung zum Indoor-Anbau?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Ja, unser erfahrenes Team berät Sie gerne zu allen Aspekten des Indoor-Anbaus. Kontaktieren Sie uns telefonisch unter 015208491860 oder besuchen Sie unseren Laden."
}
}
]
};
// Generate ItemList for all categories (more appropriate for homepage)
const categoriesListJsonLd = {
"@context": "https://schema.org",
"@type": "ItemList",
"name": "Produktkategorien",
"description": "Alle verfügbaren Produktkategorien in unserem Online-Shop",
"numberOfItems": categories.filter(category => category.seoName).length,
"itemListElement": categories
.filter(category => category.seoName) // Only include categories with seoName
.map((category, index) => ({
"@type": "ListItem",
"position": index + 1,
"item": {
"@type": "Thing",
"name": category.name,
"url": `${canonicalUrl}/Kategorie/${category.seoName}`
}
}))
};
// Return all JSON-LD scripts
const websiteScript = `<script type="application/ld+json">${JSON.stringify(websiteJsonLd)}</script>`;
const organizationScript = `<script type="application/ld+json">${JSON.stringify(organizationJsonLd)}</script>`;
const faqScript = `<script type="application/ld+json">${JSON.stringify(faqJsonLd)}</script>`;
const categoriesScript = categories.length > 0
? `<script type="application/ld+json">${JSON.stringify(categoriesListJsonLd)}</script>`
: '';
return websiteScript + '\n' + organizationScript + '\n' + faqScript + (categoriesScript ? '\n' + categoriesScript : '');
};
module.exports = {
generateHomepageMetaTags,
generateHomepageJsonLd,
};

64
prerender/seo/index.cjs Normal file
View File

@@ -0,0 +1,64 @@
// Import all SEO functions from their respective modules
const {
generateProductMetaTags,
generateProductJsonLd,
} = require('./product.cjs');
const {
generateCategoryJsonLd,
} = require('./category.cjs');
const {
generateHomepageMetaTags,
generateHomepageJsonLd,
} = require('./homepage.cjs');
const {
generateSitemapJsonLd,
generateXmlSitemap,
} = require('./sitemap.cjs');
const {
generateKonfiguratorMetaTags,
} = require('./konfigurator.cjs');
const {
generateRobotsTxt,
generateProductsXml,
} = require('./feeds.cjs');
const {
generateLlmsTxt,
generateCategoryLlmsTxt,
generateAllCategoryLlmsPages,
} = require('./llms.cjs');
// Export all functions for use in the main application
module.exports = {
// Product functions
generateProductMetaTags,
generateProductJsonLd,
// Category functions
generateCategoryJsonLd,
// Homepage functions
generateHomepageMetaTags,
generateHomepageJsonLd,
// Sitemap functions
generateSitemapJsonLd,
generateXmlSitemap,
// Konfigurator functions
generateKonfiguratorMetaTags,
// Feed/Export functions
generateRobotsTxt,
generateProductsXml,
// LLMs/AI functions
generateLlmsTxt,
generateCategoryLlmsTxt,
generateAllCategoryLlmsPages,
};

View File

@@ -0,0 +1,36 @@
const generateKonfiguratorMetaTags = (baseUrl, config) => {
const description = "Unser interaktiver Growbox Konfigurator hilft dir dabei, das perfekte Indoor Growing Setup zusammenzustellen. Wähle aus verschiedenen Growbox-Größen, Beleuchtung, Belüftung und Extras. Bundle-Rabatte bis 36%!";
const keywords = "Growbox Konfigurator, Indoor Growing, Growzelt, Beleuchtung, Belüftung, Growbox Setup, Indoor Garden";
// Ensure URLs are properly formatted
const canonicalUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const imageUrl = `${canonicalUrl}${config.images.placeholder}`; // Placeholder image
return `
<!-- SEO Meta Tags -->
<meta name="description" content="${description}">
<meta name="keywords" content="${keywords}">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="Growbox Konfigurator - Stelle dein perfektes Indoor Grow Setup zusammen">
<meta property="og:description" content="${description}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:url" content="${canonicalUrl}/Konfigurator">
<meta property="og:type" content="website">
<meta property="og:site_name" content="${config.siteName}">
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Growbox Konfigurator - Indoor Grow Setup">
<meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${imageUrl}">
<!-- Additional Meta Tags -->
<meta name="robots" content="index, follow">
<link rel="canonical" href="${canonicalUrl}/Konfigurator">
`;
};
module.exports = {
generateKonfiguratorMetaTags,
};

277
prerender/seo/llms.cjs Normal file
View File

@@ -0,0 +1,277 @@
const generateLlmsTxt = (allCategories = [], allProductsData = [], baseUrl, config) => {
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
// Group products by category for statistics
const productsByCategory = {};
allProductsData.forEach((product) => {
const categoryId = product.categoryId || 'uncategorized';
if (!productsByCategory[categoryId]) {
productsByCategory[categoryId] = [];
}
productsByCategory[categoryId].push(product);
});
// Find category names for organization
const categoryMap = {};
allCategories.forEach((cat) => {
categoryMap[cat.id] = cat.name;
});
let llmsTxt = `# ${config.siteName} - Site Map for LLMs
Generated: ${currentDate}
Base URL: ${baseUrl}
## About ${config.brandName}
GrowHeads.de is a German online shop and local store in Dresden specializing in high-quality seeds, plants, and gardening supplies for cannabis cultivation.
## Site Structure
### Static Pages
- **Home** - ${baseUrl}/
- **Datenschutz (Privacy Policy)** - ${baseUrl}/datenschutz
- **Impressum (Legal Notice)** - ${baseUrl}/impressum
- **AGB (Terms & Conditions)** - ${baseUrl}/agb
- **Widerrufsrecht (Right of Withdrawal)** - ${baseUrl}/widerrufsrecht
- **Batteriegesetzhinweise (Battery Law Notice)** - ${baseUrl}/batteriegesetzhinweise
- **Sitemap** - ${baseUrl}/sitemap
- **Growbox Konfigurator** - ${baseUrl}/Konfigurator - Interactive tool to configure grow box setups with bundle discounts
- **Profile** - ${baseUrl}/profile - User account and order management
### Site Features
- **Language**: German (${config.language})
- **Currency**: ${config.currency} (Euro)
- **Shipping**: ${config.country}
- **Payment Methods**: Credit Cards, PayPal, Bank Transfer, Cash on Delivery, Cash on Pickup
### Product Categories (${allCategories.length} categories)
`;
// Add categories with links to their detailed LLM files
allCategories.forEach((category) => {
if (category.seoName) {
const productCount = productsByCategory[category.id]?.length || 0;
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
const productsPerPage = 50;
const totalPages = Math.ceil(productCount / productsPerPage);
llmsTxt += `#### ${category.name} (${productCount} products)`;
if (totalPages > 1) {
llmsTxt += `
- **Product Catalog**: ${totalPages} pages available
- **Page 1**: ${baseUrl}/llms-${categorySlug}-page-1.txt (Products 1-${Math.min(productsPerPage, productCount)})`;
if (totalPages > 2) {
llmsTxt += `
- **Page 2**: ${baseUrl}/llms-${categorySlug}-page-2.txt (Products ${productsPerPage + 1}-${Math.min(productsPerPage * 2, productCount)})`;
}
if (totalPages > 3) {
llmsTxt += `
- **...**: Additional pages available`;
}
if (totalPages > 2) {
llmsTxt += `
- **Page ${totalPages}**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt (Products ${((totalPages - 1) * productsPerPage) + 1}-${productCount})`;
}
llmsTxt += `
- **Access Pattern**: Replace "page-X" with desired page number (1-${totalPages})`;
} else if (productCount > 0) {
llmsTxt += `
- **Product Catalog**: ${baseUrl}/llms-${categorySlug}-page-1.txt`;
} else {
llmsTxt += `
- **Product Catalog**: No products available`;
}
llmsTxt += `
`;
}
});
llmsTxt += `
---
*This sitemap is automatically generated during the site build process and includes all publicly accessible content. For technical inquiries, please refer to our contact information in the Impressum.*
`;
return llmsTxt;
};
const generateCategoryLlmsTxt = (category, categoryProducts = [], baseUrl, config, pageNumber = 1, productsPerPage = 50) => {
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
// Calculate pagination
const totalProducts = categoryProducts.length;
const totalPages = Math.ceil(totalProducts / productsPerPage);
const startIndex = (pageNumber - 1) * productsPerPage;
const endIndex = Math.min(startIndex + productsPerPage, totalProducts);
const pageProducts = categoryProducts.slice(startIndex, endIndex);
let categoryLlmsTxt = `# ${category.name} - Product Catalog (Page ${pageNumber} of ${totalPages})
Generated: ${currentDate}
Base URL: ${baseUrl}
Category: ${category.name} (ID: ${category.id})
Category URL: ${baseUrl}/Kategorie/${category.seoName}
## Category Overview
This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in the "${category.name}" category from ${config.siteName}.
**Statistics:**
- **Total Products in Category**: ${totalProducts}
- **Products on This Page**: ${pageProducts.length}
- **Current Page**: ${pageNumber} of ${totalPages}
- **Category ID**: ${category.id}
- **Category URL**: ${baseUrl}/Kategorie/${category.seoName}
- **Back to Main Sitemap**: ${baseUrl}/llms.txt
`;
// Add navigation hints for LLMs
if (totalPages > 1) {
categoryLlmsTxt += `## Navigation for LLMs
**How to access other pages in this category:**
`;
if (pageNumber > 1) {
categoryLlmsTxt += `- **Previous Page**: ${baseUrl}/llms-${categorySlug}-page-${pageNumber - 1}.txt
`;
}
if (pageNumber < totalPages) {
categoryLlmsTxt += `- **Next Page**: ${baseUrl}/llms-${categorySlug}-page-${pageNumber + 1}.txt
`;
}
categoryLlmsTxt += `- **First Page**: ${baseUrl}/llms-${categorySlug}-page-1.txt
- **Last Page**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt
**All pages in this category:**
`;
for (let i = 1; i <= totalPages; i++) {
categoryLlmsTxt += `- **Page ${i}**: ${baseUrl}/llms-${categorySlug}-page-${i}.txt (Products ${((i-1) * productsPerPage) + 1}-${Math.min(i * productsPerPage, totalProducts)})
`;
}
categoryLlmsTxt += `
`;
}
if (pageProducts.length > 0) {
pageProducts.forEach((product, index) => {
if (product.seoName) {
// Clean description for markdown (remove HTML tags and limit length)
const cleanDescription = product.description
? product.description
.replace(/<[^>]*>/g, "")
.replace(/\n/g, " ")
.trim()
.substring(0, 300)
: "";
const globalIndex = startIndex + index + 1;
categoryLlmsTxt += `## ${globalIndex}. ${product.name}
- **Product URL**: ${baseUrl}/Artikel/${product.seoName}
- **Article Number**: ${product.articleNumber || 'N/A'}
- **Price**: €${product.price || '0.00'}
- **Brand**: ${product.manufacturer || config.brandName}
- **Availability**: ${product.available ? 'In Stock' : 'Out of Stock'}`;
if (product.gtin) {
categoryLlmsTxt += `
- **GTIN**: ${product.gtin}`;
}
if (product.weight && !isNaN(product.weight)) {
categoryLlmsTxt += `
- **Weight**: ${product.weight}g`;
}
if (cleanDescription) {
categoryLlmsTxt += `
**Description:**
${cleanDescription}${product.description && product.description.length > 300 ? '...' : ''}`;
}
categoryLlmsTxt += `
---
`;
}
});
} else {
categoryLlmsTxt += `## No Products Available
This category currently contains no products.
`;
}
// Add footer navigation for convenience
if (totalPages > 1) {
categoryLlmsTxt += `## Page Navigation
`;
if (pageNumber > 1) {
categoryLlmsTxt += `← [Previous Page](${baseUrl}/llms-${categorySlug}-page-${pageNumber - 1}.txt) | `;
}
categoryLlmsTxt += `[Category Overview](${baseUrl}/llms-${categorySlug}-page-1.txt)`;
if (pageNumber < totalPages) {
categoryLlmsTxt += ` | [Next Page](${baseUrl}/llms-${categorySlug}-page-${pageNumber + 1}.txt) →`;
}
categoryLlmsTxt += `
`;
}
categoryLlmsTxt += `---
*This category product list is automatically generated during the site build process. Product availability and pricing are updated in real-time on the main website.*
`;
return categoryLlmsTxt;
};
// Helper function to generate all pages for a category
const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl, config, productsPerPage = 50) => {
const totalProducts = categoryProducts.length;
const totalPages = Math.ceil(totalProducts / productsPerPage);
const pages = [];
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
const pageContent = generateCategoryLlmsTxt(category, categoryProducts, baseUrl, config, pageNumber, productsPerPage);
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
const fileName = `llms-${categorySlug}-page-${pageNumber}.txt`;
pages.push({
fileName,
content: pageContent,
pageNumber,
totalPages
});
}
return pages;
};
module.exports = {
generateLlmsTxt,
generateCategoryLlmsTxt,
generateAllCategoryLlmsPages,
};

135
prerender/seo/product.cjs Normal file
View File

@@ -0,0 +1,135 @@
const generateProductMetaTags = (product, baseUrl, config) => {
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
const imageUrl =
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.jpg`
: `${baseUrl}/assets/images/nopicture.jpg`;
// Clean description for meta (remove HTML tags and limit length)
const cleanDescription = product.description
? product.description
.replace(/<[^>]*>/g, "")
.replace(/\n/g, " ")
.substring(0, 160)
: `${product.name} - Art.-Nr.: ${product.articleNumber}`;
return `
<!-- SEO Meta Tags -->
<meta name="description" content="${cleanDescription}">
<meta name="keywords" content="${product.name}, ${
product.manufacturer || ""
}, ${product.articleNumber}">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="${product.name}">
<meta property="og:description" content="${cleanDescription}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:url" content="${productUrl}">
<meta property="og:type" content="product">
<meta property="og:site_name" content="${config.siteName}">
<meta property="product:price:amount" content="${product.price}">
<meta property="product:price:currency" content="${config.currency}">
<meta property="product:availability" content="${
product.available ? "in stock" : "out of stock"
}">
${product.gtin ? `<meta property="product:gtin" content="${product.gtin}">` : ''}
${product.articleNumber ? `<meta property="product:retailer_item_id" content="${product.articleNumber}">` : ''}
${product.manufacturer ? `<meta property="product:brand" content="${product.manufacturer}">` : ''}
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${product.name}">
<meta name="twitter:description" content="${cleanDescription}">
<meta name="twitter:image" content="${imageUrl}">
<!-- Additional Meta Tags -->
<meta name="robots" content="index, follow">
<link rel="canonical" href="${productUrl}">
`;
};
const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) => {
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
const imageUrl =
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.jpg`
: `${baseUrl}/assets/images/nopicture.jpg`;
// Clean description for JSON-LD (remove HTML tags)
const cleanDescription = product.description
? product.description.replace(/<[^>]*>/g, "").replace(/\n/g, " ")
: product.name;
// Calculate price valid date (current date + 3 months)
const priceValidDate = new Date();
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
const jsonLd = {
"@context": "https://schema.org/",
"@type": "Product",
name: product.name,
image: [imageUrl],
description: cleanDescription,
sku: product.articleNumber,
...(product.gtin && { gtin: product.gtin }),
brand: {
"@type": "Brand",
name: product.manufacturer || "Unknown",
},
offers: {
"@type": "Offer",
url: productUrl,
priceCurrency: config.currency,
price: product.price.toString(),
priceValidUntil: priceValidDate.toISOString().split("T")[0],
itemCondition: "https://schema.org/NewCondition",
availability: product.available
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
seller: {
"@type": "Organization",
name: config.brandName,
},
},
};
// Add breadcrumb if category information is available
if (categoryInfo && categoryInfo.name && categoryInfo.seoName) {
jsonLd.breadcrumb = {
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: baseUrl,
},
{
"@type": "ListItem",
position: 2,
name: categoryInfo.name,
item: `${baseUrl}/Kategorie/${categoryInfo.seoName}`,
},
{
"@type": "ListItem",
position: 3,
name: product.name,
item: productUrl,
},
],
};
}
return `<script type="application/ld+json">${JSON.stringify(
jsonLd
)}</script>`;
};
module.exports = {
generateProductMetaTags,
generateProductJsonLd,
};

117
prerender/seo/sitemap.cjs Normal file
View File

@@ -0,0 +1,117 @@
const generateSitemapJsonLd = (allCategories = [], baseUrl, config) => {
const jsonLd = {
"@context": "https://schema.org/",
"@type": "WebPage",
name: "Sitemap",
url: `${baseUrl}/sitemap`,
description: `Sitemap - Übersicht aller Kategorien und Seiten auf ${config.siteName}`,
breadcrumb: {
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: baseUrl,
},
{
"@type": "ListItem",
position: 2,
name: "Sitemap",
item: `${baseUrl}/sitemap`,
},
],
},
};
// Add all categories as site navigation elements
if (allCategories && allCategories.length > 0) {
jsonLd.mainEntity = {
"@type": "SiteNavigationElement",
name: "Kategorien",
hasPart: allCategories.map((category) => ({
"@type": "SiteNavigationElement",
name: category.name,
url: `${baseUrl}/Kategorie/${category.seoName}`,
description: `${category.name} Kategorie`,
})),
};
}
return `<script type="application/ld+json">${JSON.stringify(
jsonLd
)}</script>`;
};
const generateXmlSitemap = (allCategories = [], allProducts = [], baseUrl) => {
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
let sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
`;
// Homepage
sitemap += ` <url>
<loc>${baseUrl}/</loc>
<lastmod>${currentDate}</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
`;
// Static pages
const staticPages = [
{ path: "/datenschutz", changefreq: "monthly", priority: "0.3" },
{ path: "/impressum", changefreq: "monthly", priority: "0.3" },
{ path: "/batteriegesetzhinweise", changefreq: "monthly", priority: "0.3" },
{ path: "/widerrufsrecht", changefreq: "monthly", priority: "0.3" },
{ path: "/sitemap", changefreq: "weekly", priority: "0.5" },
{ path: "/agb", changefreq: "monthly", priority: "0.3" },
{ path: "/404", changefreq: "monthly", priority: "0.1" },
{ path: "/Konfigurator", changefreq: "weekly", priority: "0.8" },
];
staticPages.forEach((page) => {
sitemap += ` <url>
<loc>${baseUrl}${page.path}</loc>
<lastmod>${currentDate}</lastmod>
<changefreq>${page.changefreq}</changefreq>
<priority>${page.priority}</priority>
</url>
`;
});
// Category pages
allCategories.forEach((category) => {
if (category.seoName) {
sitemap += ` <url>
<loc>${baseUrl}/Kategorie/${category.seoName}</loc>
<lastmod>${currentDate}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
`;
}
});
// Product pages
allProducts.forEach((productSeoName) => {
sitemap += ` <url>
<loc>${baseUrl}/Artikel/${productSeoName}</loc>
<lastmod>${currentDate}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
</url>
`;
});
sitemap += `</urlset>`;
return sitemap;
};
module.exports = {
generateSitemapJsonLd,
generateXmlSitemap,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1,11 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="SeedHeads - Ihr Online-Shop für hochwertige Samen, Pflanzen und Gartenbedarf. Entdecken Sie unser großes Sortiment an Saatgut, Pflanzen und Gartenzubehör für Ihren grünen Daumen.">
<meta name="description" content="Growheads - Dein Anlaufpunkt für alles rund um den Grow in Dresden. Substrate, Dünger, Steckling & Seeds, Lampen, Lüfter, Filter, Growboxen">
<base href="/">
<title>SeedHeads.de</title>
<title>GrowHeads.de</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
</head>
<body style="overflow-y: scroll;">
@@ -36,4 +36,4 @@
setTimeout(checkForUpdates, 1000 * 1000);
</script>
</body>
</html>
</html>

View File

@@ -40,6 +40,7 @@ const ServerLogsPage = lazy(() => import(/* webpackChunkName: "admin-logs" */ ".
// Lazy load legal pages - rarely accessed
const Datenschutz = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Datenschutz.js"));
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 Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
@@ -164,7 +165,8 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
// Check if we're in development mode
const isDevelopment = process.env.NODE_ENV === "development";
const socket = useContext(SocketContext);
const {socket,socketB} = useContext(SocketContext);
console.log("AppContent: socket", socket);
return (
<Box
@@ -200,7 +202,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
{/* Category page - Render Content in parallel */}
<Route
path="/Kategorie/:categoryId"
element={<Content socket={socket} />}
element={<Content socket={socket} socketB={socketB} />}
/>
{/* Single product page */}
<Route
@@ -209,7 +211,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
/>
{/* Search page - Render Content in parallel */}
<Route path="/search" element={<Content socket={socket} />} />
<Route path="/search" element={<Content socket={socket} socketB={socketB} />} />
{/* Profile page */}
<Route path="/profile" element={<ProfilePageWithSocket />} />
@@ -217,21 +219,22 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
{/* Reset password page */}
<Route
path="/resetPassword"
element={<ResetPassword socket={socket} />}
element={<ResetPassword socket={socket} socketB={socketB} />}
/>
{/* Admin page */}
<Route path="/admin" element={<AdminPage socket={socket} />} />
<Route path="/admin" element={<AdminPage socket={socket} socketB={socketB} />} />
{/* Admin Users page */}
<Route path="/admin/users" element={<UsersPage socket={socket} />} />
<Route path="/admin/users" element={<UsersPage socket={socket} socketB={socketB} />} />
{/* Admin Server Logs page */}
<Route path="/admin/logs" element={<ServerLogsPage socket={socket} />} />
<Route path="/admin/logs" element={<ServerLogsPage socket={socket} socketB={socketB} />} />
{/* Legal pages */}
<Route path="/datenschutz" element={<Datenschutz />} />
<Route path="/agb" element={<AGB />} />
<Route path="/404" element={<NotFound404 />} />
<Route path="/sitemap" element={<Sitemap />} />
<Route path="/impressum" element={<Impressum />} />
<Route

137
src/PrerenderSitemap.js Normal file
View File

@@ -0,0 +1,137 @@
const React = require('react');
const {
Box,
AppBar,
Toolbar,
Container,
Typography,
List,
ListItem,
ListItemText
} = require('@mui/material');
const Footer = require('./components/Footer.js').default;
const { Logo, CategoryList } = require('./components/header/index.js');
const LegalPage = require('./pages/LegalPage.js').default;
const PrerenderSitemap = ({ categoryData }) => {
// Process category data to flatten the hierarchy
const collectAllCategories = (categoryNode, categories = [], level = 0) => {
if (!categoryNode) return categories;
// Add current category (skip root category 209)
if (categoryNode.id !== 209 && categoryNode.seoName) {
categories.push({
id: categoryNode.id,
name: categoryNode.name,
seoName: categoryNode.seoName,
level: level
});
}
// Recursively add children
if (categoryNode.children) {
for (const child of categoryNode.children) {
collectAllCategories(child, categories, level + 1);
}
}
return categories;
};
const categories = categoryData ? collectAllCategories(categoryData) : [];
const sitemapLinks = [
{ title: 'Startseite', url: '/' },
{ title: 'Mein Profil', url: '/profile' },
{ title: 'Datenschutz', url: '/datenschutz' },
{ title: 'AGB', url: '/agb' },
{ title: 'Impressum', url: '/impressum' },
{ title: 'Batteriegesetzhinweise', url: '/batteriegesetzhinweise' },
{ title: 'Widerrufsrecht', url: '/widerrufsrecht' },
{ title: 'Growbox Konfigurator', url: '/Konfigurator' },
{ title: 'API', url: '/api/', route: false },
];
const content = React.createElement(
React.Fragment,
null,
React.createElement(
Typography,
{ variant: 'body1', paragraph: true },
'Hier finden Sie eine Übersicht aller verfügbaren Seiten unserer Website.'
),
// Static site links
React.createElement(
Typography,
{ variant: 'h6', sx: { mt: 3, mb: 2, fontWeight: 'bold' } },
'Seiten'
),
React.createElement(
List,
null,
sitemapLinks.map((link) =>
React.createElement(
ListItem,
{
key: link.url,
button: true,
component: link.route === false ? 'a' : 'a',
href: link.url,
sx: {
py: 1,
borderBottom: '1px solid',
borderColor: 'divider'
}
},
React.createElement(ListItemText, { primary: link.title })
)
)
),
// Category links
React.createElement(
Typography,
{ variant: 'h6', sx: { mt: 4, mb: 2, fontWeight: 'bold' } },
'Kategorien'
),
React.createElement(
List,
null,
categories.map((category) =>
React.createElement(
ListItem,
{
key: category.id,
button: true,
component: 'a',
href: `/Kategorie/${category.seoName}`,
sx: {
py: 1,
pl: 2 + (category.level * 2), // Indent based on category level
borderBottom: '1px solid',
borderColor: 'divider'
}
},
React.createElement(
ListItemText,
{
primary: category.name,
sx: {
'& .MuiTypography-root': {
fontSize: category.level === 0 ? '1rem' : '0.9rem',
fontWeight: category.level === 0 ? 'bold' : 'normal',
color: category.level === 0 ? 'primary.main' : 'text.primary'
}
}
}
)
)
)
)
);
return React.createElement(LegalPage, { title: 'Sitemap', content: content });
};
module.exports = { default: PrerenderSitemap };

View File

@@ -22,7 +22,7 @@ const CategoryBox = ({
const [imageUrl, setImageUrl] = useState(null);
const [imageError, setImageError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const socket = useContext(SocketContext);
const context = useContext(SocketContext);
useEffect(() => {
let objectUrl = null;
@@ -61,10 +61,10 @@ const CategoryBox = ({
}
// If socket is available and connected, fetch the image
if (socket && socket.connected && id && !isLoading) {
if (context && context.socket && context.socket.connected && id && !isLoading) {
setIsLoading(true);
socket.emit('getCategoryPic', { categoryId: id }, (response) => {
context.socket.emit('getCategoryPic', { categoryId: id }, (response) => {
setIsLoading(false);
if (response.success) {
@@ -119,7 +119,7 @@ const CategoryBox = ({
URL.revokeObjectURL(objectUrl);
}
};
}, [socket, socket?.connected, id, isLoading]);
}, [context, context?.socket?.connected, id, isLoading]);
return (
<Paper

View File

@@ -97,7 +97,12 @@ function getFilteredProducts(unfilteredProducts, attributes) {
let filteredProducts = (unfilteredProducts || []).filter(product => {
const availabilityFilter = sessionStorage.getItem('filter_availability');
let inStockMatch = availabilityFilter == 1 ? true : (product.available>0);
const isNewMatch = availabilityFilters.includes('2') ? isNew(product.neu) : true;
// Check if there are any new products in the entire set
const hasNewProducts = (unfilteredProducts || []).some(product => isNew(product.neu));
// Only apply the new filter if there are actually new products and the filter is active
const isNewMatch = availabilityFilters.includes('2') && hasNewProducts ? isNew(product.neu) : true;
let soonMatch = availabilityFilters.includes('3') ? !product.available && product.incoming : true;
const soon2Match = (availabilityFilter != 1)&&availabilityFilters.includes('3') ? (product.available) || (!product.available && product.incoming) : true;
@@ -133,7 +138,31 @@ function getFilteredProducts(unfilteredProducts, attributes) {
const manufacturer = uniqueManufacturersWithName.find(manufacturer => manufacturer.id === filter);
return {name: manufacturer.value, value: manufacturer.id};
});
return {filteredProducts,activeAttributeFilters:activeAttributeFiltersWithNames,activeManufacturerFilters:activeManufacturerFiltersWithNames};
// Extract active availability filters
const availabilityFilter = sessionStorage.getItem('filter_availability');
const activeAvailabilityFilters = [];
// Check if there are actually products with these characteristics
const hasNewProducts = (unfilteredProducts || []).some(product => isNew(product.neu));
const hasComingSoonProducts = (unfilteredProducts || []).some(product => !product.available && product.incoming);
// Check for "auf Lager" filter (in stock) - it's active when filter_availability is NOT set to '1'
if (availabilityFilter !== '1') {
activeAvailabilityFilters.push({id: '1', name: 'auf Lager'});
}
// Check for "Neu" filter (new) - only show if there are actually new products and filter is active
if (availabilityFilters.includes('2') && hasNewProducts) {
activeAvailabilityFilters.push({id: '2', name: 'Neu'});
}
// Check for "Bald verfügbar" filter (coming soon) - only show if there are actually coming soon products and filter is active
if (availabilityFilters.includes('3') && hasComingSoonProducts) {
activeAvailabilityFilters.push({id: '3', name: 'Bald verfügbar'});
}
return {filteredProducts,activeAttributeFilters:activeAttributeFiltersWithNames,activeManufacturerFilters:activeManufacturerFiltersWithNames,activeAvailabilityFilters};
}
function setCachedCategoryData(categoryId, data) {
if (!window.productCache) {
@@ -238,6 +267,8 @@ class Content extends Component {
});
}
fetchCategoryData(categoryId) {
const cachedData = getCachedCategoryData(categoryId);
if (cachedData) {
@@ -251,14 +282,36 @@ class Content extends Component {
console.log("Socket not connected yet, waiting for connection to fetch category data");
return;
}
console.log(`productList:${categoryId}`);
this.props.socket.off(`productList:${categoryId}`);
// Track if we've received the full response to ignore stub response if needed
let receivedFullResponse = false;
this.props.socket.on(`productList:${categoryId}`,(response) => {
console.log("getCategoryProducts full response", response);
receivedFullResponse = true;
setCachedCategoryData(categoryId, response);
if (response && response.products !== undefined) {
this.processDataWithCategoryTree(response, categoryId);
} else {
console.log("fetchCategoryData in Content failed", response);
}
});
this.props.socket.emit("getCategoryProducts", { categoryId: categoryId },
(response) => {
setCachedCategoryData(categoryId, response);
if (response && response.products !== undefined) {
this.processDataWithCategoryTree(response, categoryId);
console.log("getCategoryProducts stub response", response);
// Only process stub response if we haven't received the full response yet
if (!receivedFullResponse) {
setCachedCategoryData(categoryId, response);
if (response && response.products !== undefined) {
this.processDataWithCategoryTree(response, categoryId);
} else {
console.log("fetchCategoryData in Content failed", response);
}
} else {
console.log("fetchCategoryData in Content failed", response);
console.log("Ignoring stub response - full response already received");
}
}
);
@@ -417,7 +470,7 @@ class Content extends Component {
return (
<Container maxWidth="xl" sx={{ py: 2, flexGrow: 1, display: 'grid', gridTemplateRows: '1fr' }}>
<Container maxWidth="xl" sx={{ py: { xs: 0, sm: 2 }, px: { xs: 0, sm: 3 }, flexGrow: 1, display: 'grid', gridTemplateRows: '1fr' }}>
{showCategoryBoxes ? (
// Show category boxes layout when no products but have child categories
@@ -467,30 +520,20 @@ class Content extends Component {
</Box>
{/* Subcategories Grid */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<CategoryBoxGrid
categories={this.state.childCategories}
showTitle={false}
spacing={3}
/>
<Box sx={{ flexGrow: 1 }}>
<CategoryBoxGrid categories={this.state.childCategories} />
</Box>
</Box>
);
} else {
// Just show subcategories without parent
return (
<CategoryBoxGrid
categories={this.state.childCategories}
showTitle={false}
spacing={3}
/>
);
// No parent category, just show subcategories
return <CategoryBoxGrid categories={this.state.childCategories} />;
}
})()}
</Box>
)}
{/* Show parent category navigation when in 2nd or 3rd level but no subcategories */}
{/* Show standalone parent category navigation when there are only products */}
{this.state.loaded &&
this.props.params.categoryId &&
!(this.state.unfilteredProducts.length > 0 && this.state.childCategories.length > 0) && (() => {
@@ -534,7 +577,7 @@ class Content extends Component {
<Box sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 2fr', md: '1fr 3fr', lg: '1fr 4fr', xl: '1fr 4fr' },
gap: 3
gap: { xs: 0, sm: 3 }
}}>
<Stack direction="row" spacing={0} sx={{
@@ -661,10 +704,12 @@ class Content extends Component {
<Box>
<ProductList
socket={this.props.socket}
socketB={this.props.socketB}
totalProductCount={(this.state.unfilteredProducts || []).length}
products={this.state.filteredProducts || []}
activeAttributeFilters={this.state.activeAttributeFilters || []}
activeManufacturerFilters={this.state.activeManufacturerFilters || []}
activeAvailabilityFilters={this.state.activeAvailabilityFilters || []}
onFilterChange={()=>{this.filterProducts()}}
dataType={this.state.dataType}
dataParam={this.state.dataParam}

View File

@@ -37,13 +37,17 @@ class Header extends Component {
render() {
// Get socket directly from context in render method
const socket = this.context;
const {socket,socketB} = this.context;
const { isHomePage, isProfilePage } = this.props;
return (
<AppBar position="sticky" color="primary" elevation={0} sx={{ zIndex: 1100 }}>
<Toolbar sx={{ minHeight: 64 }}>
<Container maxWidth="lg" sx={{ display: 'flex', alignItems: 'center' }}>
<Toolbar sx={{ minHeight: 64, py: { xs: 0.5, sm: 0 } }}>
<Container maxWidth="lg" sx={{
display: 'flex',
alignItems: 'center',
px: { xs: 0, sm: 3 }
}}>
{/* First row: Logo and ButtonGroup on xs, all items on larger screens */}
<Box sx={{
display: 'flex',
@@ -56,28 +60,41 @@ class Header extends Component {
display: 'flex',
alignItems: 'center',
width: '100%',
justifyContent: { xs: 'space-between', sm: 'flex-start' }
justifyContent: { xs: 'space-between', sm: 'flex-start' },
minHeight: { xs: 52, sm: 'auto' },
px: { xs: 0, sm: 0 }
}}>
<Logo />
{/* SearchBar visible on sm and up */}
<Box sx={{ display: { xs: 'none', sm: 'block' }, flexGrow: 1 }}>
<SearchBar />
</Box>
<ButtonGroupWithRouter socket={socket}/>
<Box sx={{
display: 'flex',
alignItems: { xs: 'flex-end', sm: 'center' },
transform: { xs: 'translateY(4px) translateX(9px)', sm: 'none' },
ml: { xs: 0, sm: 0 }
}}>
<ButtonGroupWithRouter socket={socket}/>
</Box>
</Box>
{/* Second row: SearchBar only on xs */}
{/* Second row: SearchBar only on xs - make it wider */}
<Box sx={{
display: { xs: 'block', sm: 'none' },
width: '100%',
mt: 1,mb: 1
mt: { xs: 1, sm: 0 },
mb: { xs: 0.5, sm: 0 },
px: { xs: 0, sm: 0 }
}}>
<SearchBar />
<Box sx={{ width: '100%' }}>
<SearchBar />
</Box>
</Box>
</Box>
</Container>
</Toolbar>
{(isHomePage || this.props.categoryId || isProfilePage) && <CategoryList categoryId={209} activeCategoryId={this.props.categoryId} socket={socket} />}
{(isHomePage || this.props.categoryId || isProfilePage) && <CategoryList categoryId={209} activeCategoryId={this.props.categoryId} socket={socket} socketB={socketB} />}
</AppBar>
);
}
@@ -91,7 +108,7 @@ const HeaderWithContext = (props) => {
return (
<SocketContext.Consumer>
{socket => <Header {...props} socket={socket} isHomePage={isHomePage} isProfilePage={isProfilePage} />}
{({socket,socketB}) => <Header {...props} socket={socket} socketB={socketB} isHomePage={isHomePage} isProfilePage={isProfilePage} />}
</SocketContext.Consumer>
);
};

View File

@@ -77,7 +77,7 @@ class Images extends Component {
}
loadPic = (size,bildId,index) => {
this.props.socket.emit('getPic', { bildId, size }, (res) => {
this.props.socketB.emit('getPic', { bildId, size }, (res) => {
if(res.success){
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));

View File

@@ -543,7 +543,7 @@ export class LoginComponent extends Component {
onClick={this.handleOpen}
sx={{ my: 1, mx: 1.5 }}
>
Anmelden
Login
</Button>
)
)}

381
src/components/Mollie.js Normal file
View File

@@ -0,0 +1,381 @@
import React, { Component, useState } from "react";
import { Button, Box, Typography, CircularProgress } from "@mui/material";
import config from "../config.js";
// Function to lazy load Mollie script
const loadMollie = () => {
return new Promise((resolve, reject) => {
// Check if Mollie is already loaded
if (window.Mollie) {
resolve(window.Mollie);
return;
}
// Create script element
const script = document.createElement('script');
script.src = 'https://js.mollie.com/v1/mollie.js';
script.async = true;
script.onload = () => {
if (window.Mollie) {
resolve(window.Mollie);
} else {
reject(new Error('Mollie failed to load'));
}
};
script.onerror = () => {
reject(new Error('Failed to load Mollie script'));
};
document.head.appendChild(script);
});
};
const CheckoutForm = ({ mollie }) => {
const [errorMessage, setErrorMessage] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
React.useEffect(() => {
if (!mollie) return;
let mountedComponents = {
cardNumber: null,
cardHolder: null,
expiryDate: null,
verificationCode: null
};
try {
// Create Mollie components
const cardNumber = mollie.createComponent('cardNumber');
const cardHolder = mollie.createComponent('cardHolder');
const expiryDate = mollie.createComponent('expiryDate');
const verificationCode = mollie.createComponent('verificationCode');
// Store references for cleanup
mountedComponents = {
cardNumber,
cardHolder,
expiryDate,
verificationCode
};
// Mount components
cardNumber.mount('#card-number');
cardHolder.mount('#card-holder');
expiryDate.mount('#expiry-date');
verificationCode.mount('#verification-code');
// Set up error handling
cardNumber.addEventListener('change', event => {
const errorElement = document.querySelector('#card-number-error');
if (errorElement) {
if (event.error && event.touched) {
errorElement.textContent = event.error;
} else {
errorElement.textContent = '';
}
}
});
cardHolder.addEventListener('change', event => {
const errorElement = document.querySelector('#card-holder-error');
if (errorElement) {
if (event.error && event.touched) {
errorElement.textContent = event.error;
} else {
errorElement.textContent = '';
}
}
});
expiryDate.addEventListener('change', event => {
const errorElement = document.querySelector('#expiry-date-error');
if (errorElement) {
if (event.error && event.touched) {
errorElement.textContent = event.error;
} else {
errorElement.textContent = '';
}
}
});
verificationCode.addEventListener('change', event => {
const errorElement = document.querySelector('#verification-code-error');
if (errorElement) {
if (event.error && event.touched) {
errorElement.textContent = event.error;
} else {
errorElement.textContent = '';
}
}
});
// Components are now mounted and ready
} catch (error) {
console.error('Error creating Mollie components:', error);
setErrorMessage('Failed to initialize payment form. Please try again.');
}
// Cleanup function
return () => {
try {
if (mountedComponents.cardNumber) mountedComponents.cardNumber.unmount();
if (mountedComponents.cardHolder) mountedComponents.cardHolder.unmount();
if (mountedComponents.expiryDate) mountedComponents.expiryDate.unmount();
if (mountedComponents.verificationCode) mountedComponents.verificationCode.unmount();
} catch (error) {
console.error('Error cleaning up Mollie components:', error);
}
};
}, [mollie]);
const handleSubmit = async (event) => {
event.preventDefault();
if (!mollie || isSubmitting) {
return;
}
setIsSubmitting(true);
setErrorMessage(null);
try {
const { token, error } = await mollie.createToken();
if (error) {
setErrorMessage(error.message || 'Payment failed. Please try again.');
setIsSubmitting(false);
return;
}
if (token) {
// Handle successful token creation
// Create a payment completion event similar to Stripe
const mollieCompletionData = {
mollieToken: token,
paymentMethod: 'mollie'
};
// Dispatch a custom event to notify the parent component
const completionEvent = new CustomEvent('molliePaymentComplete', {
detail: mollieCompletionData
});
window.dispatchEvent(completionEvent);
// For now, redirect to profile with completion data
const returnUrl = `${window.location.origin}/profile?complete&mollie_token=${token}`;
window.location.href = returnUrl;
}
} catch (error) {
console.error('Error creating Mollie token:', error);
setErrorMessage('Payment failed. Please try again.');
setIsSubmitting(false);
}
};
return (
<Box sx={{ maxWidth: 600, mx: 'auto', p: 3 }}>
<Typography variant="h6" gutterBottom>
Kreditkarte oder Sofortüberweisung
</Typography>
<form onSubmit={handleSubmit}>
<Box sx={{ mb: 3 }}>
<Typography variant="body2" gutterBottom>
Kartennummer
</Typography>
<Box
id="card-number"
sx={{
p: 2,
border: '1px solid #e0e0e0',
borderRadius: 1,
minHeight: 40,
backgroundColor: '#fff'
}}
/>
<Typography
id="card-number-error"
variant="caption"
sx={{ color: 'error.main', minHeight: 16, display: 'block' }}
/>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="body2" gutterBottom>
Karteninhaber
</Typography>
<Box
id="card-holder"
sx={{
p: 2,
border: '1px solid #e0e0e0',
borderRadius: 1,
minHeight: 40,
backgroundColor: '#fff'
}}
/>
<Typography
id="card-holder-error"
variant="caption"
sx={{ color: 'error.main', minHeight: 16, display: 'block' }}
/>
</Box>
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" gutterBottom>
Ablaufdatum
</Typography>
<Box
id="expiry-date"
sx={{
p: 2,
border: '1px solid #e0e0e0',
borderRadius: 1,
minHeight: 40,
backgroundColor: '#fff'
}}
/>
<Typography
id="expiry-date-error"
variant="caption"
sx={{ color: 'error.main', minHeight: 16, display: 'block' }}
/>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" gutterBottom>
Sicherheitscode
</Typography>
<Box
id="verification-code"
sx={{
p: 2,
border: '1px solid #e0e0e0',
borderRadius: 1,
minHeight: 40,
backgroundColor: '#fff'
}}
/>
<Typography
id="verification-code-error"
variant="caption"
sx={{ color: 'error.main', minHeight: 16, display: 'block' }}
/>
</Box>
</Box>
<Button
variant="contained"
disabled={!mollie || isSubmitting}
type="submit"
fullWidth
sx={{
mt: 2,
backgroundColor: '#2e7d32',
'&:hover': {
backgroundColor: '#1b5e20'
}
}}
>
{isSubmitting ? (
<>
<CircularProgress size={20} sx={{ mr: 1, color: 'white' }} />
Verarbeitung...
</>
) : (
'Bezahlung Abschließen'
)}
</Button>
{errorMessage && (
<Typography
variant="body2"
sx={{ color: 'error.main', mt: 2, textAlign: 'center' }}
>
{errorMessage}
</Typography>
)}
</form>
</Box>
);
};
class Mollie extends Component {
constructor(props) {
super(props);
this.state = {
mollie: null,
loading: true,
error: null,
};
this.molliePromise = loadMollie();
}
componentDidMount() {
this.molliePromise
.then((MollieClass) => {
try {
// Initialize Mollie with profile key
const mollie = MollieClass(config.mollieProfileKey, {
locale: 'de_DE',
testmode: true // Set to false for production
});
this.setState({ mollie, loading: false });
} catch (error) {
console.error('Error initializing Mollie:', error);
this.setState({
error: 'Failed to initialize payment system. Please try again.',
loading: false
});
}
})
.catch((error) => {
console.error('Error loading Mollie:', error);
this.setState({
error: 'Failed to load payment system. Please try again.',
loading: false
});
});
}
render() {
const { mollie, loading, error } = this.state;
if (loading) {
return (
<Box sx={{ textAlign: 'center', py: 4 }}>
<CircularProgress sx={{ color: '#2e7d32' }} />
<Typography variant="body1" sx={{ mt: 2 }}>
Zahlungskomponente wird geladen...
</Typography>
</Box>
);
}
if (error) {
return (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body1" sx={{ color: 'error.main' }}>
{error}
</Typography>
<Button
variant="outlined"
onClick={() => window.location.reload()}
sx={{ mt: 2 }}
>
Seite neu laden
</Button>
</Box>
);
}
return <CheckoutForm mollie={mollie} />;
}
}
export default Mollie;

View File

@@ -26,7 +26,8 @@ class Product extends Component {
this.state = {image:window.smallPicCache[bildId],loading:false, error: false}
}else{
this.state = {image: null, loading: true, error: false};
this.props.socket.emit('getPic', { bildId, size:'small' }, (res) => {
console.log("Product: Fetching image from socketB", this.props.socketB);
this.props.socketB.emit('getPic', { bildId, size:'small' }, (res) => {
if(res.success){
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
if (this._isMounted) {
@@ -179,18 +180,21 @@ class Product extends Component {
<Card
sx={{
width: { xs: 'calc(100vw - 48px)', sm: '250px' },
minWidth: { xs: 'calc(100vw - 48px)', sm: '250px' },
width: { xs: '100vw', sm: '250px' },
minWidth: { xs: '100vw', sm: '250px' },
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
position: 'relative',
overflow: 'hidden',
borderRadius: '8px',
borderRadius: { xs: 0, sm: '8px' },
border: { xs: 'none', sm: 'inherit' },
boxShadow: { xs: 'none', sm: 'inherit' },
mx: { xs: 0, sm: 'auto' },
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: '0px 10px 20px rgba(0,0,0,0.1)'
transform: { xs: 'none', sm: 'translateY(-5px)' },
boxShadow: { xs: 'none', sm: '0px 10px 20px rgba(0,0,0,0.1)' }
}
}}
>

View File

@@ -110,8 +110,8 @@ class ProductDetailPage extends Component {
}
} else {
// Not in cache, fetch from server
if (this.props.socket && this.props.socket.connected) {
this.props.socket.emit(
if (this.props.socketB && this.props.socketB.connected) {
this.props.socketB.emit(
"getAttributePicture",
{ id: cacheKey },
(res) => {
@@ -334,6 +334,7 @@ class ProductDetailPage extends Component {
{product.pictureList && (
<Images
socket={this.props.socket}
socketB={this.props.socketB}
pictureList={product.pictureList}
fullscreenOpen={this.state.imageDialogOpen}
onOpenFullscreen={this.handleOpenDialog}

View File

@@ -11,7 +11,7 @@ const ProductDetailWithSocket = () => {
return (
<SocketContext.Consumer>
{socket => <ProductDetailPage seoName={seoName} navigate={navigate} location={location} socket={socket} />}
{({socket,socketB}) => <ProductDetailPage seoName={seoName} navigate={navigate} location={location} socket={socket} socketB={socketB} />}
</SocketContext.Consumer>
);
};

View File

@@ -162,13 +162,17 @@ class ProductFilters extends Component {
return (
<Paper
id="filters-paper"
elevation={1}
elevation={window.innerWidth < 600 ? 0 : 1}
sx={{
p: 2,
borderRadius: 2,
p: { xs: 1, sm: 2 },
borderRadius: { xs: 0, sm: 2 },
bgcolor: 'background.paper',
display: 'flex',
flexDirection: 'column'
flexDirection: 'column',
border: { xs: 'none', sm: 'inherit' },
boxShadow: { xs: 'none', sm: 'inherit' },
mx: { xs: 0, sm: 'auto' },
width: { xs: '100%', sm: 'auto' }
}}
>

View File

@@ -122,9 +122,19 @@ class ProductList extends Component {
}
renderPagination = (pages, page) => {
// Make pagination invisible when there are zero products to avoid layout shifts
const hasProducts = this.props.products.length > 0;
return (
<Box sx={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'left' }}>
{((this.state.itemsPerPage==='all')||(this.props.products.length<this.state.itemsPerPage))?null:
<Box sx={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'left',
width: '100%',
visibility: hasProducts ? 'visible' : 'hidden'
}}>
{(this.state.itemsPerPage==='all')?null:
<Pagination
count={pages}
page={page}
@@ -150,6 +160,57 @@ class ProductList extends Component {
);
}
// Check if filters are active
hasActiveFilters = () => {
return (
(this.props.activeAttributeFilters && this.props.activeAttributeFilters.length > 0) ||
(this.props.activeManufacturerFilters && this.props.activeManufacturerFilters.length > 0) ||
(this.props.activeAvailabilityFilters && this.props.activeAvailabilityFilters.length > 0)
);
}
// Render message when no products found but filters are active
renderNoProductsMessage = () => {
const hasFiltersActive = this.hasActiveFilters();
const hasUnfilteredProducts = this.props.totalProductCount > 0;
if (this.props.products.length === 0 && hasUnfilteredProducts && hasFiltersActive) {
return (
<Box sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
py: 4,
px: 2
}}>
<Typography variant="h6" color="text.secondary" sx={{ textAlign: 'center' }}>
Entferne Filter um Produkte zu sehen
</Typography>
</Box>
);
}
return null;
}
// Helper function for correct pluralization
getProductCountText = () => {
const filteredCount = this.props.products.length;
const totalCount = this.props.totalProductCount;
const isFiltered = totalCount !== filteredCount;
if (!isFiltered) {
// No filters applied
if (filteredCount === 0) return "0 Produkte";
if (filteredCount === 1) return "1 Produkt";
return `${filteredCount} Produkte`;
} else {
// Filters applied
if (totalCount === 0) return "0 Produkte";
if (totalCount === 1) return `${filteredCount} von 1 Produkt`;
return `${filteredCount} von ${totalCount} Produkten`;
}
}
render() {
//console.log('products',this.props.activeAttributeFilters,this.props.activeManufacturerFilters,window.currentSearchQuery,this.state.sortBy);
@@ -164,15 +225,56 @@ class ProductList extends Component {
const products = this.state.itemsPerPage==='all'?[...filteredProducts]:filteredProducts.slice((this.state.page - 1) * this.state.itemsPerPage , this.state.page * this.state.itemsPerPage);
return (
<Box sx={{ height: '100%' }}>
<Box sx={{ height: '100%', px: { xs: 0, sm: 0 } }}>
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
alignItems: 'center',
flexDirection: { xs: 'column', sm: 'row' },
gap: { xs: 1, sm: 0 },
px: { xs: 0, sm: 0 },
py: { xs: 1, sm: 0 },
bgcolor: { xs: '#e8f5e8', sm: 'transparent' },
mb: { xs: 0, sm: 0 }
}}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Box sx={{
display: 'flex',
gap: { xs: 0.5, sm: 1 },
alignItems: 'center',
flexWrap: 'wrap',
order: { xs: 2, sm: 1 },
px: { xs: 1, sm: 0 }
}}>
{this.props.activeAvailabilityFilters && this.props.activeAvailabilityFilters.map((filter,index) => (
<Chip
size="medium"
key={`availability-${index}`}
label={filter.name}
onClick={() => {
if (filter.id === '1') {
// Add "auf Lager" filter by setting the sessionStorage item to '1'
sessionStorage.setItem('filter_availability', '1');
} else {
// Remove "Neu" or "Bald verfügbar" filters
removeSessionSetting(`filter_availability_${filter.id}`);
}
this.props.onFilterChange();
}}
onDelete={() => {
if (filter.id === '1') {
// Add "auf Lager" filter by setting the sessionStorage item to '1'
sessionStorage.setItem('filter_availability', '1');
} else {
// Remove "Neu" or "Bald verfügbar" filters
removeSessionSetting(`filter_availability_${filter.id}`);
}
this.props.onFilterChange();
}}
clickable
/>
))}
{this.props.activeAttributeFilters.map((filter,index) => (
<Chip
size="medium"
@@ -205,11 +307,26 @@ class ProductList extends Component {
clickable
/>
))}
</Box>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Box sx={{
display: 'flex',
gap: { xs: 1, sm: 2 },
alignItems: 'center',
order: { xs: 1, sm: 2 },
width: { xs: '100%', sm: 'auto' },
justifyContent: { xs: 'space-between', sm: 'flex-end' },
px: { xs: 1, sm: 0 }
}}>
{/* Sort Dropdown */}
<FormControl variant="outlined" size="small" sx={{ minWidth: 140 }}>
<FormControl
variant={window.innerWidth < 600 ? 'standard' : 'outlined'}
size="small"
sx={{
minWidth: { xs: 120, sm: 140 }
}}
>
<InputLabel id="sort-by-label">Sortierung</InputLabel>
<Select
size="small"
@@ -244,7 +361,13 @@ class ProductList extends Component {
</FormControl>
{/* Per Page Dropdown */}
<FormControl variant="outlined" size="small" sx={{ minWidth: 100 }}>
<FormControl
variant={window.innerWidth < 600 ? 'standard' : 'outlined'}
size="small"
sx={{
minWidth: { xs: 80, sm: 100 }
}}
>
<InputLabel id="products-per-page-label">pro Seite</InputLabel>
<Select
labelId="products-per-page-label"
@@ -278,39 +401,56 @@ class ProductList extends Component {
<MenuItem value="all">Alle</MenuItem>
</Select>
</FormControl>
{/* Product count info - mobile only */}
<Box sx={{
display: { xs: 'block', sm: 'none' },
ml: 1
}}>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>
{this.getProductCountText()}
</Typography>
</Box>
</Box>
</Box>
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
alignItems: 'center',
px: { xs: 0, sm: 0 },
py: { xs: 1, sm: 0 },
bgcolor: { xs: '#e8f5e8', sm: 'transparent' },
mt: { xs: 0, sm: 0 }
}}>
{ this.renderPagination(Math.ceil(filteredProducts.length / this.state.itemsPerPage), this.state.page) }
<Stack direction="row" spacing={2}>
<Box sx={{ px: { xs: 1, sm: 0 }, width: '100%' }}>
{ this.renderPagination(Math.ceil(filteredProducts.length / this.state.itemsPerPage), this.state.page) }
</Box>
<Stack direction="row" spacing={2} sx={{ display: { xs: 'none', sm: 'flex' }, px: { xs: 1, sm: 0 } }}>
<Typography variant="body2" color="text.secondary">
{/*this.props.dataType == 'category' && (<>Kategorie: {this.props.dataParam}</>)}*/}
{this.props.dataType == 'search' && (<>Suchergebnisse für: "{this.props.dataParam}"</>)}
</Typography>
<Typography variant="body2" color="text.secondary">
{
this.props.totalProductCount==this.props.products.length && this.props.totalProductCount>0 ?
`${this.props.totalProductCount} Produkte`
:
`${this.props.products.length} von ${this.props.totalProductCount} Produkte`
}
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
{this.getProductCountText()}
</Typography>
</Stack>
</Box>
<Grid container spacing={2}>
{products.map((product) => (
<Grid container spacing={{ xs: 0, sm: 2 }}>
{this.renderNoProductsMessage()}
{products.map((product, index) => (
<Grid
key={product.id}
key={product.id}
sx={{
display: 'flex',
justifyContent: { xs: 'stretch', sm: 'center' },
mb: 1
mb: { xs: 0, sm: 1 },
width: { xs: '100%', sm: 'auto' },
borderBottom: {
xs: index < products.length - 1 ? '16px solid #e8f5e8' : 'none',
sm: 'none'
}
}}
>
<Product
@@ -331,14 +471,25 @@ class ProductList extends Component {
versandklasse={product.versandklasse}
weight={product.weight}
socket={this.props.socket}
socketB={this.props.socketB}
pictureList={product.pictureList}
availableSupplier={product.availableSupplier}
/>
</Grid>
))}
</Grid>
{this.renderPagination(Math.ceil(filteredProducts.length / this.state.itemsPerPage), this.state.page)}
{/* Bottom pagination */}
<Box sx={{
px: { xs: 0, sm: 0 },
py: { xs: 1, sm: 1 },
bgcolor: { xs: '#e8f5e8', sm: 'transparent' },
mt: { xs: 0, sm: 2 }
}}>
<Box sx={{ px: { xs: 1, sm: 0 } }}>
{this.renderPagination(Math.ceil(filteredProducts.length / this.state.itemsPerPage), this.state.page)}
</Box>
</Box>
</Box>
);
}

View File

@@ -142,7 +142,7 @@ class ButtonGroup extends Component {
onClose={this.toggleCart}
disableScrollLock={true}
>
<Box sx={{ width: 420, p: 2 }}>
<Box sx={{ width: { xs: '100vw', sm: 420 }, p: { xs: 1, sm: 2 } }}>
<Box
sx={{
display: 'flex',

View File

@@ -3,8 +3,11 @@ import Box from "@mui/material/Box";
import Container from "@mui/material/Container";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import Collapse from "@mui/material/Collapse";
import { Link } from "react-router-dom";
import HomeIcon from "@mui/icons-material/Home";
import MenuIcon from "@mui/icons-material/Menu";
import CloseIcon from "@mui/icons-material/Close";
class CategoryList extends Component {
findCategoryById = (category, targetId) => {
@@ -54,6 +57,7 @@ class CategoryList extends Component {
level3Categories: [], // Children of active level 2 category
activePath: [], // Array of active category objects for each level
fetchedCategories: false,
mobileMenuOpen: false, // State for mobile collapsible menu
};
// Try to get cached data for SSR
@@ -124,11 +128,13 @@ class CategoryList extends Component {
componentDidUpdate(prevProps) {
// Handle socket connection changes
const wasConnected = prevProps.socket && prevProps.socket.connected;
const isNowConnected = this.props.socket && this.props.socket.connected;
if (!wasConnected && isNowConnected && !this.state.fetchedCategories) {
// Socket just connected and we haven't fetched categories yet
this.setState(
{
fetchedCategories: false,
@@ -158,7 +164,7 @@ class CategoryList extends Component {
}
if (this.state.fetchedCategories) {
//console.log('Categories already fetched, skipping');
console.log('Categories already fetched, skipping');
return;
}
@@ -222,7 +228,6 @@ class CategoryList extends Component {
//console.log('CategoryList: Fetching categories from socket');
socket.emit("categoryList", { categoryId: 209 }, (response) => {
if (response && response.categoryTree) {
//console.log('Category tree received:', response.categoryTree);
// Store in global cache with timestamp
try {
@@ -237,7 +242,6 @@ class CategoryList extends Component {
} catch (err) {
console.error("Error writing to cache:", err);
}
this.processCategoryTree(response.categoryTree);
} else {
try {
@@ -313,18 +317,32 @@ class CategoryList extends Component {
});
};
handleMobileMenuToggle = () => {
this.setState(prevState => ({
mobileMenuOpen: !prevState.mobileMenuOpen
}));
};
handleMobileCategoryClick = () => {
// Close the mobile menu when a category is selected
this.setState({
mobileMenuOpen: false
});
};
render() {
const { level1Categories, level2Categories, level3Categories, activePath } =
const { level1Categories, activePath, mobileMenuOpen } =
this.state;
const renderCategoryRow = (categories, level = 1) => (
const renderCategoryRow = (categories, level = 1, isMobile = false) => (
<Box
sx={{
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
flexWrap: "nowrap",
overflowX: "auto",
flexWrap: isMobile ? "wrap" : "nowrap",
overflowX: isMobile ? "visible" : "auto",
flexDirection: isMobile ? "column" : "row",
py: 0.5, // Add vertical padding to prevent border clipping
"&::-webkit-scrollbar": {
display: "none",
@@ -340,34 +358,76 @@ class CategoryList extends Component {
color="inherit"
size="small"
aria-label="Zur Startseite"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
fontWeight: "normal",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
mx: 0.5,
my: 0.25, // Add consistent vertical margin to account for borders
minWidth: "auto",
border: "2px solid transparent", // Always have border space
borderRadius: 1, // Always have border radius
mx: isMobile ? 0 : 0.5,
my: 0.25,
minWidth: isMobile ? "100%" : "auto",
borderRadius: 1,
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(this.props.activeCategoryId === null && {
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
transform: "translateY(-2px)",
bgcolor: "rgba(255,255,255,0.25)",
borderColor: "rgba(255,255,255,0.6)", // Change border color instead of adding border
fontWeight: "bold",
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
}),
"&:hover": {
opacity: 1,
bgcolor: "rgba(255,255,255,0.15)",
transform: "translateY(-1px)",
boxShadow: "0 2px 6px rgba(0,0,0,0.3)",
bgcolor: "#fff",
textShadow: "none",
"& .MuiSvgIcon-root": {
color: "#2e7d32 !important",
},
"& .bold-text": {
color: "#2e7d32 !important",
},
"& .thin-text": {
color: "transparent !important",
},
},
}}
>
<HomeIcon sx={{ fontSize: "1rem" }} />
<HomeIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: this.props.activeCategoryId === null ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
{/* Bold text (always rendered to set width) */}
<Box
className="bold-text"
sx={{
fontWeight: "bold",
color: this.props.activeCategoryId === null ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
>
Startseite
</Box>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: this.props.activeCategoryId === null ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
Startseite
</Box>
</Box>
)}
</Button>
)}
{this.state.fetchedCategories && categories.length > 0 ? (
@@ -385,39 +445,72 @@ class CategoryList extends Component {
to={`/Kategorie/${category.seoName}`}
color="inherit"
size="small"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
fontWeight: "normal",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
mx: 0.5,
my: 0.25, // Add consistent vertical margin to account for borders
border: "2px solid transparent", // Always have border space
borderRadius: 1, // Always have border radius
mx: isMobile ? 0 : 0.5,
my: 0.25,
minWidth: isMobile ? "100%" : "auto",
borderRadius: 1,
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(isActiveAtThisLevel && {
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
transform: "translateY(-2px)",
bgcolor: "rgba(255,255,255,0.25)",
borderColor: "rgba(255,255,255,0.6)", // Change border color instead of adding border
fontWeight: "bold",
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
}),
"&:hover": {
opacity: 1,
bgcolor: "rgba(255,255,255,0.15)",
transform: "translateY(-1px)",
boxShadow: "0 2px 6px rgba(0,0,0,0.3)",
bgcolor: "#fff",
textShadow: "none",
"& .bold-text": {
color: "#2e7d32 !important",
},
"& .thin-text": {
color: "transparent !important",
},
},
}}
>
{category.name}
<Box sx={{ position: "relative", display: "inline-block" }}>
{/* Bold text (always rendered to set width) */}
<Box
className="bold-text"
sx={{
fontWeight: "bold",
color: isActiveAtThisLevel ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
>
{category.name}
</Box>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: isActiveAtThisLevel ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{category.name}
</Box>
</Box>
</Button>
);
})}
</>
) : (
level === 1 && (
level === 1 && !isMobile && (
<Typography
variant="caption"
color="inherit"
@@ -447,30 +540,87 @@ class CategoryList extends Component {
return (
<Profiler id="CategoryList" onRender={onRenderCallback}>
{/* Desktop Menu - Hidden on xs, shown on sm and up */}
<Box
sx={{
width: "100%",
bgcolor: "primary.dark",
display: { xs: "none", md: "block" },
display: { xs: "none", sm: "block" },
}}
>
<Container maxWidth="lg" sx={{ px: 2 }}>
{/* Level 1 Categories Row - Always shown */}
{renderCategoryRow(level1Categories, 1)}
{renderCategoryRow(level1Categories, 1, false)}
{/* Level 2 Categories Row - Show when level 1 is selected */}
{/* DISABLED FOR NOW
{level2Categories.length > 0 && (
<Box sx={{ mt: 0.5 }}>
{renderCategoryRow(level2Categories, 2)}
{renderCategoryRow(level2Categories, 2, false)}
</Box>
)}
{/* Level 3 Categories Row - Show when level 2 is selected */}
{/* DISABLED FOR NOW
{level3Categories.length > 0 && (
<Box sx={{ mt: 0.5 }}>
{renderCategoryRow(level3Categories, 3)}
{renderCategoryRow(level3Categories, 3, false)}
</Box>
)}
*/}
</Container>
</Box>
{/* Mobile Menu - Shown only on xs screens */}
<Box
sx={{
width: "100%",
bgcolor: "primary.dark",
display: { xs: "block", sm: "none" },
}}
>
<Container maxWidth="lg" sx={{ px: 2 }}>
{/* Toggle Button */}
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
py: 1,
cursor: "pointer",
"&:hover": {
bgcolor: "rgba(255,255,255,0.1)"
}
}}
onClick={this.handleMobileMenuToggle}
role="button"
tabIndex={0}
aria-label={mobileMenuOpen ? "Kategorien schließen" : "Kategorien öffnen"}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.handleMobileMenuToggle();
}
}}
>
<Typography variant="subtitle2" color="inherit" sx={{
fontWeight: "bold",
textShadow: "0 1px 2px rgba(0,0,0,0.3)"
}}>
Kategorien
</Typography>
<Box sx={{ display: "flex", alignItems: "center" }}>
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
</Box>
</Box>
{/* Collapsible Menu Content */}
<Collapse in={mobileMenuOpen}>
<Box sx={{ pb: 2 }}>
{/* Level 1 Categories - Only level shown in mobile menu */}
{renderCategoryRow(level1Categories, 1, true)}
</Box>
</Collapse>
</Container>
</Box>
</Profiler>

View File

@@ -15,7 +15,7 @@ import SocketContext from "../../contexts/SocketContext.js";
const SearchBar = () => {
const navigate = useNavigate();
const location = useLocation();
const socket = React.useContext(SocketContext);
const context = React.useContext(SocketContext);
const searchParams = new URLSearchParams(location.search);
// State management
@@ -58,7 +58,7 @@ const SearchBar = () => {
// @note Autocomplete function using getSearchProducts Socket.io API - returns objects with name and seoName
const fetchAutocomplete = React.useCallback(
(query) => {
if (!socket || !query || query.length < 2) {
if (!context || !context.socket || !context.socket.connected || !query || query.length < 2) {
setSuggestions([]);
setShowSuggestions(false);
setLoadingSuggestions(false);
@@ -67,7 +67,7 @@ const SearchBar = () => {
setLoadingSuggestions(true);
socket.emit(
context.socket.emit(
"getSearchProducts",
{
query: query.trim(),
@@ -90,7 +90,7 @@ const SearchBar = () => {
}
);
},
[socket]
[context]
);
const handleSearchChange = (e) => {
@@ -219,7 +219,7 @@ const SearchBar = () => {
onSubmit={handleSearch}
sx={{
flexGrow: 1,
mx: { xs: 1, sm: 2, md: 4 },
mx: { xs: 0, sm: 2, md: 4 },
position: "relative",
}}
>

View File

@@ -51,6 +51,9 @@ class CartTab extends Component {
showStripePayment: false,
StripeComponent: null,
isLoadingStripe: false,
showMolliePayment: false,
MollieComponent: null,
isLoadingMollie: false,
showPaymentConfirmation: false,
orderCompleted: false,
originalCartItems: []
@@ -67,8 +70,8 @@ class CartTab extends Component {
// @note Add method to fetch and apply order template prefill data
fetchOrderTemplate = () => {
if (this.context && this.context.connected) {
this.context.emit('getOrderTemplate', (response) => {
if (this.context && this.context.socket && this.context.socket.connected) {
this.context.socket.emit('getOrderTemplate', (response) => {
if (response.success && response.orderTemplate) {
const template = response.orderTemplate;
@@ -116,7 +119,7 @@ class CartTab extends Component {
// Determine payment method - respect constraints
let prefillPaymentMethod = template.payment_method || "wire";
const paymentMethodMap = {
"credit_card": "stripe",
"credit_card": "mollie",//stripe
"bank_transfer": "wire",
"cash_on_delivery": "onDelivery",
"cash": "cash"
@@ -319,6 +322,27 @@ class CartTab extends Component {
}
};
loadMollieComponent = async () => {
this.setState({ isLoadingMollie: true });
try {
const { default: Mollie } = await import("../Mollie.js");
this.setState({
MollieComponent: Mollie,
showMolliePayment: true,
isCompletingOrder: false,
isLoadingMollie: false,
});
} catch (error) {
console.error("Failed to load Mollie component:", error);
this.setState({
isCompletingOrder: false,
isLoadingMollie: false,
completionError: "Failed to load payment component. Please try again.",
});
}
};
handleCompleteOrder = () => {
this.setState({ completionError: null }); // Clear previous errors
@@ -363,6 +387,25 @@ class CartTab extends Component {
this.orderService.createStripeIntent(totalAmount, this.loadStripeComponent);
return;
}
// Handle Mollie payment differently
if (paymentMethod === "mollie") {
// Store the cart items used for Mollie payment in sessionStorage for later reference
try {
sessionStorage.setItem('molliePaymentCart', JSON.stringify(cartItems));
} catch (error) {
console.error("Failed to store Mollie payment cart:", error);
}
// Calculate total amount for Mollie
const subtotal = cartItems.reduce(
(total, item) => total + item.price * item.quantity,
0
);
const totalAmount = Math.round((subtotal + deliveryCost) * 100); // Convert to cents
this.orderService.createMollieIntent(totalAmount, this.loadMollieComponent);
return;
}
// Handle regular orders
const orderData = {
@@ -398,6 +441,9 @@ class CartTab extends Component {
showStripePayment,
StripeComponent,
isLoadingStripe,
showMolliePayment,
MollieComponent,
isLoadingMollie,
showPaymentConfirmation,
orderCompleted,
} = this.state;
@@ -409,7 +455,7 @@ class CartTab extends Component {
const displayError = completionError || preSubmitError;
return (
<Box sx={{ p: 3 }}>
<Box sx={{ p: { xs: 1, sm: 3 } }}>
{/* Payment Confirmation */}
{showPaymentConfirmation && (
<PaymentConfirmationDialog
@@ -433,8 +479,8 @@ class CartTab extends Component {
{!showPaymentConfirmation && (
<CartDropdown
cartItems={cartItems}
socket={this.context}
showDetailedSummary={showStripePayment}
socket={this.context.socket}
showDetailedSummary={showStripePayment || showMolliePayment}
deliveryMethod={deliveryMethod}
deliveryCost={deliveryCost}
/>
@@ -442,7 +488,7 @@ class CartTab extends Component {
{cartItems.length > 0 && (
<Box sx={{ mt: 3 }}>
{isLoadingStripe ? (
{isLoadingStripe || isLoadingMollie ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body1">
Zahlungskomponente wird geladen...
@@ -468,9 +514,29 @@ class CartTab extends Component {
</Box>
<StripeComponent clientSecret={stripeClientSecret} />
</>
) : showMolliePayment && MollieComponent ? (
<>
<Box sx={{ mb: 2 }}>
<Button
variant="outlined"
onClick={() => this.setState({ showMolliePayment: false })}
sx={{
color: '#2e7d32',
borderColor: '#2e7d32',
'&:hover': {
backgroundColor: 'rgba(46, 125, 50, 0.04)',
borderColor: '#1b5e20'
}
}}
>
Zurück zur Bestellung
</Button>
</Box>
<MollieComponent />
</>
) : (
<CheckoutForm
paymentMethod={paymentMethod}
paymentMethod={paymentMethod}
invoiceAddress={invoiceAddress}
deliveryAddress={deliveryAddress}
useSameAddress={useSameAddress}
@@ -478,7 +544,7 @@ class CartTab extends Component {
addressFormErrors={addressFormErrors}
termsAccepted={termsAccepted}
note={note}
deliveryMethod={deliveryMethod}
deliveryMethod={deliveryMethod}
hasStecklinge={hasStecklinge}
isPickupOnly={isPickupOnly}
deliveryCost={deliveryCost}

View File

@@ -120,7 +120,7 @@ class OrderProcessingService {
// If socket is ready, process immediately
const context = this.getContext();
if (context && context.connected) {
if (context && context.socket && context.socket.connected) {
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
if (isAuthenticated) {
this.sendStripeOrder();
@@ -131,7 +131,7 @@ class OrderProcessingService {
// Wait for socket to be ready
this.socketHandler = () => {
const context = this.getContext();
if (context && context.connected) {
if (context && context.socket && context.socket.connected) {
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
const state = this.getState();
@@ -189,7 +189,7 @@ class OrderProcessingService {
// Emit stripe order to backend via socket.io
const context = this.getContext();
context.emit("issueStripeOrder", orderData, (response) => {
context.socket.emit("issueStripeOrder", orderData, (response) => {
if (response.success) {
this.setState({
isCompletingOrder: false,
@@ -208,8 +208,8 @@ class OrderProcessingService {
// Process regular (non-Stripe) orders
processRegularOrder(orderData) {
const context = this.getContext();
if (context) {
context.emit("issueOrder", orderData, (response) => {
if (context && context.socket && context.socket.connected) {
context.socket.emit("issueOrder", orderData, (response) => {
if (response.success) {
// Clear the cart
window.cart = [];
@@ -246,8 +246,8 @@ class OrderProcessingService {
// Create Stripe payment intent
createStripeIntent(totalAmount, loadStripeComponent) {
const context = this.getContext();
if (context) {
context.emit(
if (context && context.socket && context.socket.connected) {
context.socket.emit(
"createStripeIntent",
{ amount: totalAmount },
(response) => {
@@ -270,6 +270,10 @@ class OrderProcessingService {
});
}
}
// Create Mollie payment intent
createMollieIntent(totalAmount, loadMollieComponent) {
loadMollieComponent();
}
// Calculate delivery cost
getDeliveryCost() {

View File

@@ -68,7 +68,7 @@ const OrdersTab = ({ orderIdFromHash }) => {
const [selectedOrder, setSelectedOrder] = useState(null);
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
const socket = useContext(SocketContext);
const {socket} = useContext(SocketContext);
const navigate = useNavigate();
const handleViewDetails = useCallback(
@@ -139,7 +139,7 @@ const OrdersTab = ({ orderIdFromHash }) => {
if (loading) {
return (
<Box sx={{ p: 3, display: "flex", justifyContent: "center" }}>
<Box sx={{ p: { xs: 1, sm: 3 }, display: "flex", justifyContent: "center" }}>
<CircularProgress />
</Box>
);
@@ -147,14 +147,14 @@ const OrdersTab = ({ orderIdFromHash }) => {
if (error) {
return (
<Box sx={{ p: 3 }}>
<Box sx={{ p: { xs: 1, sm: 3 } }}>
<Alert severity="error">{error}</Alert>
</Box>
);
}
return (
<Box sx={{ p: 3 }}>
<Box sx={{ p: { xs: 1, sm: 3 } }}>
{orders.length > 0 ? (
<TableContainer component={Paper}>
<Table>

View File

@@ -24,7 +24,7 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
// Handle delivery method changes - auto-switch to stripe when DHL/DPD is selected
useEffect(() => {
if ((deliveryMethod === "DHL" || deliveryMethod === "DPD") && paymentMethod === "cash") {
handlePaymentMethodChange({ target: { value: "stripe" } });
handlePaymentMethodChange({ target: { value: "mollie" } });
}
}, [deliveryMethod, paymentMethod, handlePaymentMethodChange]);
@@ -42,8 +42,22 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
description: "Bezahlen Sie per Banküberweisung",
disabled: totalAmount === 0,
},
{
/*{
id: "stripe",
name: "Karte oder Sofortüberweisung (Stripe)",
description: totalAmount < 0.50 && totalAmount > 0
? "Bezahlen Sie per Karte oder Sofortüberweisung (Mindestbetrag: 0,50 €)"
: "Bezahlen Sie per Karte oder Sofortüberweisung",
disabled: totalAmount < 0.50 || (deliveryMethod !== "DHL" && deliveryMethod !== "DPD" && deliveryMethod !== "Abholung"),
icons: [
"/assets/images/giropay.png",
"/assets/images/maestro.png",
"/assets/images/mastercard.png",
"/assets/images/visa_electron.png",
],
},*/
{
id: "mollie",
name: "Karte oder Sofortüberweisung",
description: totalAmount < 0.50 && totalAmount > 0
? "Bezahlen Sie per Karte oder Sofortüberweisung (Mindestbetrag: 0,50 €)"

View File

@@ -235,8 +235,8 @@ class SettingsTab extends Component {
render() {
return (
<Box sx={{ p: 3 }}>
<Paper sx={{ p: 3}}>
<Box sx={{ p: { xs: 1, sm: 3 } }}>
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
Passwort ändern
</Typography>
@@ -289,7 +289,7 @@ class SettingsTab extends Component {
<Divider sx={{ my: 4 }} />
<Paper sx={{ p: 3 }}>
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
E-Mail-Adresse ändern
</Typography>
@@ -332,7 +332,7 @@ class SettingsTab extends Component {
<Divider sx={{ my: 4 }} />
<Paper sx={{ p: 3 }}>
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
API-Schlüssel
</Typography>

View File

@@ -1,24 +1,25 @@
const config = {
baseUrl: "https://seedheads.de",
baseUrl: "https://growheads.de",
apiBaseUrl: "",
googleClientId: "928121624463-jbgfdlgem22scs1k9c87ucg4ffvaik6o.apps.googleusercontent.com",
stripePublishableKey: "pk_test_51R7lltRtpe3h1vwJzIrDb5bcEigTLBHrtqj9SiPX7FOEATSuD6oJmKc8xpNp49ShpGJZb2GShHIUqj4zlSIz4olj00ipOuOAnu",
mollieProfileKey: "pfl_AtcRTimCff",
// SEO and Business Information
siteName: "SeedHeads.de",
brandName: "SeedHeads",
siteName: "Growheads.de",
brandName: "GrowHeads",
currency: "EUR",
language: "de-DE",
country: "DE",
// Shop Descriptions
descriptions: {
short: "SeedHeads - Online-Shop für Samen, Pflanzen und Gartenbedarf",
long: "SeedHeads - Ihr Online-Shop für hochwertige Samen, Pflanzen und Gartenbedarf. Entdecken Sie unser großes Sortiment an Saatgut, Pflanzen und Gartenzubehör für Ihren grünen Daumen."
short: "GrowHeads - Online-Shop für Cannanis-Samen, Stecklinge und Gartenbedarf",
long: "GrowHeads - Ihr Online-Shop für hochwertige Samen, Pflanzen und Gartenbedarf zur Cannabis Kultivierung. Entdecken Sie unser großes Sortiment an Saatgut, Pflanzen und Gartenzubehör für Ihren grünen Daumen."
},
// Keywords
keywords: "Samen, Pflanzen, Gartenbedarf, Saatgut, Online-Shop, SeedHeads, Garten, Pflanzen kaufen",
keywords: "Seeds, Steckling, Cannabis, Biobizz, Growheads",
// Shipping
shipping: {

View File

@@ -151,16 +151,16 @@ const Home = () => {
const [rootCategories, setRootCategories] = useState(() =>
initializeCategories()
);
const socket = useContext(SocketContext);
const context = useContext(SocketContext);
useEffect(() => {
// Only fetch from socket if we don't already have categories and we're in browser
if (
rootCategories.length === 0 &&
socket &&
context && context.socket && context.socket.connected &&
typeof window !== "undefined"
) {
socket.emit("categoryList", { categoryId: 209 }, (response) => {
context.socket.emit("categoryList", { categoryId: 209 }, (response) => {
if (response && response.categoryTree) {
// Store in cache
try {
@@ -176,7 +176,7 @@ const Home = () => {
}
});
}
}, [socket, rootCategories.length]);
}, [context, context?.socket?.connected, rootCategories.length]);
// Filter categories (excluding specific IDs)
const filteredCategories = rootCategories.filter(
@@ -407,7 +407,7 @@ const Home = () => {
color: "text.primary",
borderRadius: 2,
overflow: "hidden",
height: { xs: 250, sm: 300 },
height: { xs: 150, sm: 200, md: 300 },
display: "flex",
flexDirection: "column",
transition: "all 0.3s ease",
@@ -468,7 +468,7 @@ const Home = () => {
color: "text.primary",
borderRadius: 2,
overflow: "hidden",
height: { xs: 250, sm: 300 },
height: { xs: 150, sm: 200, md: 300 },
display: "flex",
flexDirection: "column",
boxShadow: 10,

34
src/pages/NotFound404.js Normal file
View File

@@ -0,0 +1,34 @@
import React from 'react';
import { Typography, Box } from '@mui/material';
import LegalPage from './LegalPage.js';
const NotFound404 = () => {
const content = (
<>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
mb: 3,
}}
>
<img
src="/assets/images/404.png"
alt="404 - Page Not Found"
style={{
maxWidth: '100%',
height: 'auto',
maxHeight: '300px',
}}
/>
</Box>
<Typography variant="body1" paragraph align="center">
This page is no longer available.
</Typography>
</>
);
return <LegalPage title="Page Not Found" content={content} />;
};
export default NotFound404;

View File

@@ -167,11 +167,16 @@ const ProfilePage = (props) => {
}
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<Paper elevation={2} sx={{ borderRadius: 2, overflow: 'hidden' }}>
<Box sx={{ bgcolor: '#2e7d32', p: 3, color: 'white' }}>
<Container maxWidth="md" sx={{ py: { xs: 0, sm: 4 }, px: { xs: 0, sm: 3 } }}>
<Paper elevation={{ xs: 0, sm: 2 }} sx={{ borderRadius: { xs: 0, sm: 2 }, overflow: 'hidden' }}>
<Box sx={{ bgcolor: '#2e7d32', p: { xs: 2, sm: 3 }, color: 'white' }}>
<Typography variant="h5" fontWeight="bold">
Mein Profil
{window.innerWidth < 600 ?
(tabValue === 0 ? 'Bestellabschluss' :
tabValue === 1 ? 'Bestellungen' :
tabValue === 2 ? 'Einstellungen' : 'Mein Profil')
: 'Mein Profil'
}
</Typography>
{user && (
<Typography variant="body1" sx={{ mt: 1 }}>
@@ -185,7 +190,11 @@ const ProfilePage = (props) => {
value={tabValue}
onChange={handleTabChange}
variant="fullWidth"
sx={{ borderBottom: 1, borderColor: 'divider' }}
sx={{
borderBottom: 1,
borderColor: 'divider',
display: { xs: 'none', sm: 'flex' }
}}
TabIndicatorProps={{
style: { backgroundColor: '#2e7d32' }
}}
@@ -225,8 +234,8 @@ const ProfilePage = (props) => {
// Wrap with socket context
const ProfilePageWithSocket = (props) => {
const socket = useContext(SocketContext);
return <ProfilePage {...props} socket={socket} />;
const {socket,socketB} = useContext(SocketContext);
return <ProfilePage {...props} socket={socket} socketB={socketB} />;
};
export default ProfilePageWithSocket;

View File

@@ -33,10 +33,43 @@ const collectAllCategories = (categoryNode, categories = [], level = 0) => {
return categories;
};
// Check for cached data - handle both browser and prerender environments
const getProductCache = () => {
if (typeof window !== "undefined" && window.productCache) {
return window.productCache;
}
if (
typeof global !== "undefined" &&
global.window &&
global.window.productCache
) {
return global.window.productCache;
}
return null;
};
// Initialize categories from cache if available (for prerendering)
const initializeCategories = () => {
const productCache = getProductCache();
if (productCache && productCache["categoryTree_209"]) {
const cached = productCache["categoryTree_209"];
const cacheAge = Date.now() - cached.timestamp;
const tenMinutes = 10 * 60 * 1000;
if (cacheAge < tenMinutes && cached.categoryTree) {
return collectAllCategories(cached.categoryTree);
}
}
return [];
};
const Sitemap = () => {
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const socket = useContext(SocketContext);
// Initialize categories and loading state together
const initialCategories = initializeCategories();
const [categories, setCategories] = useState(initialCategories);
const [loading, setLoading] = useState(initialCategories.length === 0);
const context = useContext(SocketContext);
const sitemapLinks = [
{ title: 'Startseite', url: '/' },
@@ -52,8 +85,14 @@ const Sitemap = () => {
useEffect(() => {
const fetchCategories = () => {
// Try cache first
if (window.productCache && window.productCache['categoryTree_209']) {
// If we already have categories from prerendering, we're done
if (categories.length > 0) {
setLoading(false);
return;
}
// Try cache first (for browser environment)
if (typeof window !== "undefined" && window.productCache && window.productCache['categoryTree_209']) {
const cached = window.productCache['categoryTree_209'];
const cacheAge = Date.now() - cached.timestamp;
const tenMinutes = 10 * 60 * 1000;
@@ -65,9 +104,9 @@ const Sitemap = () => {
}
}
// Otherwise, fetch from socket if available
if (socket) {
socket.emit('categoryList', { categoryId: 209 }, (response) => {
// Otherwise, fetch from socket if available (only in browser)
if (context && context.socket && context.socket.connected && typeof window !== "undefined") {
context.socket.emit('categoryList', { categoryId: 209 }, (response) => {
if (response && response.categoryTree) {
// Store in cache
try {
@@ -94,7 +133,7 @@ const Sitemap = () => {
};
fetchCategories();
}, [socket]);
}, [context, categories.length]);
const content = (
<>

View File

@@ -7,8 +7,10 @@ class SocketProvider extends Component {
constructor(props) {
super(props);
this.socket = null;
this.socketB = null;
this.state = {
connected: false,
connectedB: false,
showPrerenderFallback: true,
};
}
@@ -77,6 +79,66 @@ class SocketProvider extends Component {
console.error("SocketProvider: Failed to reconnect");
this.handleConnectionFailure();
});
this.socketB = io(url, {
transports: ["websocket"],
});
this.socketB.on("connect", () => {
console.log("SocketProvider: connectedB");
//this.setState({ connectedB: true });
});
this.socketB.on("disconnect", () => {
//this.setState({ connectedB: false });
console.log("SocketProvider: Socket disconnectedB");
});
this.socketB.on("connect_error", (error) => {
console.error("SocketProvider: Connection errorB:", error);
});
this.socketB.on("reconnect_attempt", (attemptNumber) => {
console.log(`SocketProvider: Reconnection attemptB ${attemptNumber}`);
});
this.socketB.on("reconnect_failed", () => {
console.error("SocketProvider: Failed to reconnectB");
});
this.socketB.waitForConnect = (timeout = 10000) => {
return new Promise((resolve, reject) => {
if (this.socketB.connected) {
resolve();
return;
}
let timeoutId;
const connectHandler = () => {
clearTimeout(timeoutId);
this.socketB.off("connect", connectHandler);
this.socketB.off("connect_error", errorHandler);
resolve();
};
const errorHandler = (error) => {
clearTimeout(timeoutId);
this.socketB.off("connect", connectHandler);
this.socketB.off("connect_error", errorHandler);
reject(new Error(`Socket connection failed: ${error.message}`));
};
// Set up timeout
timeoutId = setTimeout(() => {
this.socketB.off("connect", connectHandler);
this.socketB.off("connect_error", errorHandler);
reject(new Error(`Socket connection timeout after ${timeout}ms`));
}, timeout);
// Add event listeners
this.socketB.on("connect", connectHandler);
this.socketB.on("connect_error", errorHandler);
});
};
}
handleConnectionFailure() {
@@ -96,6 +158,10 @@ class SocketProvider extends Component {
console.log("SocketProvider: Disconnecting socket");
this.socket.disconnect();
}
if (this.socketB) {
console.log("SocketProvider: Disconnecting socketB");
this.socketB.disconnect();
}
}
render() {
@@ -104,7 +170,7 @@ class SocketProvider extends Component {
window.__PRERENDER_FALLBACK__;
return (
<SocketContext.Provider value={this.socket}>
<SocketContext.Provider value={{socket:this.socket,socketB:this.socketB}}>
{/* Always render children but control visibility */}
<div style={{ display: this.state.connected ? 'block' : 'none' }}>
{this.props.children}

View File

@@ -314,7 +314,11 @@ export default {
hot: true,
port: 9500,
open: false,
historyApiFallback: true,
historyApiFallback: {
index: '/index.html',
disableDotRule: true,
htmlAcceptHeaders: ['text/html', 'application/xhtml+xml']
},
client: {
logging: 'verbose',
overlay: {