Compare commits
200 Commits
8bc80c872d
...
live
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbd5df28f8 | ||
|
|
57515bfb85 | ||
|
|
9df5642a6e | ||
|
|
a50dd086c3 | ||
|
|
e88370ff3e | ||
|
|
5d3e0832fe | ||
|
|
3347ba2754 | ||
|
|
013a38ca98 | ||
|
|
2d6c8ff25f | ||
|
|
d2ac8d3fc1 | ||
|
|
8928b3f283 | ||
|
|
87db7ba3ea | ||
|
|
766fef2796 | ||
|
|
a08c90a521 | ||
|
|
10d60d5827 | ||
|
|
905eee57d5 | ||
|
|
3389a9b66c | ||
|
|
d63c385a97 | ||
|
|
1b51da69a9 | ||
|
|
da81479d9b | ||
|
|
d8678e261d | ||
|
|
ef91e50aa5 | ||
|
|
061bf5ff17 | ||
|
|
0b915db9eb | ||
|
|
43e67ee4c4 | ||
|
|
b599e6424b | ||
|
|
1ddbafaa51 | ||
|
|
e6faa63219 | ||
|
|
277edea15e | ||
|
|
b267b9132a | ||
|
|
c82a6a8f62 | ||
|
|
6b0ab27a3a | ||
|
|
289baec8cf | ||
|
|
11ba2db893 | ||
|
|
521cc307a3 | ||
|
|
d397930f2c | ||
|
|
8e43eaaede | ||
|
|
13c63db643 | ||
|
|
5b12dad435 | ||
|
|
f20628f71c | ||
|
|
f9437a79e6 | ||
|
|
f665e7c5f8 | ||
|
|
4f5a44dc7d | ||
|
|
bf2e5f56ce | ||
|
|
0c92591d32 | ||
|
|
8ea2e50432 | ||
|
|
8649408957 | ||
|
|
9e9d9ada4a | ||
|
|
2bb9a151a3 | ||
|
|
4ae9344b63 | ||
|
|
e00c226b9a | ||
|
|
cf12323dfa | ||
|
|
95177c8df7 | ||
|
|
65f29144a6 | ||
|
|
ded5fe330d | ||
|
|
1c9d3d5ad0 | ||
|
|
0e29ab2a61 | ||
|
|
f8f2658653 | ||
|
|
c82cd5ea78 | ||
|
|
f490f60cb7 | ||
|
|
a13c786b0b | ||
|
|
33ad3dd20b | ||
|
|
3f01ca12b4 | ||
|
|
71fb9bafcd | ||
|
|
8abaef8110 | ||
|
|
4e708d0a14 | ||
|
|
964a64a96a | ||
|
|
0dd1e01018 | ||
|
|
77ffe864b1 | ||
|
|
9d93ab8f2c | ||
|
|
09e015a529 | ||
|
|
8ec92ad718 | ||
|
|
bccaf703ef | ||
|
|
3bf80ce3d7 | ||
|
|
29a4bfc1c6 | ||
|
|
ea05a83901 | ||
|
|
12ed71b406 | ||
|
|
1ac253d5f3 | ||
|
|
cbb8dc463f | ||
|
|
479e328e7c | ||
|
|
3660f80277 | ||
|
|
8862f0c6b8 | ||
|
|
21ae00b3f7 | ||
|
|
ead44afb69 | ||
|
|
1a5143a55d | ||
|
|
3a97c2571e | ||
|
|
b3810fded7 | ||
|
|
64bf798843 | ||
|
|
6a144f7441 | ||
|
|
2ac9baada0 | ||
|
|
d40e311b51 | ||
|
|
7a8d07ffc3 | ||
|
|
09cd68c144 | ||
|
|
97fd7ee484 | ||
|
|
22a0f78db2 | ||
|
|
55d9f6a543 | ||
|
|
c1d2205e6c | ||
|
|
9f707737b4 | ||
|
|
0a7f7e653b | ||
|
|
42fa46f2f9 | ||
|
|
9b38ed6f2a | ||
|
|
b29b946aaf | ||
|
|
14787cbd74 | ||
|
|
afcdbb29c9 | ||
|
|
4584da1199 | ||
|
|
c1f2be99a7 | ||
|
|
7c78c6d85c | ||
|
|
c1810b18b3 | ||
|
|
3a8f31c109 | ||
|
|
02ed8c5f9d | ||
|
|
5662177175 | ||
|
|
b9e00ca134 | ||
|
|
b207377a8e | ||
|
|
2f753a81a4 | ||
|
|
1aabd3ef1e | ||
|
|
4879f68998 | ||
|
|
31c302493a | ||
|
|
934f6abc92 | ||
|
|
4dd1b2d227 | ||
|
|
f3e8395000 | ||
|
|
95f303bc68 | ||
|
|
226ca3e834 | ||
|
|
146daf8eb1 | ||
|
|
e472e6bb77 | ||
|
|
a2b7a2509f | ||
|
|
21ed40c4ce | ||
|
|
abf94eba86 | ||
|
|
cd4d124e22 | ||
|
|
b5a78b33cb | ||
|
|
bd4c0a50f1 | ||
|
|
23dbdec432 | ||
|
|
72010c410e | ||
|
|
5dc0280fc7 | ||
|
|
bfcc320e6d | ||
|
|
a653908624 | ||
|
|
1e8e6d7ac1 | ||
|
|
acdfc38b4a | ||
|
|
c906e0c936 | ||
|
|
cee69c9a31 | ||
|
|
1c777f8daa | ||
|
|
602324b1fe | ||
|
|
d16e979771 | ||
|
|
61faf654bc | ||
|
|
4e6b63a6a4 | ||
|
|
9982527f35 | ||
|
|
bde001c39b | ||
|
|
bbd1371eb2 | ||
|
|
d8f438c3f3 | ||
|
|
9c3a4ee91b | ||
|
|
bad176a6d1 | ||
|
|
d70fac24ed | ||
|
|
c4bd28ba92 | ||
|
|
24b762b9d6 | ||
|
|
464f159556 | ||
|
|
0a787f9d25 | ||
|
|
5202ff6e3e | ||
|
|
2b64719758 | ||
|
|
e0da7ed312 | ||
|
|
a68d912c99 | ||
|
|
d3998133e5 | ||
|
|
1fd6ed85b6 | ||
|
|
b2474a595c | ||
|
|
f748056568 | ||
|
|
5cff3e2c2a | ||
|
|
8629dc5d87 | ||
|
|
275ee3bea6 | ||
|
|
3d136775e2 | ||
|
|
92987a518b | ||
|
|
bffb1fed27 | ||
|
|
b8d8003ac3 | ||
|
|
19cf475b0e | ||
|
|
1fb92e2df9 | ||
|
|
bdd50770be | ||
|
|
ca98c304e5 | ||
|
|
543c8c4f30 | ||
|
|
bfd1803c6f | ||
|
|
ea488982a7 | ||
|
|
a21efab9d2 | ||
|
|
abe1bbfb67 | ||
|
|
195ff493b8 | ||
|
|
e80fedf9a9 | ||
|
|
b8441b3ceb | ||
|
|
3df20cbc6a | ||
|
|
cc679e77a9 | ||
|
|
5d14bef740 | ||
|
|
27de1c3406 | ||
|
|
8e6e020a1b | ||
|
|
055e49c957 | ||
|
|
5a3865aa3c | ||
|
|
fe93bfd7df | ||
|
|
3afce32e3d | ||
|
|
349b004627 | ||
|
|
f2ee641bfd | ||
|
|
2774c6924f | ||
|
|
f93cde5131 | ||
|
|
dfb4f3e189 | ||
|
|
5fb3e10598 | ||
|
|
b602444066 | ||
|
|
7eb70abb22 | ||
|
|
65ffc1542f |
5
.cursor/rules/devserver.mdc
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
alwaysApply: false
|
||||
---
|
||||
never run your own dev sever, it can be restarted with ```pm2 restart dev_seedheads_fron```
|
||||
get logoutput lioke this ```pm2 log dev_seedheads_fron --lines 20 --nostream```
|
||||
62
.gitignore
vendored
@@ -1,63 +1,3 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.cursor/
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
/public/index.prerender.html
|
||||
/public/assets/images/prod*.jpg
|
||||
/public/assets/images/cat*.jpg
|
||||
/public/prerender.css
|
||||
/public/Artikel/*
|
||||
/public/Kategorie/*
|
||||
/public/agb
|
||||
/public/batteriegesetzhinweise
|
||||
/public/datenschutz
|
||||
/public/impressum
|
||||
/public/sitemap
|
||||
/public/widerrufsrecht
|
||||
/public/robots.txt
|
||||
/public/sitemap.xml
|
||||
/public/index.prerender.html
|
||||
/public/Konfigurator
|
||||
/public/profile
|
||||
/public/404
|
||||
|
||||
/public/products.xml
|
||||
/public/llms*
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
.hintrc
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Local configuration
|
||||
src/config.local.js
|
||||
|
||||
taxonomy-with-ids.de-DE*
|
||||
|
||||
# Local development notes
|
||||
dev-notes.md
|
||||
dev-notes.local.md
|
||||
/logs
|
||||
47
.vscode/launch.json
vendored
@@ -16,7 +16,8 @@
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
]
|
||||
},{
|
||||
},
|
||||
{
|
||||
"name": "Start",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
@@ -28,6 +29,50 @@
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "Launch Chrome - Debug React App",
|
||||
"url": "https://dev.seedheads.de",
|
||||
"webRoot": "${workspaceFolder}/src",
|
||||
"sourceMapPathOverrides": {
|
||||
"webpack://reactshop/./src/*": "${webRoot}/*",
|
||||
"webpack://reactshop/src/*": "${webRoot}/*",
|
||||
"webpack:///src/*": "${webRoot}/*",
|
||||
"webpack:///./src/*": "${webRoot}/*",
|
||||
"webpack:///./*": "${workspaceFolder}/*",
|
||||
"webpack:///./~/*": "${workspaceFolder}/node_modules/*",
|
||||
"webpack://*": "${workspaceFolder}/*"
|
||||
},
|
||||
"smartStep": true,
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
"${workspaceFolder}/node_modules/**",
|
||||
"${workspaceFolder}/dist/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "attach",
|
||||
"name": "Attach to Chrome - Debug React App",
|
||||
"port": 9222,
|
||||
"webRoot": "${workspaceFolder}/src",
|
||||
"sourceMapPathOverrides": {
|
||||
"webpack://reactshop/./src/*": "${webRoot}/*",
|
||||
"webpack://reactshop/src/*": "${webRoot}/*",
|
||||
"webpack:///src/*": "${webRoot}/*",
|
||||
"webpack:///./src/*": "${webRoot}/*",
|
||||
"webpack:///./*": "${workspaceFolder}/*",
|
||||
"webpack:///./~/*": "${workspaceFolder}/node_modules/*",
|
||||
"webpack://*": "${workspaceFolder}/*"
|
||||
},
|
||||
"smartStep": true,
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
"${workspaceFolder}/node_modules/**",
|
||||
"${workspaceFolder}/dist/**"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
40
README.md
@@ -11,3 +11,43 @@ Entpacken & Doppelklick auf `start-dev-seedheads.bat` - das Skript wird:
|
||||
- Abhängigkeiten automatisch installieren falls nötig
|
||||
- Entwicklungsserver mit API-Proxy zu seedheads.de starten
|
||||
- Browser öffnen auf http://localhost:9500
|
||||
|
||||
## Socket Connection Optimization
|
||||
|
||||
The application uses Socket.IO for real-time communication with the server. To improve initial loading performance, sockets are now connected lazily:
|
||||
|
||||
- Sockets are created with `autoConnect: false` and only establish a connection when:
|
||||
- The first `emit` is called on the socket
|
||||
- An explicit connection is requested via the context methods
|
||||
|
||||
### Usage
|
||||
|
||||
```jsx
|
||||
// In a component
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import SocketContext from '../contexts/SocketContext';
|
||||
import { emitAsync } from '../utils/socketUtils';
|
||||
|
||||
const MyComponent = () => {
|
||||
const { socket, socketB } = useContext(SocketContext);
|
||||
|
||||
useEffect(() => {
|
||||
// The socket will automatically connect when emit is called
|
||||
socket.emit('someEvent', { data: 'example' });
|
||||
|
||||
// Or use the utility for Promise-based responses
|
||||
emitAsync(socket, 'getData', { id: 123 })
|
||||
.then(response => console.log(response))
|
||||
.catch(error => console.error(error));
|
||||
}, [socket]);
|
||||
|
||||
return <div>My Component</div>;
|
||||
};
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
- Reduced initial page load time
|
||||
- Connections established only when needed
|
||||
- Automatic fallback to polling if WebSocket fails
|
||||
- Promise-based utilities for easier async/await usage
|
||||
|
||||
154
docs/nginx.conf
Normal file
@@ -0,0 +1,154 @@
|
||||
server {
|
||||
client_max_body_size 64M;
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name example.de;
|
||||
ssl_certificate /etc/letsencrypt/live/example.de/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/example.de/privkey.pem;
|
||||
|
||||
gzip on;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_types
|
||||
text/css
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/x-javascript
|
||||
application/json
|
||||
application/xml
|
||||
image/svg+xml;
|
||||
|
||||
index index.html;
|
||||
root /example/dist;
|
||||
|
||||
error_log logs/error.log info;
|
||||
access_log logs/access.log combined;
|
||||
|
||||
location /socket.io/ {
|
||||
proxy_pass http://localhost:9303/socket.io/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_connect_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_read_timeout 3600s;
|
||||
send_timeout 3600s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
keepalive_timeout 65;
|
||||
keepalive_requests 100;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:9303/api/;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_set_header User-Agent $http_user_agent;
|
||||
proxy_set_header Content-Type $content_type;
|
||||
proxy_set_header Content-Length $content_length;
|
||||
|
||||
proxy_set_header X-API-Key $http_x_api_key;
|
||||
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_buffering off;
|
||||
|
||||
client_max_body_size 10M;
|
||||
}
|
||||
|
||||
location ^~ /Kategorie/ {
|
||||
types {}
|
||||
default_type text/html;
|
||||
}
|
||||
|
||||
location ^~ /Artikel/ {
|
||||
types {}
|
||||
default_type text/html;
|
||||
}
|
||||
|
||||
location = /sitemap.xml {
|
||||
types {}
|
||||
default_type application/xml;
|
||||
}
|
||||
|
||||
location ~ ^/(datenschutz|impressum|batteriegesetzhinweise|widerrufsrecht|sitemap|agb|Kategorien|Konfigurator|404|profile|resetPassword|thc-test|filiale|aktionen|presseverleih|payment/success)(/|$) {
|
||||
types {}
|
||||
default_type text/html;
|
||||
}
|
||||
|
||||
location = /404 {
|
||||
error_page 404 =404 /404-big.html;
|
||||
return 404;
|
||||
}
|
||||
|
||||
location = /404-big.html {
|
||||
internal;
|
||||
alias /home/seb/src/growheads_de/dist/404;
|
||||
default_type text/html;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
|
||||
location = /404.html {
|
||||
internal;
|
||||
default_type text/html;
|
||||
return 404 '<!doctype html><html><body>
|
||||
<script>
|
||||
if (!navigator.userAgent.includes("bot")) { location.href="/404"; }
|
||||
</script>
|
||||
</body></html>';
|
||||
}
|
||||
|
||||
location ~* \.(js|css)\?.*$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Vary Accept-Encoding;
|
||||
}
|
||||
|
||||
location ~* \.(js|css)$ {
|
||||
if ($uri ~ "\.[a-f0-9]{7,}\.(js|css)$") {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
break;
|
||||
}
|
||||
expires 1d;
|
||||
add_header Cache-Control "public";
|
||||
add_header Vary Accept-Encoding;
|
||||
}
|
||||
|
||||
location ~* \.(ttf|otf|woff|woff2|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
}
|
||||
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
add_header Vary Accept-Encoding;
|
||||
}
|
||||
|
||||
location = /prerender.css {
|
||||
expires 1w;
|
||||
add_header Cache-Control "public";
|
||||
add_header Vary Accept-Encoding;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
add_header Vary Accept-Encoding;
|
||||
}
|
||||
}
|
||||
380
generate-category-descriptions.js
Normal file
@@ -0,0 +1,380 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
// Configuration
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
const DIST_DIR = './dist';
|
||||
const OUTPUT_CSV = './category-descriptions.csv';
|
||||
|
||||
// Model configuration
|
||||
const MODEL = 'gpt-5.1';
|
||||
|
||||
// Initialize OpenAI client
|
||||
const openai = new OpenAI({
|
||||
apiKey: OPENAI_API_KEY,
|
||||
});
|
||||
|
||||
// System prompt for generating SEO descriptions
|
||||
const SEO_DESCRIPTION_PROMPT = `You are given a list of products from a specific category. Create a SEO-friendly description for that category that would be suitable for a product catalog page.
|
||||
|
||||
Requirements:
|
||||
- Write in German
|
||||
- Make it SEO-optimized with relevant keywords
|
||||
|
||||
The product list format is:
|
||||
First line: categoryName,categoryId
|
||||
Subsequent lines: articleNumber,price,productName,shortDescription
|
||||
|
||||
Generate a compelling category description based on this product data.`;
|
||||
|
||||
// Function to find all *-list.txt files in dist directory
|
||||
function findListFiles() {
|
||||
try {
|
||||
const files = fs.readdirSync(DIST_DIR);
|
||||
return files.filter(file => file.endsWith('-list.txt'));
|
||||
} catch (error) {
|
||||
console.error('Error reading dist directory:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Function to read a list file and extract category info
|
||||
function readListFile(filePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
if (lines.length < 1) {
|
||||
throw new Error('File is empty');
|
||||
}
|
||||
|
||||
// Parse first line: categoryName,categoryId,[subcategoryIds]
|
||||
const firstLine = lines[0];
|
||||
const parts = firstLine.split(',');
|
||||
|
||||
if (parts.length < 2) {
|
||||
throw new Error('Invalid first line format');
|
||||
}
|
||||
|
||||
const categoryName = parts[0].replace(/^"|"$/g, '');
|
||||
const categoryId = parts[1].replace(/^"|"$/g, '');
|
||||
|
||||
// Parse subcategory IDs from array notation [id1,id2,...]
|
||||
let subcategoryIds = [];
|
||||
if (parts.length >= 3) {
|
||||
const subcatString = parts.slice(2).join(','); // Handle case where array spans multiple comma-separated values
|
||||
const match = subcatString.match(/\[(.*?)\]/);
|
||||
if (match && match[1]) {
|
||||
subcategoryIds = match[1].split(',').map(id => id.trim()).filter(id => id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!categoryName || !categoryId) {
|
||||
throw new Error('Invalid first line format');
|
||||
}
|
||||
|
||||
return {
|
||||
categoryName: categoryName,
|
||||
categoryId: categoryId,
|
||||
subcategoryIds: subcategoryIds,
|
||||
content: content
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error reading ${filePath}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to build processing order based on dependencies
|
||||
function buildProcessingOrder(categories) {
|
||||
const categoryMap = new Map();
|
||||
const processed = new Set();
|
||||
const processingOrder = [];
|
||||
|
||||
// Create a map of categoryId -> category data
|
||||
categories.forEach(cat => {
|
||||
categoryMap.set(cat.categoryId, cat);
|
||||
});
|
||||
|
||||
// Function to check if all subcategories are processed
|
||||
function canProcess(category) {
|
||||
return category.subcategoryIds.every(subId => processed.has(subId));
|
||||
}
|
||||
|
||||
// Keep processing until all categories are done
|
||||
while (processingOrder.length < categories.length) {
|
||||
const beforeLength = processingOrder.length;
|
||||
|
||||
// Find categories that can be processed now
|
||||
for (const category of categories) {
|
||||
if (!processed.has(category.categoryId) && canProcess(category)) {
|
||||
processingOrder.push(category);
|
||||
processed.add(category.categoryId);
|
||||
}
|
||||
}
|
||||
|
||||
// If no progress was made, there might be a circular dependency or missing category
|
||||
if (processingOrder.length === beforeLength) {
|
||||
console.error('⚠️ Unable to resolve all category dependencies');
|
||||
// Add remaining categories anyway
|
||||
for (const category of categories) {
|
||||
if (!processed.has(category.categoryId)) {
|
||||
console.warn(` Adding ${category.categoryName} (${category.categoryId}) despite unresolved dependencies`);
|
||||
processingOrder.push(category);
|
||||
processed.add(category.categoryId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return processingOrder;
|
||||
}
|
||||
|
||||
// Function to generate SEO description using OpenAI
|
||||
async function generateSEODescription(productListContent, categoryName, categoryId, subcategoryDescriptions = []) {
|
||||
try {
|
||||
console.log(`🔄 Generating SEO description for category: ${categoryName} (ID: ${categoryId})`);
|
||||
|
||||
// Prepend subcategory information if present
|
||||
let fullContent = productListContent;
|
||||
if (subcategoryDescriptions.length > 0) {
|
||||
const subcatInfo = 'This category has the following subcategories:\n' +
|
||||
subcategoryDescriptions.map(sub => `- "${sub.name}": ${sub.description}`).join('\n') +
|
||||
'\n\n';
|
||||
fullContent = subcatInfo + productListContent;
|
||||
}
|
||||
|
||||
const response = await openai.responses.create({
|
||||
model: "gpt-5.1",
|
||||
input: [
|
||||
{
|
||||
"role": "developer",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": SEO_DESCRIPTION_PROMPT
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": fullContent
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
text: {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "descriptions",
|
||||
"strict": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"seo_description": {
|
||||
"type": "string",
|
||||
"description": "A concise description intended for SEO purposes. 155 characters"
|
||||
},
|
||||
"long_description": {
|
||||
"type": "string",
|
||||
"description": "A comprehensive description, 2-5 Sentences"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"seo_description",
|
||||
"long_description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"verbosity": "medium"
|
||||
},
|
||||
reasoning: {
|
||||
"effort": "none"
|
||||
}
|
||||
});
|
||||
|
||||
const description = response.output_text;
|
||||
console.log(`✅ Generated description for ${categoryName}`);
|
||||
return description;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error generating description for ${categoryName}:`, error.message);
|
||||
return `Error generating description: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to write CSV file
|
||||
function writeCSV(results) {
|
||||
try {
|
||||
const csvHeader = 'categoryId,listFileName,seoDescription\n';
|
||||
const csvRows = results.map(result =>
|
||||
`"${result.categoryId}","${result.listFileName}","${result.description.replace(/"/g, '""')}"`
|
||||
).join('\n');
|
||||
|
||||
const csvContent = csvHeader + csvRows;
|
||||
fs.writeFileSync(OUTPUT_CSV, csvContent, 'utf8');
|
||||
console.log(`✅ CSV file written: ${OUTPUT_CSV}`);
|
||||
console.log(`📊 Processed ${results.length} categories`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error writing CSV file:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution function
|
||||
async function main() {
|
||||
console.log('🚀 Starting category description generation...');
|
||||
|
||||
// Check if OpenAI API key is set
|
||||
if (!OPENAI_API_KEY) {
|
||||
console.error('❌ OPENAI_API_KEY environment variable is not set');
|
||||
console.log('Please set your OpenAI API key: export OPENAI_API_KEY="your-api-key-here"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if dist directory exists
|
||||
if (!fs.existsSync(DIST_DIR)) {
|
||||
console.error(`❌ Dist directory not found: ${DIST_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find all list files
|
||||
const listFiles = findListFiles();
|
||||
if (listFiles.length === 0) {
|
||||
console.log('⚠️ No *-list.txt files found in dist directory');
|
||||
console.log('💡 Make sure to run the prerender script first to generate the list files');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`📂 Found ${listFiles.length} list files to process`);
|
||||
|
||||
// Step 1: Read all list files and extract category information
|
||||
console.log('📖 Reading all category files...');
|
||||
const categories = [];
|
||||
const fileDataMap = new Map(); // Map categoryId -> fileData
|
||||
|
||||
for (const listFile of listFiles) {
|
||||
const filePath = path.join(DIST_DIR, listFile);
|
||||
const fileData = readListFile(filePath);
|
||||
|
||||
if (!fileData) {
|
||||
console.log(`⚠️ Skipping ${listFile} due to read error`);
|
||||
continue;
|
||||
}
|
||||
|
||||
categories.push({
|
||||
categoryId: fileData.categoryId,
|
||||
categoryName: fileData.categoryName,
|
||||
subcategoryIds: fileData.subcategoryIds,
|
||||
listFileName: listFile
|
||||
});
|
||||
|
||||
fileDataMap.set(fileData.categoryId, {
|
||||
...fileData,
|
||||
listFileName: listFile
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✅ Read ${categories.length} categories`);
|
||||
|
||||
// Step 2: Build processing order based on dependencies
|
||||
console.log('🔨 Building processing order based on category hierarchy...');
|
||||
const processingOrder = buildProcessingOrder(categories);
|
||||
|
||||
const leafCategories = processingOrder.filter(cat => cat.subcategoryIds.length === 0);
|
||||
const parentCategories = processingOrder.filter(cat => cat.subcategoryIds.length > 0);
|
||||
|
||||
console.log(` 📄 ${leafCategories.length} leaf categories (no subcategories)`);
|
||||
console.log(` 📁 ${parentCategories.length} parent categories (with subcategories)`);
|
||||
|
||||
// Step 3: Process categories in order
|
||||
const results = [];
|
||||
const generatedDescriptions = new Map(); // Map categoryId -> {seo_description, long_description}
|
||||
|
||||
for (const category of processingOrder) {
|
||||
const fileData = fileDataMap.get(category.categoryId);
|
||||
|
||||
if (!fileData) {
|
||||
console.log(`⚠️ Skipping ${category.categoryName} - no file data found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gather subcategory descriptions
|
||||
const subcategoryDescriptions = [];
|
||||
for (const subId of category.subcategoryIds) {
|
||||
const subDesc = generatedDescriptions.get(subId);
|
||||
const subCategory = categories.find(cat => cat.categoryId === subId);
|
||||
|
||||
if (subDesc && subCategory) {
|
||||
subcategoryDescriptions.push({
|
||||
name: subCategory.categoryName,
|
||||
description: subDesc.long_description || subDesc.seo_description
|
||||
});
|
||||
} else if (subCategory) {
|
||||
console.warn(` ⚠️ Subcategory ${subCategory.categoryName} (${subId}) not yet processed`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate SEO description
|
||||
const descriptionJSON = await generateSEODescription(
|
||||
fileData.content,
|
||||
fileData.categoryName,
|
||||
fileData.categoryId,
|
||||
subcategoryDescriptions
|
||||
);
|
||||
|
||||
// Parse the JSON response
|
||||
let parsedDescription;
|
||||
try {
|
||||
parsedDescription = JSON.parse(descriptionJSON);
|
||||
generatedDescriptions.set(category.categoryId, parsedDescription);
|
||||
} catch (error) {
|
||||
console.error(` ❌ Failed to parse JSON for ${category.categoryName}:`, error.message);
|
||||
parsedDescription = { seo_description: descriptionJSON, long_description: descriptionJSON };
|
||||
generatedDescriptions.set(category.categoryId, parsedDescription);
|
||||
}
|
||||
|
||||
// Store result
|
||||
results.push({
|
||||
categoryId: category.categoryId,
|
||||
listFileName: fileData.listFileName,
|
||||
description: parsedDescription.seo_description || descriptionJSON
|
||||
});
|
||||
|
||||
// Add delay to avoid rate limiting
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// Write CSV output
|
||||
if (results.length > 0) {
|
||||
writeCSV(results);
|
||||
console.log('🎉 Category description generation completed successfully!');
|
||||
} else {
|
||||
console.error('❌ No results to write - all files failed processing');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch(error => {
|
||||
console.error('❌ Script failed:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
findListFiles,
|
||||
readListFile,
|
||||
buildProcessingOrder,
|
||||
generateSEODescription,
|
||||
writeCSV
|
||||
};
|
||||
114
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"@mui/material": "^7.1.1",
|
||||
"@stripe/react-stripe-js": "^3.7.0",
|
||||
"@stripe/stripe-js": "^7.3.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"country-flag-icons": "^1.5.19",
|
||||
"html-react-parser": "^5.2.5",
|
||||
@@ -26,6 +27,7 @@
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.6.0",
|
||||
"react-router-dom": "^7.6.2",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sharp": "^0.34.2",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
@@ -58,7 +60,9 @@
|
||||
"webpack-bundle-analyzer": "^4.10.2",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.2",
|
||||
"webpack-node-externals": "^3.0.0"
|
||||
"webpack-node-externals": "^3.0.0",
|
||||
"xmldom": "^0.6.0",
|
||||
"xpath": "^0.0.34"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
@@ -4198,6 +4202,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/async-mutex": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
|
||||
"integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
@@ -4541,9 +4554,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001727",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
|
||||
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
|
||||
"version": "1.0.30001757",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
|
||||
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -5253,6 +5266,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/default-browser": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz",
|
||||
@@ -8874,7 +8896,6 @@
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9446,6 +9467,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-srcset": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
@@ -9654,7 +9681,6 @@
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -10543,6 +10569,60 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.17.0",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
|
||||
"integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^8.0.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^8.3.11"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
@@ -11215,7 +11295,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -11827,7 +11906,6 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"devOptional": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
@@ -12747,6 +12825,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xmldom": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.6.0.tgz",
|
||||
"integrity": "sha512-iAcin401y58LckRZ0TkI4k0VSM1Qg0KGSc3i8rU+xrxe19A/BN1zHyVSJY7uoutVlaTSzYyk/v5AmkewAP7jtg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
@@ -12755,6 +12843,16 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xpath": {
|
||||
"version": "0.0.34",
|
||||
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz",
|
||||
"integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz",
|
||||
|
||||
13
package.json
@@ -7,17 +7,20 @@
|
||||
"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:client": "node scripts/convert-images-to-avif.cjs && cross-env NODE_ENV=production webpack --progress --mode production && shx cp dist/index.html dist/index_template.html",
|
||||
"build": "npm run build:client",
|
||||
"analyze": "cross-env ANALYZE=true NODE_ENV=production webpack --progress --mode production",
|
||||
"lint": "eslint src/**/*.{js,jsx}",
|
||||
"prerender": "node prerender.cjs",
|
||||
"prerender:prod": "cross-env NODE_ENV=production node prerender.cjs",
|
||||
"prerender:product": "node prerender-single-product.cjs",
|
||||
"prerender:product:prod": "cross-env NODE_ENV=production node prerender-single-product.cjs",
|
||||
"build:prerender": "npm run build:client && npm run prerender:prod",
|
||||
"translate": "node translate-i18n.js",
|
||||
"translate:english": "node translate-i18n.js --only-english",
|
||||
"translate:skip-english": "node translate-i18n.js --skip-english",
|
||||
"translate:others": "node translate-i18n.js --skip-english"
|
||||
"translate:others": "node translate-i18n.js --skip-english",
|
||||
"validate:products": "node scripts/validate-products-xml.cjs"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -30,6 +33,7 @@
|
||||
"@mui/material": "^7.1.1",
|
||||
"@stripe/react-stripe-js": "^3.7.0",
|
||||
"@stripe/stripe-js": "^7.3.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"country-flag-icons": "^1.5.19",
|
||||
"html-react-parser": "^5.2.5",
|
||||
@@ -41,6 +45,7 @@
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.6.0",
|
||||
"react-router-dom": "^7.6.2",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sharp": "^0.34.2",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
@@ -73,6 +78,8 @@
|
||||
"webpack-bundle-analyzer": "^4.10.2",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.2",
|
||||
"webpack-node-externals": "^3.0.0"
|
||||
"webpack-node-externals": "^3.0.0",
|
||||
"xmldom": "^0.6.0",
|
||||
"xpath": "^0.0.34"
|
||||
}
|
||||
}
|
||||
|
||||
208
prerender-single-product.cjs
Normal file
@@ -0,0 +1,208 @@
|
||||
require("@babel/register")({
|
||||
presets: [
|
||||
["@babel/preset-env", { targets: { node: "current" } }],
|
||||
"@babel/preset-react",
|
||||
],
|
||||
extensions: [".js", ".jsx"],
|
||||
ignore: [/node_modules/],
|
||||
});
|
||||
|
||||
// Minimal globals for socket.io-client only - no JSDOM to avoid interference
|
||||
global.window = {}; // Minimal window object for productCache
|
||||
global.navigator = { userAgent: "node.js" };
|
||||
global.URL = require("url").URL;
|
||||
global.Blob = class MockBlob {
|
||||
constructor(data, options) {
|
||||
this.data = data;
|
||||
this.type = options?.type || "";
|
||||
}
|
||||
};
|
||||
|
||||
// Import modules
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const React = require("react");
|
||||
const io = require("socket.io-client");
|
||||
|
||||
// Initialize i18n for prerendering with German as default
|
||||
const i18n = require("i18next");
|
||||
const { initReactI18next } = require("react-i18next");
|
||||
|
||||
// Import translation (just German for testing)
|
||||
const translationDE = require("./src/i18n/locales/de/translation.js").default;
|
||||
|
||||
// Initialize i18n
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
de: { translation: translationDE }
|
||||
},
|
||||
lng: 'de',
|
||||
fallbackLng: 'de',
|
||||
debug: false,
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
},
|
||||
react: {
|
||||
useSuspense: false
|
||||
}
|
||||
});
|
||||
|
||||
// Make i18n available globally
|
||||
global.i18n = i18n;
|
||||
|
||||
// Import prerender modules
|
||||
const config = require("./prerender/config.cjs");
|
||||
const shopConfig = require("./src/config.js").default;
|
||||
const { renderPage } = require("./prerender/renderer.cjs");
|
||||
const { generateProductMetaTags, generateProductJsonLd } = require("./prerender/seo.cjs");
|
||||
const { fetchProductDetails, saveProductImages } = require("./prerender/data-fetching.cjs");
|
||||
|
||||
// Import product component
|
||||
const PrerenderProduct = require("./src/PrerenderProduct.js").default;
|
||||
|
||||
const renderSingleProduct = async (productSeoName) => {
|
||||
const socketUrl = "http://127.0.0.1:9303";
|
||||
console.log(`🔌 Connecting to socket at ${socketUrl}...`);
|
||||
|
||||
const socket = io(socketUrl, {
|
||||
path: "/socket.io/",
|
||||
transports: ["websocket"],
|
||||
reconnection: false,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.error("❌ Timeout: Could not connect to backend after 15 seconds");
|
||||
socket.disconnect();
|
||||
reject(new Error("Connection timeout"));
|
||||
}, 15000);
|
||||
|
||||
socket.on("connect", async () => {
|
||||
console.log(`✅ Socket connected. Fetching product: ${productSeoName}`);
|
||||
|
||||
try {
|
||||
// Fetch product details
|
||||
const productDetails = await fetchProductDetails(socket, productSeoName);
|
||||
console.log(`📦 Product found: ${productDetails.product.name}`);
|
||||
|
||||
// Save product image to static files
|
||||
if (productDetails.product) {
|
||||
console.log(`📷 Saving product image...`);
|
||||
await saveProductImages(socket, [productDetails.product], "Single Product", config.outputDir);
|
||||
}
|
||||
|
||||
// Set up minimal global cache (empty for single product test)
|
||||
global.window.productCache = {};
|
||||
global.productCache = {};
|
||||
|
||||
// Create product component
|
||||
const productComponent = React.createElement(PrerenderProduct, {
|
||||
productData: productDetails,
|
||||
t: global.i18n.t.bind(global.i18n),
|
||||
});
|
||||
|
||||
// Generate metadata
|
||||
const actualSeoName = productDetails.product.seoName || productSeoName;
|
||||
const filename = `Artikel/${actualSeoName}`;
|
||||
const location = `/Artikel/${actualSeoName}`;
|
||||
const description = `Product "${productDetails.product.name}" (seoName: ${productSeoName})`;
|
||||
|
||||
const metaTags = generateProductMetaTags({
|
||||
...productDetails.product,
|
||||
seoName: actualSeoName,
|
||||
}, shopConfig.baseUrl, shopConfig);
|
||||
|
||||
const jsonLdScript = generateProductJsonLd({
|
||||
...productDetails.product,
|
||||
seoName: actualSeoName,
|
||||
}, shopConfig.baseUrl, shopConfig);
|
||||
|
||||
const combinedMetaTags = metaTags + "\n" + jsonLdScript;
|
||||
|
||||
// Render the page
|
||||
console.log(`🎨 Rendering product page...`);
|
||||
const success = renderPage(
|
||||
productComponent,
|
||||
location,
|
||||
filename,
|
||||
description,
|
||||
combinedMetaTags,
|
||||
true, // needsRouter
|
||||
config,
|
||||
false, // suppressLogs
|
||||
productDetails // productData for cache
|
||||
);
|
||||
|
||||
if (success) {
|
||||
const outputPath = path.resolve(__dirname, config.outputDir, `${filename}.html`);
|
||||
console.log(`✅ Product page rendered successfully!`);
|
||||
console.log(`📄 Output file: ${outputPath}`);
|
||||
console.log(`🌐 Test URL: http://localhost:3000/Artikel/${actualSeoName}`);
|
||||
|
||||
// Show file size
|
||||
if (fs.existsSync(outputPath)) {
|
||||
const stats = fs.statSync(outputPath);
|
||||
console.log(`📊 File size: ${Math.round(stats.size / 1024)}KB`);
|
||||
}
|
||||
} else {
|
||||
console.log(`❌ Failed to render product page`);
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
socket.disconnect();
|
||||
resolve(success);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error fetching/rendering product: ${error.message}`);
|
||||
clearTimeout(timeout);
|
||||
socket.disconnect();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("connect_error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error("❌ Socket connection error:", err);
|
||||
console.log("💡 Make sure the backend server is running on http://127.0.0.1:9303");
|
||||
reject(err);
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error("❌ Socket error:", err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Get product seoName from command line arguments
|
||||
const productSeoName = process.argv[2];
|
||||
|
||||
if (!productSeoName) {
|
||||
console.log("❌ Usage: node prerender-single-product.cjs <product-seo-name>");
|
||||
console.log("📝 Example: node prerender-single-product.cjs led-grow-light-600w");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`🚀 Starting single product prerender test...`);
|
||||
console.log(`🎯 Product SEO name: ${productSeoName}`);
|
||||
console.log(`🔧 Mode: ${config.isProduction ? 'PRODUCTION' : 'DEVELOPMENT'}`);
|
||||
console.log(`📁 Output directory: ${config.outputDir}`);
|
||||
|
||||
renderSingleProduct(productSeoName)
|
||||
.then((success) => {
|
||||
if (success) {
|
||||
console.log(`\n🎉 Single product prerender completed successfully!`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(`\n💥 Single product prerender failed!`);
|
||||
process.exit(1);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`\n💥 Single product prerender failed:`, error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -19,6 +19,24 @@ global.Blob = class MockBlob {
|
||||
}
|
||||
};
|
||||
|
||||
class CategoryService {
|
||||
constructor() {
|
||||
this.get = this.get.bind(this);
|
||||
}
|
||||
|
||||
getSync(categoryId, language = "de") {
|
||||
const cacheKey = `${categoryId}_${language}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
async get(categoryId, language = "de") {
|
||||
const cacheKey = `${categoryId}_${language}`;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
global.window.categoryService = new CategoryService();
|
||||
|
||||
|
||||
// Import modules
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
@@ -103,7 +121,6 @@ const shopConfig = require("./src/config.js").default;
|
||||
const { renderPage } = require("./prerender/renderer.cjs");
|
||||
const {
|
||||
collectAllCategories,
|
||||
writeCombinedCssFile,
|
||||
} = require("./prerender/utils.cjs");
|
||||
const {
|
||||
generateProductMetaTags,
|
||||
@@ -119,6 +136,7 @@ const {
|
||||
generateLlmsTxt,
|
||||
generateCategoryLlmsTxt,
|
||||
generateAllCategoryLlmsPages,
|
||||
generateCategoryProductList,
|
||||
} = require("./prerender/seo.cjs");
|
||||
const {
|
||||
fetchCategoryProducts,
|
||||
@@ -141,6 +159,7 @@ const Batteriegesetzhinweise =
|
||||
const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default;
|
||||
const Sitemap = require("./src/pages/Sitemap.js").default;
|
||||
const PrerenderSitemap = require("./src/PrerenderSitemap.js").default;
|
||||
const PrerenderCategoriesPage = require("./src/PrerenderCategoriesPage.js").default;
|
||||
const AGB = require("./src/pages/AGB.js").default;
|
||||
const NotFound404 = require("./src/pages/NotFound404.js").default;
|
||||
|
||||
@@ -149,7 +168,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
||||
const socketUrl = "http://127.0.0.1:9303";
|
||||
const workerSocket = io(socketUrl, {
|
||||
path: "/socket.io/",
|
||||
transports: ["polling", "websocket"],
|
||||
transports: ["websocket"],
|
||||
reconnection: false,
|
||||
timeout: 10000,
|
||||
});
|
||||
@@ -202,7 +221,8 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
||||
combinedMetaTags,
|
||||
true,
|
||||
config,
|
||||
true // Suppress logs during parallel rendering to avoid interfering with progress bar
|
||||
true, // Suppress logs during parallel rendering to avoid interfering with progress bar
|
||||
productDetails // Pass product data for cache population
|
||||
);
|
||||
|
||||
if (success) {
|
||||
@@ -350,14 +370,14 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
|
||||
|
||||
const renderApp = async (categoryData, socket) => {
|
||||
if (categoryData) {
|
||||
global.window.productCache = {
|
||||
categoryTree_209: { categoryTree: categoryData, timestamp: Date.now() },
|
||||
global.window.categoryCache = {
|
||||
"209_de": categoryData,
|
||||
};
|
||||
// @note Make cache available to components during rendering
|
||||
global.productCache = global.window.productCache;
|
||||
global.categoryCache = global.window.categoryCache;
|
||||
} else {
|
||||
global.window.productCache = {};
|
||||
global.productCache = {};
|
||||
global.window.categoryCache = {};
|
||||
global.categoryCache = {};
|
||||
}
|
||||
|
||||
// Helper to call renderPage with config
|
||||
@@ -403,6 +423,14 @@ const renderApp = async (categoryData, socket) => {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Copy index.html to resetPassword (no file extension) for SPA routing
|
||||
if (config.isProduction) {
|
||||
const indexPath = path.resolve(__dirname, config.outputDir, "index.html");
|
||||
const resetPasswordPath = path.resolve(__dirname, config.outputDir, "resetPassword");
|
||||
fs.copyFileSync(indexPath, resetPasswordPath);
|
||||
console.log(`✅ Copied index.html to ${resetPasswordPath}`);
|
||||
}
|
||||
|
||||
// Render static pages
|
||||
console.log("\n📄 Rendering static pages...");
|
||||
|
||||
@@ -438,6 +466,13 @@ const renderApp = async (categoryData, socket) => {
|
||||
description: "Sitemap page",
|
||||
needsCategoryData: true,
|
||||
},
|
||||
{
|
||||
component: PrerenderCategoriesPage,
|
||||
path: "/Kategorien",
|
||||
filename: "Kategorien",
|
||||
description: "Categories page",
|
||||
needsCategoryData: true,
|
||||
},
|
||||
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
|
||||
{ component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" },
|
||||
{
|
||||
@@ -507,7 +542,14 @@ const renderApp = async (categoryData, socket) => {
|
||||
let categoryPagesRendered = 0;
|
||||
let categoriesWithProducts = 0;
|
||||
|
||||
for (const category of allCategories) {
|
||||
const allCategoriesPlusNeu = [...allCategories, {
|
||||
id: "neu",
|
||||
name: "Neuheiten",
|
||||
seoName: "neu",
|
||||
parentId: 209
|
||||
}];
|
||||
|
||||
for (const category of allCategoriesPlusNeu) {
|
||||
// Skip categories without seoName
|
||||
if (!category.seoName) {
|
||||
console.log(
|
||||
@@ -525,8 +567,7 @@ const renderApp = async (categoryData, socket) => {
|
||||
try {
|
||||
productData = await fetchCategoryProducts(socket, category.id);
|
||||
console.log(
|
||||
` ✅ Found ${
|
||||
productData.products ? productData.products.length : 0
|
||||
` ✅ Found ${productData.products ? productData.products.length : 0
|
||||
} products`
|
||||
);
|
||||
|
||||
@@ -641,8 +682,7 @@ const renderApp = async (categoryData, socket) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Write the combined CSS file after all pages are rendered
|
||||
writeCombinedCssFile(config.globalCssCollection, config.outputDir);
|
||||
// No longer writing combined CSS file - each page has its own embedded CSS
|
||||
|
||||
// Generate XML sitemap with all rendered pages
|
||||
console.log("\n🗺️ Generating XML sitemap...");
|
||||
@@ -699,6 +739,26 @@ const renderApp = async (categoryData, socket) => {
|
||||
console.log(` - File verification: ⚠️ ${verifyError.message}`);
|
||||
}
|
||||
|
||||
// Validate XML against Google Shopping schema
|
||||
try {
|
||||
const ProductsXmlValidator = require('./scripts/validate-products-xml.cjs');
|
||||
const validator = new ProductsXmlValidator(productsXmlPath);
|
||||
const validationResults = await validator.validate();
|
||||
|
||||
if (validationResults.valid) {
|
||||
console.log(` - Schema validation: ✅ Valid Google Shopping RSS 2.0`);
|
||||
} else {
|
||||
console.log(` - Schema validation: ⚠️ ${validationResults.summary.errorCount} errors, ${validationResults.summary.warningCount} warnings`);
|
||||
|
||||
// Show first few errors for quick debugging
|
||||
if (validationResults.errors.length > 0) {
|
||||
console.log(` - First error: ${validationResults.errors[0].message}`);
|
||||
}
|
||||
}
|
||||
} catch (validationError) {
|
||||
console.log(` - Schema validation: ⚠️ Validation failed: ${validationError.message}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error generating products.xml: ${error.message}`);
|
||||
console.log("\n⚠️ Skipping products.xml generation due to errors");
|
||||
@@ -751,10 +811,16 @@ const renderApp = async (categoryData, socket) => {
|
||||
totalPaginatedFiles++;
|
||||
}
|
||||
|
||||
// Generate and write the product list file for this category
|
||||
const productList = generateCategoryProductList(category, categoryProducts);
|
||||
const listPath = path.resolve(__dirname, config.outputDir, productList.fileName);
|
||||
fs.writeFileSync(listPath, productList.content, { encoding: 'utf8' });
|
||||
|
||||
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)`);
|
||||
console.log(` 📋 ${productList.fileName} - ${productList.productCount} products (${Math.round(productList.content.length / 1024)}KB)`);
|
||||
|
||||
categoryFilesGenerated++;
|
||||
totalCategoryProducts += categoryProducts.length;
|
||||
@@ -790,7 +856,7 @@ const fetchCategoryDataAndRender = () => {
|
||||
|
||||
const socket = io(socketUrl, {
|
||||
path: "/socket.io/",
|
||||
transports: ["polling", "websocket"], // Using polling first is more robust
|
||||
transports: ["websocket"],
|
||||
reconnection: false,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
@@ -50,10 +50,18 @@ const getWebpackEntrypoints = () => {
|
||||
return entrypoints;
|
||||
};
|
||||
|
||||
// Read global CSS styles and fix font paths for prerender
|
||||
let globalCss = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'index.css'), 'utf8');
|
||||
// Fix relative font paths for prerendered HTML (remove ../public to make them relative to public root)
|
||||
globalCss = globalCss.replace(/url\('\.\.\/public/g, "url('");
|
||||
// Read global CSS styles - use webpack processed CSS in production, raw CSS in development
|
||||
let globalCss = '';
|
||||
if (isProduction) {
|
||||
// In production, webpack has already processed fonts and inlined CSS
|
||||
// Don't read raw src/index.css as it has unprocessed font paths
|
||||
globalCss = ''; // CSS will be handled by webpack's inlined CSS
|
||||
} else {
|
||||
// In development, read raw CSS and fix font paths for prerender
|
||||
globalCss = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'index.css'), 'utf8');
|
||||
// Fix relative font paths for prerendered HTML (remove ../public to make them relative to public root)
|
||||
globalCss = globalCss.replace(/url\('\.\.\/public/g, "url('");
|
||||
}
|
||||
|
||||
// Global CSS collection
|
||||
const globalCssCollection = new Set();
|
||||
|
||||
@@ -37,9 +37,15 @@ const fetchCategoryProducts = (socket, categoryId) => {
|
||||
reject(new Error(`Timeout fetching products for category ${categoryId}`));
|
||||
}, 5000);
|
||||
|
||||
// Prerender system fetches German version by default
|
||||
socket.emit(
|
||||
"getCategoryProducts",
|
||||
{ full:true, categoryId: parseInt(categoryId) },
|
||||
{
|
||||
full: true,
|
||||
categoryId: categoryId === "neu" ? "neu" : parseInt(categoryId),
|
||||
language: 'de',
|
||||
requestTranslation: false
|
||||
},
|
||||
(response) => {
|
||||
clearTimeout(timeout);
|
||||
if (response && response.products !== undefined) {
|
||||
@@ -68,7 +74,13 @@ const fetchProductDetails = (socket, productSeoName) => {
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
socket.emit("getProductView", { seoName: productSeoName, nocount: true }, (response) => {
|
||||
// Prerender system fetches German version by default
|
||||
socket.emit("getProductView", {
|
||||
seoName: productSeoName,
|
||||
nocount: true,
|
||||
language: 'de',
|
||||
requestTranslation: false
|
||||
}, (response) => {
|
||||
clearTimeout(timeout);
|
||||
if (response && response.product) {
|
||||
response.product.seoName = productSeoName;
|
||||
@@ -140,7 +152,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
||||
"public",
|
||||
"assets",
|
||||
"images",
|
||||
"sh.png"
|
||||
"sh.avif"
|
||||
);
|
||||
|
||||
// Ensure assets/images directory exists
|
||||
@@ -173,7 +185,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
||||
if (imageIds.length > 0) {
|
||||
// Process first image for each product
|
||||
const bildId = parseInt(imageIds[0]);
|
||||
const estimatedFilename = `prod${bildId}.jpg`; // We'll generate a filename based on the ID
|
||||
const estimatedFilename = `prod${bildId}.avif`; // We'll generate a filename based on the ID
|
||||
|
||||
const imagePath = path.join(assetsPath, estimatedFilename);
|
||||
|
||||
@@ -187,7 +199,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
||||
const imageBuffer = await fetchProductImage(socket, bildId);
|
||||
|
||||
// If overlay exists, apply it to the image
|
||||
if (fs.existsSync(overlayPath)) {
|
||||
if (false && fs.existsSync(overlayPath)) {
|
||||
try {
|
||||
// Get image dimensions to center the overlay
|
||||
const baseImage = sharp(Buffer.from(imageBuffer));
|
||||
@@ -219,12 +231,12 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
||||
opacity: 0.3,
|
||||
},
|
||||
])
|
||||
.jpeg() // Ensure output is JPEG
|
||||
.avif() // Ensure output is AVIF
|
||||
.toBuffer();
|
||||
|
||||
fs.writeFileSync(imagePath, processedImageBuffer);
|
||||
console.log(
|
||||
` ✅ Applied centered inverted sh.png overlay to ${estimatedFilename}`
|
||||
` ✅ Applied centered inverted sh.avif overlay to ${estimatedFilename}`
|
||||
);
|
||||
} catch (overlayError) {
|
||||
console.log(
|
||||
@@ -269,7 +281,7 @@ const saveCategoryImages = async (socket, categories, outputDir) => {
|
||||
// Debug: Log categories that will be processed
|
||||
console.log(" 🔍 Categories to process:");
|
||||
categories.forEach((cat, index) => {
|
||||
console.log(` ${index + 1}. "${cat.name}" (ID: ${cat.id}) -> cat${cat.id}.jpg`);
|
||||
console.log(` ${index + 1}. "${cat.name}" (ID: ${cat.id}) -> cat${cat.id}.avif`);
|
||||
});
|
||||
|
||||
const assetsPath = path.resolve(
|
||||
@@ -296,7 +308,7 @@ const saveCategoryImages = async (socket, categories, outputDir) => {
|
||||
for (const category of categories) {
|
||||
categoriesProcessed++;
|
||||
|
||||
const estimatedFilename = `cat${category.id}.jpg`; // Use 'cat' prefix with category ID
|
||||
const estimatedFilename = `cat${category.id}.avif`; // Use 'cat' prefix with category ID
|
||||
const imagePath = path.join(assetsPath, estimatedFilename);
|
||||
|
||||
// Skip if image already exists
|
||||
|
||||
@@ -17,7 +17,8 @@ const renderPage = (
|
||||
metaTags = "",
|
||||
needsRouter = false,
|
||||
config,
|
||||
suppressLogs = false
|
||||
suppressLogs = false,
|
||||
productData = null
|
||||
) => {
|
||||
const {
|
||||
isProduction,
|
||||
@@ -26,7 +27,7 @@ const renderPage = (
|
||||
globalCssCollection,
|
||||
webpackEntrypoints,
|
||||
} = config;
|
||||
const { writeCombinedCssFile, optimizeCss } = require("./utils.cjs");
|
||||
const { optimizeCss } = require("./utils.cjs");
|
||||
|
||||
// @note Set prerender fallback flag in global environment for CategoryBox during SSR
|
||||
if (typeof global !== "undefined" && global.window) {
|
||||
@@ -51,26 +52,20 @@ const renderPage = (
|
||||
);
|
||||
|
||||
let renderedMarkup;
|
||||
let pageSpecificCss = ""; // Declare outside try block for broader scope
|
||||
|
||||
try {
|
||||
renderedMarkup = ReactDOMServer.renderToString(pageElement);
|
||||
const emotionChunks = extractCriticalToChunks(renderedMarkup);
|
||||
|
||||
// Collect CSS from this page
|
||||
// Collect CSS from this page for direct inlining (no global accumulation)
|
||||
if (emotionChunks.styles.length > 0) {
|
||||
const oldSize = globalCssCollection.size;
|
||||
|
||||
emotionChunks.styles.forEach((style) => {
|
||||
if (style.css) {
|
||||
globalCssCollection.add(style.css);
|
||||
pageSpecificCss += style.css + "\n";
|
||||
}
|
||||
});
|
||||
|
||||
// Check if new styles were added
|
||||
if (globalCssCollection.size > oldSize) {
|
||||
// Write CSS file immediately when new styles are added
|
||||
writeCombinedCssFile(globalCssCollection, outputDir);
|
||||
}
|
||||
if (!suppressLogs) console.log(` - CSS rules: ${emotionChunks.styles.length}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Rendering failed for ${filename}:`, error);
|
||||
@@ -126,26 +121,12 @@ const renderPage = (
|
||||
}
|
||||
});
|
||||
|
||||
// Read and inline prerender CSS to eliminate render-blocking request
|
||||
try {
|
||||
const prerenderCssPath = path.resolve(__dirname, "..", outputDir, "prerender.css");
|
||||
if (fs.existsSync(prerenderCssPath)) {
|
||||
const prerenderCssContent = fs.readFileSync(prerenderCssPath, "utf8");
|
||||
// Use advanced CSS optimization
|
||||
const optimizedPrerenderCss = optimizeCss(prerenderCssContent);
|
||||
inlinedCss += optimizedPrerenderCss;
|
||||
if (!suppressLogs) console.log(` ✅ Inlined prerender CSS (${Math.round(optimizedPrerenderCss.length / 1024)}KB)`);
|
||||
} else {
|
||||
// Fallback to external loading if prerender.css doesn't exist yet
|
||||
additionalTags += `<link rel="preload" href="/prerender.css" as="style" onload="this.onload=null;this.rel='stylesheet'">`;
|
||||
additionalTags += `<noscript><link rel="stylesheet" href="/prerender.css"></noscript>`;
|
||||
if (!suppressLogs) console.log(` ⚠️ prerender.css not found for inlining, using async loading`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback to external loading
|
||||
additionalTags += `<link rel="preload" href="/prerender.css" as="style" onload="this.onload=null;this.rel='stylesheet'">`;
|
||||
additionalTags += `<noscript><link rel="stylesheet" href="/prerender.css"></noscript>`;
|
||||
if (!suppressLogs) console.log(` ⚠️ Error reading prerender.css: ${error.message}, using async loading`);
|
||||
// Inline page-specific CSS directly (no shared prerender.css file)
|
||||
if (pageSpecificCss.trim()) {
|
||||
// Use advanced CSS optimization on page-specific CSS
|
||||
const optimizedPageCss = optimizeCss(pageSpecificCss);
|
||||
inlinedCss += optimizedPageCss;
|
||||
if (!suppressLogs) console.log(` ✅ Inlined page-specific CSS (${Math.round(optimizedPageCss.length / 1024)}KB)`);
|
||||
}
|
||||
|
||||
// Add JavaScript files
|
||||
@@ -182,23 +163,47 @@ const renderPage = (
|
||||
content: ${JSON.stringify(renderedMarkup)},
|
||||
timestamp: ${Date.now()}
|
||||
};
|
||||
|
||||
// DEBUG: Multiple alerts throughout the loading process
|
||||
// Debug alerts removed
|
||||
|
||||
|
||||
</script>
|
||||
`;
|
||||
|
||||
// @note Create script to populate window.productCache with ONLY the static category tree
|
||||
let productCacheScript = '';
|
||||
if (typeof global !== "undefined" && global.window && global.window.productCache) {
|
||||
if (typeof global !== "undefined" && global.window && global.window.categoryCache) {
|
||||
// Only include the static categoryTree_209, not any dynamic data that gets added during rendering
|
||||
const staticCache = {};
|
||||
if (global.window.productCache.categoryTree_209) {
|
||||
staticCache.categoryTree_209 = global.window.productCache.categoryTree_209;
|
||||
if (global.window.categoryCache["209_de"]) {
|
||||
staticCache["209_de"] = global.window.categoryCache["209_de"];
|
||||
}
|
||||
|
||||
const staticCacheData = JSON.stringify(staticCache);
|
||||
productCacheScript = `
|
||||
<script>
|
||||
// Populate window.productCache with static category tree only
|
||||
window.productCache = ${staticCacheData};
|
||||
// Populate window.categoryCache with static category tree only
|
||||
window.categoryCache = ${staticCacheData};
|
||||
</script>
|
||||
`;
|
||||
}
|
||||
|
||||
// Create script to populate window.productDetailCache for individual product pages
|
||||
let productDetailCacheScript = '';
|
||||
if (productData && productData.product) {
|
||||
// Cache the entire response object (includes product, attributes, etc.)
|
||||
// Use language-aware cache key (prerender defaults to German)
|
||||
const productDetailCacheData = JSON.stringify(productData);
|
||||
const language = 'de'; // Prerender system caches German version
|
||||
const cacheKey = `product_${productData.product.seoName}_${language}`;
|
||||
productDetailCacheScript = `
|
||||
<script>
|
||||
// Populate window.productDetailCache with complete product data for SPA hydration
|
||||
if (!window.productDetailCache) {
|
||||
window.productDetailCache = {};
|
||||
}
|
||||
window.productDetailCache['${cacheKey}'] = ${productDetailCacheData};
|
||||
</script>
|
||||
`;
|
||||
}
|
||||
@@ -214,7 +219,7 @@ const renderPage = (
|
||||
|
||||
template = template.replace(
|
||||
"</head>",
|
||||
`${resourceHints}${combinedCssTag}${additionalTags}${metaTags}${prerenderFallbackScript}${productCacheScript}</head>`
|
||||
`${resourceHints}${combinedCssTag}${additionalTags}${metaTags}${prerenderFallbackScript}${productCacheScript}${productDetailCacheScript}</head>`
|
||||
);
|
||||
|
||||
const rootDivRegex = /<div id="root"[\s\S]*?>[\s\S]*?<\/div>/;
|
||||
@@ -222,8 +227,10 @@ const renderPage = (
|
||||
|
||||
let newHtml;
|
||||
if (rootDivRegex.test(template)) {
|
||||
if (!suppressLogs) console.log(` 📝 Root div found, replacing with ${renderedMarkup.length} chars of markup`);
|
||||
newHtml = template.replace(rootDivRegex, replacementHtml);
|
||||
} else {
|
||||
if (!suppressLogs) console.log(` ⚠️ No root div found, appending to body`);
|
||||
newHtml = template.replace("<body>", `<body>${replacementHtml}`);
|
||||
}
|
||||
|
||||
@@ -240,10 +247,9 @@ const renderPage = (
|
||||
if (!suppressLogs) {
|
||||
console.log(`✅ ${description} prerendered to ${outputPath}`);
|
||||
console.log(` - Markup length: ${renderedMarkup.length} characters`);
|
||||
console.log(` - CSS rules: ${Object.keys(cache.inserted).length}`);
|
||||
console.log(` - Total inlined CSS: ${Math.round(combinedCss.length / 1024)}KB`);
|
||||
console.log(` - Render-blocking CSS eliminated: ${inlinedCss ? 'YES' : 'NO'}`);
|
||||
console.log(` - Fallback content saved to window.__PRERENDER_FALLBACK__`);
|
||||
if (productDetailCacheScript) {
|
||||
console.log(` - Product detail cache populated for SPA hydration`);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
// Category IDs to skip (seeds, plants, headshop items)
|
||||
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258];
|
||||
|
||||
// Check if category ID is in skip list
|
||||
if (category.id && skipCategoryIds.includes(parseInt(category.id))) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const categoryUrl = `${baseUrl}/Kategorie/${category.seoName}`;
|
||||
|
||||
// Calculate price valid date (current date + 3 months)
|
||||
const priceValidDate = new Date();
|
||||
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
|
||||
const priceValidUntil = priceValidDate.toISOString().split("T")[0];
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org/",
|
||||
"@type": "CollectionPage",
|
||||
@@ -42,7 +55,7 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
product.pictureList && product.pictureList.trim()
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||
.split(",")[0]
|
||||
.trim()}.jpg`
|
||||
.trim()}.avif`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`,
|
||||
description: product.description
|
||||
? product.description.replace(/<[^>]*>/g, "").substring(0, 200)
|
||||
@@ -57,6 +70,7 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
url: `${baseUrl}/Artikel/${product.seoName}`,
|
||||
price: product.price && !isNaN(product.price) ? product.price.toString() : "0.00",
|
||||
priceCurrency: config.currency,
|
||||
priceValidUntil: priceValidUntil,
|
||||
availability: product.available
|
||||
? "https://schema.org/InStock"
|
||||
: "https://schema.org/OutOfStock",
|
||||
@@ -65,6 +79,41 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
name: config.brandName,
|
||||
},
|
||||
itemCondition: "https://schema.org/NewCondition",
|
||||
hasMerchantReturnPolicy: {
|
||||
"@type": "MerchantReturnPolicy",
|
||||
applicableCountry: "DE",
|
||||
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
|
||||
merchantReturnDays: 14,
|
||||
returnMethod: "https://schema.org/ReturnByMail",
|
||||
returnFees: "https://schema.org/FreeReturn",
|
||||
},
|
||||
shippingDetails: {
|
||||
"@type": "OfferShippingDetails",
|
||||
shippingRate: {
|
||||
"@type": "MonetaryAmount",
|
||||
value: 5.90,
|
||||
currency: "EUR",
|
||||
},
|
||||
shippingDestination: {
|
||||
"@type": "DefinedRegion",
|
||||
addressCountry: "DE",
|
||||
},
|
||||
deliveryTime: {
|
||||
"@type": "ShippingDeliveryTime",
|
||||
handlingTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
transitTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 2,
|
||||
maxValue: 3,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -104,6 +104,9 @@ const determineUnitPricingData = (product) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
const currentDate = new Date().toISOString();
|
||||
|
||||
@@ -119,6 +122,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
689: "543561", // Seeds (Saatgut)
|
||||
706: "543561", // Stecklinge (cuttings) – ebenfalls Pflanzen/Saatgut
|
||||
376: "2802", // Grow-Sets – Pflanzen- & Kräuteranbausets
|
||||
915: "2802", // Grow-Sets > Set-Zubehör – Pflanzen- & Kräuteranbausets
|
||||
|
||||
// Headshop & Accessories
|
||||
709: "4082", // Headshop – Rauchzubehör
|
||||
@@ -126,8 +130,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
714: "4082", // Headshop > Bongs > Zubehör – Rauchzubehör
|
||||
748: "4082", // Headshop > Bongs > Köpfe – Rauchzubehör
|
||||
749: "4082", // Headshop > Bongs > Chillums/Diffusoren/Kupplungen – Rauchzubehör
|
||||
921: "4082", // Headshop > Pfeifen – Rauchzubehör
|
||||
924: "4082", // Headshop > Dabbing – Rauchzubehör
|
||||
896: "3151", // Headshop > Vaporizer – Vaporizer
|
||||
923: "4082", // Headshop > Papes & Blunts – Rauchzubehör
|
||||
710: "5109", // Headshop > Grinder – Gewürzmühlen (Küchenhelfer)
|
||||
922: "4082", // Headshop > Aktivkohlefilter & Tips – Rauchzubehör
|
||||
916: "4082", // Headshop > Rollen & Bauen – Rauchzubehör
|
||||
|
||||
// Measuring & Packaging
|
||||
186: "5631", // Headshop > Wiegen & Verpacken – Aufbewahrung/Zubehör
|
||||
@@ -137,6 +146,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
407: "3561", // Headshop > Grove Bags – Aufbewahrungsbehälter
|
||||
449: "1496", // Headshop > Cliptütchen – Lebensmittelverpackungsmaterial
|
||||
539: "3110", // Headshop > Gläser & Dosen – Lebensmittelbehälter
|
||||
920: "581", // Headshop > Räucherstäbchen – Raumdüfte (Home Fragrances)
|
||||
|
||||
// Lighting & Equipment
|
||||
694: "3006", // Lampen – Lampen (Beleuchtung)
|
||||
@@ -223,7 +233,6 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
387: "543541", // Zubehör > Anbauzubehör > Literatur – Bücher
|
||||
|
||||
// General categories
|
||||
705: "2802", // Grow-Sets > Set-Konfigurator – (ebenfalls Pflanzen-Anbausets)
|
||||
686: "1700", // Belüftung > Aktivkohlefilter > Zubehör – Ventilatoren
|
||||
741: "1700", // Belüftung > Ab- und Zuluft > Zubehör – Ventilatoren
|
||||
294: "3568", // Bewässerung > Zubehör – Bewässerungssysteme
|
||||
@@ -246,9 +255,9 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
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>
|
||||
<title>${config.descriptions.de.short}</title>
|
||||
<link>${baseUrl}</link>
|
||||
<description>${config.descriptions.short}</description>
|
||||
<description>${config.descriptions.de.short}</description>
|
||||
<lastBuildDate>${currentDate}</lastBuildDate>
|
||||
<language>de-DE</language>`;
|
||||
|
||||
@@ -297,15 +306,43 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
let processedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
// Track skip reasons with counts and product lists
|
||||
const skipReasons = {
|
||||
noProductOrSeoName: { count: 0, products: [] },
|
||||
excludedCategory: { count: 0, products: [] },
|
||||
excludedTermsTitle: { count: 0, products: [] },
|
||||
excludedTermsDescription: { count: 0, products: [] },
|
||||
missingGTIN: { count: 0, products: [] },
|
||||
invalidGTINChecksum: { count: 0, products: [] },
|
||||
missingPicture: { count: 0, products: [] },
|
||||
missingWeight: { count: 0, products: [] },
|
||||
insufficientDescription: { count: 0, products: [] },
|
||||
nameTooShort: { count: 0, products: [] },
|
||||
outOfStock: { count: 0, products: [] },
|
||||
zeroPriceOrInvalid: { count: 0, products: [] },
|
||||
processingError: { count: 0, products: [] }
|
||||
};
|
||||
|
||||
// Legacy arrays for backward compatibility
|
||||
const productsNeedingWeight = [];
|
||||
const productsNeedingDescription = [];
|
||||
|
||||
// Category IDs to skip (seeds, plants, headshop items)
|
||||
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710];
|
||||
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258];
|
||||
|
||||
// Add each product as an item
|
||||
allProductsData.forEach((product, index) => {
|
||||
|
||||
try {
|
||||
// Skip products without essential data
|
||||
if (!product || !product.seoName) {
|
||||
skippedCount++;
|
||||
skipReasons.noProductOrSeoName.count++;
|
||||
skipReasons.noProductOrSeoName.products.push({
|
||||
id: product?.articleNumber || 'N/A',
|
||||
name: product?.name || 'N/A',
|
||||
url: product?.seoName ? `/Artikel/${product.seoName}` : 'N/A'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -313,12 +350,21 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
const productCategoryId = product.categoryId || product.category_id || product.category || null;
|
||||
if (productCategoryId && skipCategoryIds.includes(parseInt(productCategoryId))) {
|
||||
skippedCount++;
|
||||
skipReasons.excludedCategory.count++;
|
||||
skipReasons.excludedCategory.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
categoryId: productCategoryId,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip products with excluded terms in title or description
|
||||
const productTitle = (product.name || "").toLowerCase();
|
||||
const productDescription = (product.description || "").toLowerCase();
|
||||
|
||||
// Get description early so we can check it for excluded terms
|
||||
const productDescription = product.kurzBeschreibung || product.description || '';
|
||||
|
||||
const excludedTerms = {
|
||||
title: ['canna', 'hash', 'marijuana', 'marihuana'],
|
||||
@@ -326,20 +372,42 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
};
|
||||
|
||||
// Check title for excluded terms
|
||||
if (excludedTerms.title.some(term => productTitle.includes(term))) {
|
||||
const excludedTitleTerm = excludedTerms.title.find(term => productTitle.includes(term));
|
||||
if (excludedTitleTerm) {
|
||||
skippedCount++;
|
||||
skipReasons.excludedTermsTitle.count++;
|
||||
skipReasons.excludedTermsTitle.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
term: excludedTitleTerm,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check description for excluded terms
|
||||
if (excludedTerms.description.some(term => productDescription.includes(term))) {
|
||||
const excludedDescTerm = excludedTerms.description.find(term => productDescription.toLowerCase().includes(term));
|
||||
if (excludedDescTerm) {
|
||||
skippedCount++;
|
||||
skipReasons.excludedTermsDescription.count++;
|
||||
skipReasons.excludedTermsDescription.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
term: excludedDescTerm,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip products without GTIN or with invalid GTIN
|
||||
if (!product.gtin || !product.gtin.toString().trim()) {
|
||||
skippedCount++;
|
||||
skipReasons.missingGTIN.count++;
|
||||
skipReasons.missingGTIN.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -354,37 +422,96 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
const length = digits.length;
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 0; i < length - 1; i++) {
|
||||
// Even/odd multiplier depends on GTIN length
|
||||
let multiplier = 1;
|
||||
if (length === 8) {
|
||||
multiplier = (i % 2 === 0) ? 3 : 1;
|
||||
} else {
|
||||
multiplier = ((length - i) % 2 === 0) ? 3 : 1;
|
||||
}
|
||||
// EAN-8: positions 0-6, check digit at 7
|
||||
// Multipliers: 3,1,3,1,3,1,3 for positions 0-6
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const multiplier = (i % 2 === 0) ? 3 : 1;
|
||||
sum += digits[i] * multiplier;
|
||||
}
|
||||
} else if (length === 12) {
|
||||
// UPC-A: positions 0-10, check digit at 11
|
||||
// Multipliers: 3,1,3,1,3,1,3,1,3,1,3 for positions 0-10
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const multiplier = (i % 2 === 0) ? 3 : 1;
|
||||
sum += digits[i] * multiplier;
|
||||
}
|
||||
} else if (length === 13) {
|
||||
// EAN-13: positions 0-11, check digit at 12
|
||||
// Multipliers: 1,3,1,3,1,3,1,3,1,3,1,3 for positions 0-11
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const multiplier = (i % 2 === 0) ? 1 : 3;
|
||||
sum += digits[i] * multiplier;
|
||||
}
|
||||
} else if (length === 14) {
|
||||
// EAN-14: similar to EAN-13 but 14 digits
|
||||
for (let i = 0; i < 13; i++) {
|
||||
const multiplier = (i % 2 === 0) ? 1 : 3;
|
||||
sum += digits[i] * multiplier;
|
||||
}
|
||||
}
|
||||
const checkDigit = (10 - (sum % 10)) % 10;
|
||||
return checkDigit === digits[length - 1];
|
||||
};
|
||||
|
||||
if (!isValidGTIN(gtinString)) {
|
||||
skippedCount++;
|
||||
skipReasons.invalidGTINChecksum.count++;
|
||||
skipReasons.invalidGTINChecksum.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
gtin: gtinString,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip products without pictures
|
||||
if (!product.pictureList || !product.pictureList.trim()) {
|
||||
skippedCount++;
|
||||
skipReasons.missingPicture.count++;
|
||||
skipReasons.missingPicture.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if product has weight data - validate BEFORE building XML
|
||||
if (!product.weight || isNaN(product.weight)) {
|
||||
// Track products without weight
|
||||
const productInfo = {
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'Unnamed',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
};
|
||||
productsNeedingWeight.push(productInfo);
|
||||
skipReasons.missingWeight.count++;
|
||||
skipReasons.missingWeight.products.push(productInfo);
|
||||
skippedCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if description is missing or too short (less than 20 characters) - skip if insufficient
|
||||
const originalDescription = productDescription ? cleanTextContent(productDescription) : '';
|
||||
if (!originalDescription || originalDescription.length < 20) {
|
||||
const productInfo = {
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'Unnamed',
|
||||
currentDescription: originalDescription || 'NONE',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
};
|
||||
productsNeedingDescription.push(productInfo);
|
||||
skipReasons.insufficientDescription.count++;
|
||||
skipReasons.insufficientDescription.products.push(productInfo);
|
||||
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";
|
||||
const feedDescription = cleanTextContent(productDescription).substring(0, 500);
|
||||
const cleanDescription = escapeXml(feedDescription) || "Produktbeschreibung nicht verfügbar";
|
||||
|
||||
// Clean product name
|
||||
const rawName = product.name || "Unnamed Product";
|
||||
@@ -393,6 +520,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
// Validate essential fields
|
||||
if (!cleanName || cleanName.length < 2) {
|
||||
skippedCount++;
|
||||
skipReasons.nameTooShort.count++;
|
||||
skipReasons.nameTooShort.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: rawName,
|
||||
cleanedName: cleanName,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -401,7 +535,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
|
||||
// Generate image URL
|
||||
const imageUrl = product.pictureList && product.pictureList.trim()
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.jpg`
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.avif`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
|
||||
// Generate brand (manufacturer)
|
||||
@@ -414,6 +548,18 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
// Generate availability
|
||||
const availability = product.available ? "in stock" : "out of stock";
|
||||
|
||||
// Skip products that are out of stock
|
||||
if (!product.available) {
|
||||
skippedCount++;
|
||||
skipReasons.outOfStock.count++;
|
||||
skipReasons.outOfStock.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate price (ensure it's a valid number)
|
||||
const price = product.price && !isNaN(product.price)
|
||||
? `${parseFloat(product.price).toFixed(2)} ${config.currency}`
|
||||
@@ -422,6 +568,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
// Skip products with price == 0
|
||||
if (!product.price || parseFloat(product.price) === 0) {
|
||||
skippedCount++;
|
||||
skipReasons.zeroPriceOrInvalid.count++;
|
||||
skipReasons.zeroPriceOrInvalid.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
price: product.price,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -437,7 +590,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
const googleCategory = getGoogleProductCategory(categoryId);
|
||||
const escapedGoogleCategory = escapeXml(googleCategory);
|
||||
|
||||
// Build item XML with proper formatting
|
||||
// Build item XML with proper formatting (all validation passed, safe to write XML)
|
||||
productsXml += `
|
||||
<item>
|
||||
<g:id>${productId}</g:id>
|
||||
@@ -463,11 +616,11 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
<g:gtin>${gtin}</g:gtin>`;
|
||||
}
|
||||
|
||||
// Add weight if available
|
||||
if (product.weight && !isNaN(product.weight)) {
|
||||
// Add weight (we know it exists at this point since we validated it earlier)
|
||||
// Convert from kg to grams (multiply by 1000)
|
||||
const weightInGrams = parseFloat(product.weight) * 1000;
|
||||
productsXml += `
|
||||
<g:shipping_weight>${parseFloat(product.weight).toFixed(2)} g</g:shipping_weight>`;
|
||||
}
|
||||
<g:shipping_weight>${weightInGrams.toFixed(2)} g</g:shipping_weight>`;
|
||||
|
||||
// Add unit pricing data (required by German law for many products)
|
||||
const unitPricingData = determineUnitPricingData(product);
|
||||
@@ -488,6 +641,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
} catch (itemError) {
|
||||
console.log(` ⚠️ Skipped product ${index + 1}: ${itemError.message}`);
|
||||
skippedCount++;
|
||||
skipReasons.processingError.count++;
|
||||
skipReasons.processingError.products.push({
|
||||
id: product?.articleNumber || product?.seoName || 'N/A',
|
||||
name: product?.name || 'N/A',
|
||||
error: itemError.message,
|
||||
url: product?.seoName ? `/Artikel/${product.seoName}` : 'N/A'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -495,7 +655,133 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
console.log(` 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
|
||||
console.log(`\n 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
|
||||
|
||||
// Display skip reason totals
|
||||
console.log(`\n 📋 Skip Reasons Breakdown:`);
|
||||
console.log(` ────────────────────────────────────────────────────────────`);
|
||||
|
||||
const skipReasonLabels = {
|
||||
noProductOrSeoName: 'No Product or SEO Name',
|
||||
excludedCategory: 'Excluded Category',
|
||||
excludedTermsTitle: 'Excluded Terms in Title',
|
||||
excludedTermsDescription: 'Excluded Terms in Description',
|
||||
missingGTIN: 'Missing GTIN',
|
||||
invalidGTINChecksum: 'Invalid GTIN Checksum',
|
||||
missingPicture: 'Missing Picture',
|
||||
missingWeight: 'Missing Weight',
|
||||
insufficientDescription: 'Insufficient Description',
|
||||
nameTooShort: 'Name Too Short',
|
||||
outOfStock: 'Out of Stock',
|
||||
zeroPriceOrInvalid: 'Zero or Invalid Price',
|
||||
processingError: 'Processing Error'
|
||||
};
|
||||
|
||||
let hasAnySkips = false;
|
||||
Object.entries(skipReasons).forEach(([key, data]) => {
|
||||
if (data.count > 0) {
|
||||
hasAnySkips = true;
|
||||
const label = skipReasonLabels[key] || key;
|
||||
console.log(` • ${label}: ${data.count}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasAnySkips) {
|
||||
console.log(` ✅ No products were skipped`);
|
||||
}
|
||||
|
||||
console.log(` ────────────────────────────────────────────────────────────`);
|
||||
console.log(` Total: ${skippedCount} products skipped\n`);
|
||||
|
||||
// Write log files for products needing attention
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const logsDir = path.join(process.cwd(), 'logs');
|
||||
|
||||
// Ensure logs directory exists
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write comprehensive skip reasons log
|
||||
const skipLogPath = path.join(logsDir, `skip-reasons-${timestamp}.log`);
|
||||
let skipLogContent = `# Product Skip Reasons Report
|
||||
# Generated: ${new Date().toISOString()}
|
||||
# Total products processed: ${processedCount}
|
||||
# Total products skipped: ${skippedCount}
|
||||
# Base URL: ${baseUrl}
|
||||
|
||||
`;
|
||||
|
||||
Object.entries(skipReasons).forEach(([key, data]) => {
|
||||
if (data.count > 0) {
|
||||
const label = skipReasonLabels[key] || key;
|
||||
skipLogContent += `\n## ${label} (${data.count} products)\n`;
|
||||
skipLogContent += `${'='.repeat(80)}\n`;
|
||||
|
||||
data.products.forEach(product => {
|
||||
skipLogContent += `ID: ${product.id}\n`;
|
||||
skipLogContent += `Name: ${product.name}\n`;
|
||||
if (product.categoryId !== undefined) {
|
||||
skipLogContent += `Category ID: ${product.categoryId}\n`;
|
||||
}
|
||||
if (product.term !== undefined) {
|
||||
skipLogContent += `Excluded Term: ${product.term}\n`;
|
||||
}
|
||||
if (product.gtin !== undefined) {
|
||||
skipLogContent += `GTIN: ${product.gtin}\n`;
|
||||
}
|
||||
if (product.currentDescription !== undefined) {
|
||||
skipLogContent += `Current Description: "${product.currentDescription}"\n`;
|
||||
}
|
||||
if (product.cleanedName !== undefined) {
|
||||
skipLogContent += `Cleaned Name: "${product.cleanedName}"\n`;
|
||||
}
|
||||
if (product.price !== undefined) {
|
||||
skipLogContent += `Price: ${product.price}\n`;
|
||||
}
|
||||
if (product.error !== undefined) {
|
||||
skipLogContent += `Error: ${product.error}\n`;
|
||||
}
|
||||
skipLogContent += `URL: ${baseUrl}${product.url}\n`;
|
||||
skipLogContent += `${'-'.repeat(80)}\n`;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(skipLogPath, skipLogContent, 'utf8');
|
||||
console.log(` 📄 Detailed skip reasons report saved to: ${skipLogPath}`);
|
||||
|
||||
// Write missing weight log (for backward compatibility)
|
||||
if (productsNeedingWeight.length > 0) {
|
||||
const weightLogContent = `# Products Missing Weight Data
|
||||
# Generated: ${new Date().toISOString()}
|
||||
# Total products missing weight: ${productsNeedingWeight.length}
|
||||
|
||||
${productsNeedingWeight.map(product => `${product.id}\t${product.name}\t${baseUrl}${product.url}`).join('\n')}
|
||||
`;
|
||||
|
||||
const weightLogPath = path.join(logsDir, `missing-weight-${timestamp}.log`);
|
||||
fs.writeFileSync(weightLogPath, weightLogContent, 'utf8');
|
||||
console.log(` ⚠️ Products missing weight (${productsNeedingWeight.length}) - saved to: ${weightLogPath}`);
|
||||
}
|
||||
|
||||
// Write missing description log (for backward compatibility)
|
||||
if (productsNeedingDescription.length > 0) {
|
||||
const descLogContent = `# Products With Insufficient Description Data
|
||||
# Generated: ${new Date().toISOString()}
|
||||
# Total products needing description: ${productsNeedingDescription.length}
|
||||
|
||||
${productsNeedingDescription.map(product => `${product.id}\t${product.name}\t"${product.currentDescription}"\t${baseUrl}${product.url}`).join('\n')}
|
||||
`;
|
||||
|
||||
const descLogPath = path.join(logsDir, `missing-description-${timestamp}.log`);
|
||||
fs.writeFileSync(descLogPath, descLogContent, 'utf8');
|
||||
console.log(` ⚠️ Products with insufficient description (${productsNeedingDescription.length}) - saved to: ${descLogPath}`);
|
||||
}
|
||||
|
||||
if (productsNeedingWeight.length === 0 && productsNeedingDescription.length === 0) {
|
||||
console.log(` ✅ All products have adequate weight and description data`);
|
||||
}
|
||||
|
||||
return productsXml;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const generateHomepageMetaTags = (baseUrl, config) => {
|
||||
const description = config.descriptions.long;
|
||||
const keywords = config.keywords;
|
||||
const description = config.descriptions.de.long;
|
||||
const keywords = config.keywords.de;
|
||||
const imageUrl = `${baseUrl}${config.images.logo}`;
|
||||
|
||||
// Ensure URLs are properly formatted
|
||||
@@ -12,7 +12,7 @@ const generateHomepageMetaTags = (baseUrl, config) => {
|
||||
<meta name="keywords" content="${keywords}">
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="${config.descriptions.short}">
|
||||
<meta property="og:title" content="${config.descriptions.de.short}">
|
||||
<meta property="og:description" content="${description}">
|
||||
<meta property="og:image" content="${imageUrl}">
|
||||
<meta property="og:url" content="${canonicalUrl}">
|
||||
@@ -21,7 +21,7 @@ const generateHomepageMetaTags = (baseUrl, config) => {
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="${config.descriptions.short}">
|
||||
<meta name="twitter:title" content="${config.descriptions.de.short}">
|
||||
<meta name="twitter:description" content="${description}">
|
||||
<meta name="twitter:image" content="${imageUrl}">
|
||||
|
||||
@@ -41,7 +41,7 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
|
||||
"@type": "WebSite",
|
||||
name: config.brandName,
|
||||
url: canonicalUrl,
|
||||
description: config.descriptions.long,
|
||||
description: config.descriptions.de.long,
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: config.brandName,
|
||||
@@ -73,7 +73,7 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
|
||||
"@type": "LocalBusiness",
|
||||
"name": config.brandName,
|
||||
"alternateName": config.siteName,
|
||||
"description": config.descriptions.long,
|
||||
"description": config.descriptions.de.long,
|
||||
"url": canonicalUrl,
|
||||
"logo": logoUrl,
|
||||
"image": logoUrl,
|
||||
|
||||
@@ -31,6 +31,7 @@ const {
|
||||
generateLlmsTxt,
|
||||
generateCategoryLlmsTxt,
|
||||
generateAllCategoryLlmsPages,
|
||||
generateCategoryProductList,
|
||||
} = require('./llms.cjs');
|
||||
|
||||
// Export all functions for use in the main application
|
||||
@@ -61,4 +62,5 @@ module.exports = {
|
||||
generateLlmsTxt,
|
||||
generateCategoryLlmsTxt,
|
||||
generateAllCategoryLlmsPages,
|
||||
generateCategoryProductList,
|
||||
};
|
||||
@@ -158,7 +158,7 @@ This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in
|
||||
`;
|
||||
|
||||
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 += `- **Page ${i}**: ${baseUrl}/llms-${categorySlug}-page-${i}.txt (Products ${((i - 1) * productsPerPage) + 1}-${Math.min(i * productsPerPage, totalProducts)})
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -183,7 +183,13 @@ This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in
|
||||
categoryLlmsTxt += `## ${globalIndex}. ${product.name}
|
||||
|
||||
- **Product URL**: ${baseUrl}/Artikel/${product.seoName}
|
||||
- **Article Number**: ${product.articleNumber || 'N/A'}
|
||||
`;
|
||||
|
||||
if (product.kurzBeschreibung) {
|
||||
categoryLlmsTxt += `- **Desc:** ${product.kurzBeschreibung}\n`;
|
||||
}
|
||||
|
||||
categoryLlmsTxt += `- **Article Number**: ${product.articleNumber || 'N/A'}
|
||||
- **Price**: €${product.price || '0.00'}
|
||||
- **Brand**: ${product.manufacturer || config.brandName}
|
||||
- **Availability**: ${product.available ? 'In Stock' : 'Out of Stock'}`;
|
||||
@@ -248,6 +254,41 @@ This category currently contains no products.
|
||||
return categoryLlmsTxt;
|
||||
};
|
||||
|
||||
// Helper function to generate a simple product list for a category
|
||||
const generateCategoryProductList = (category, categoryProducts = []) => {
|
||||
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
const fileName = `llms-${categorySlug}-list.txt`;
|
||||
|
||||
const subcategoryIds = (category.subcategories || []).join(',');
|
||||
let content = '';//`${String(category.name)},${String(category.id)},[${subcategoryIds}]\n`;
|
||||
|
||||
categoryProducts.forEach((product) => {
|
||||
const artnr = String(product.articleNumber || '');
|
||||
const price = String(product.price || '0.00');
|
||||
const name = String(product.name || '');
|
||||
const kurzBeschreibung = String(product.kurzBeschreibung || '');
|
||||
|
||||
// Escape commas in fields by wrapping in quotes if they contain commas
|
||||
const escapeField = (field) => {
|
||||
const fieldStr = String(field || '');
|
||||
if (fieldStr.includes(',')) {
|
||||
return `"${fieldStr.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return fieldStr;
|
||||
};
|
||||
|
||||
content += `${escapeField(artnr)},${escapeField(price)},${escapeField(name)},${escapeField(kurzBeschreibung)}\n`;
|
||||
});
|
||||
|
||||
return {
|
||||
fileName,
|
||||
content,
|
||||
categoryName: category.name,
|
||||
categoryId: category.id,
|
||||
productCount: categoryProducts.length
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to generate all pages for a category
|
||||
const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl, config, productsPerPage = 50) => {
|
||||
const totalProducts = categoryProducts.length;
|
||||
@@ -274,4 +315,5 @@ module.exports = {
|
||||
generateLlmsTxt,
|
||||
generateCategoryLlmsTxt,
|
||||
generateAllCategoryLlmsPages,
|
||||
generateCategoryProductList,
|
||||
};
|
||||
@@ -1,14 +1,21 @@
|
||||
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`
|
||||
.trim()}.avif`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
|
||||
|
||||
// Clean description for meta (remove HTML tags and limit length)
|
||||
const cleanDescription = product.description
|
||||
const cleanDescription = product.kurzBeschreibung
|
||||
? product.kurzBeschreibung
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\n/g, " ")
|
||||
.substring(0, 160)
|
||||
: product.description
|
||||
? product.description
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\n/g, " ")
|
||||
@@ -47,6 +54,11 @@ const generateProductMetaTags = (product, baseUrl, config) => {
|
||||
<!-- Additional Meta Tags -->
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="${productUrl}">
|
||||
|
||||
<!-- Store image URL in window object -->
|
||||
<script>
|
||||
window.productImageUrl = "${imageUrl}";
|
||||
</script>
|
||||
`;
|
||||
};
|
||||
|
||||
@@ -56,7 +68,7 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
|
||||
product.pictureList && product.pictureList.trim()
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||
.split(",")[0]
|
||||
.trim()}.jpg`
|
||||
.trim()}.avif`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
|
||||
// Clean description for JSON-LD (remove HTML tags)
|
||||
@@ -94,6 +106,41 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
|
||||
"@type": "Organization",
|
||||
name: config.brandName,
|
||||
},
|
||||
hasMerchantReturnPolicy: {
|
||||
"@type": "MerchantReturnPolicy",
|
||||
applicableCountry: "DE",
|
||||
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
|
||||
merchantReturnDays: 14,
|
||||
returnMethod: "https://schema.org/ReturnByMail",
|
||||
returnFees: "https://schema.org/FreeReturn",
|
||||
},
|
||||
shippingDetails: {
|
||||
"@type": "OfferShippingDetails",
|
||||
shippingRate: {
|
||||
"@type": "MonetaryAmount",
|
||||
value: 5.90,
|
||||
currency: "EUR",
|
||||
},
|
||||
shippingDestination: {
|
||||
"@type": "DefinedRegion",
|
||||
addressCountry: "DE",
|
||||
},
|
||||
deliveryTime: {
|
||||
"@type": "ShippingDeliveryTime",
|
||||
handlingTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
transitTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 2,
|
||||
maxValue: 3,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -7,11 +7,17 @@ const collectAllCategories = (categoryNode, categories = []) => {
|
||||
|
||||
// Add current category (skip root category 209)
|
||||
if (categoryNode.id !== 209) {
|
||||
// Extract subcategory IDs from children
|
||||
const subcategoryIds = categoryNode.children
|
||||
? categoryNode.children.map(child => child.id)
|
||||
: [];
|
||||
|
||||
categories.push({
|
||||
id: categoryNode.id,
|
||||
name: categoryNode.name,
|
||||
seoName: categoryNode.seoName,
|
||||
parentId: categoryNode.parentId
|
||||
parentId: categoryNode.parentId,
|
||||
subcategories: subcategoryIds
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
105
process_llms_cat.cjs
Normal file
@@ -0,0 +1,105 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Read the input file from public
|
||||
const inputFile = path.join(__dirname, 'public', 'llms-cat.txt');
|
||||
// Write the output file to dist
|
||||
const outputFile = path.join(__dirname, 'dist', 'llms-cat.txt');
|
||||
|
||||
// Function to parse a CSV line with escaped quotes
|
||||
function parseCSVLine(line) {
|
||||
const fields = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
let i = 0;
|
||||
|
||||
while (i < line.length) {
|
||||
const char = line[i];
|
||||
|
||||
if (char === '"') {
|
||||
// Check if this is an escaped quote
|
||||
if (i + 1 < line.length && line[i + 1] === '"') {
|
||||
current += '"'; // Add single quote (unescaped)
|
||||
i += 2; // Skip both quotes
|
||||
continue;
|
||||
} else {
|
||||
inQuotes = !inQuotes; // Toggle quote state
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
fields.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
fields.push(current); // Add the last field
|
||||
return fields;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(inputFile)) {
|
||||
throw new Error(`Input file not found: ${inputFile}`);
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(inputFile, 'utf8');
|
||||
const lines = data.trim().split('\n');
|
||||
|
||||
// Keep the header as intended: URL and Description
|
||||
const outputLines = ['URL of product list for article numbers,SEO Description'];
|
||||
|
||||
let skippedLines = 0;
|
||||
let processedLines = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '') continue;
|
||||
|
||||
// Skip comment lines or lines not starting with a number/quote (simple heuristic for header/comments)
|
||||
// The file starts with text "this file has..." and then header "categoryId..."
|
||||
// Actual data lines start with "
|
||||
if (!line.trim().startsWith('"')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse the CSV line properly handling escaped quotes
|
||||
const fields = parseCSVLine(line);
|
||||
|
||||
if (fields.length !== 3) {
|
||||
console.warn(`Skipping malformed line ${i + 1} (got ${fields.length} fields): ${line.substring(0, 50)}...`);
|
||||
skippedLines++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Input: categoryId, listFileName, seoDescription
|
||||
// Output: URL, SEO Description
|
||||
const [categoryId, listFileName, seoDescription] = fields;
|
||||
|
||||
// Use listFileName as URL
|
||||
const url = listFileName;
|
||||
|
||||
// Use seoDescription as description directly (it's already a string)
|
||||
const description = seoDescription;
|
||||
|
||||
// Escape quotes for CSV output
|
||||
const escapedDescription = '"' + description.replace(/"/g, '""') + '"';
|
||||
|
||||
outputLines.push(`${url},${escapedDescription}`);
|
||||
processedLines++;
|
||||
}
|
||||
|
||||
// Ensure dist directory exists
|
||||
const distDir = path.dirname(outputFile);
|
||||
if (!fs.existsSync(distDir)) {
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write the output CSV
|
||||
fs.writeFileSync(outputFile, outputLines.join('\n'), 'utf8');
|
||||
console.log(`Processed ${processedLines} lines (skipped ${skippedLines}) and created ${outputFile}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing file:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
BIN
public/assets/images/cutlings.avif
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/assets/images/gg.avif
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/assets/images/konfigurator.avif
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
public/assets/images/konfigurator.png
Normal file
|
After Width: | Height: | Size: 362 KiB |
BIN
public/assets/images/maps.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/assets/images/seeds.avif
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/assets/images/sh.avif
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
92
public/llms-cat.txt
Normal file
@@ -0,0 +1,92 @@
|
||||
this file has the list of category overview lists, where you can find article numbers
|
||||
|
||||
categoryId,listFileName,seoDescription
|
||||
"703","https://growheads.de/llms-abluft-sets-list.txt","Abluft-Sets für Growbox & Indoor-Grow: leise, energiesparend & mit Aktivkohlefilter zur Geruchsneutralisation. Ideal für Zelte von 60 cm bis 1 m²."
|
||||
"317","https://growheads.de/llms-air-pot-list.txt","Air-Pot Pflanztöpfe für maximales Wurzelwachstum: Air-Pruning, optimale Belüftung & Drainage. Ideal für Indoor, Outdoor, Hydroponik & Anzucht."
|
||||
"922","https://growheads.de/llms-aktivkohlefilter-tips-list.txt","Aktivkohlefilter & Tips für Zigaretten und Selbstgedrehte – milderer Geschmack, weniger Schadstoffe, optimale Rauchfilterung und hoher Genuss."
|
||||
"372","https://growheads.de/llms-autopot-list.txt","AutoPot Bewässerungssysteme & Zubehör: Stromlose, automatische Pflanzenbewässerung mit Tanks, Schläuchen, FlexiPots & AQUAvalve5 für Hydroponik & Garten."
|
||||
"389","https://growheads.de/llms-blumat-list.txt","Blumat Bewässerungssysteme & Zubehör: Tropf-Bewässerung, Erdfeuchte-Sensoren und Ventile für automatische, bedarfsgerechte Pflanzenbewässerung."
|
||||
"355","https://growheads.de/llms-boveda-integra-boost-list.txt","Boveda & Integra Boost Hygro-Packs für perfekte Feuchtigkeitskontrolle Deiner Kräuter. Verhindern Schimmel, Austrocknung und Aroma-Verlust bei der Lagerung."
|
||||
"749","https://growheads.de/llms-chillums-diffusoren-kupplungen-list.txt","Chillums, Diffusoren & Kupplungen für Bongs – große Auswahl an 14,5mm, 18,8mm & 29,2mm Adaptern aus Glas für bessere Kühlung & sanfteren Rauchgenuss."
|
||||
"449","https://growheads.de/llms-cliptuetchen-list.txt","Cliptütchen & Mylarbeutel: hochwertige Zip Bags und Schnellverschlussbeutel in vielen Größen, starken Folienstärken und Farben – ideal zur sicheren Lagerung."
|
||||
"924","https://growheads.de/llms-dabbing-list.txt","Entdecken Sie hochwertiges Dabbing-Zubehör für konzentrierte Aromagenuss – Dab Rigs, Tools und mehr für ein intensives, sauberes Dab-Erlebnis."
|
||||
"691","https://growheads.de/llms-duenger-list.txt","Dünger & Pflanzennährstoffe für Erde, Coco & Hydro: Bio- und Mineraldünger, Booster, Wurzelstimulatoren, PK-Additive & pH-Regulatoren für maximale Erträge."
|
||||
"692","https://growheads.de/llms-duenger-zubehoer-list.txt","Dünger-Zubehör: Abfüllhähne, Wurmhumus, pH-Eichlösungen & Desinfektionsmittel für präzise Dosierung, Hygiene und optimale Nährstoffversorgung der Pflanzen."
|
||||
"489","https://growheads.de/llms-eazyplug-jiffy-list.txt","EazyPlug & Jiffy Anzuchtmedien: nachhaltige Anzuchtwürfel, Torftöpfe & Trays für Stecklinge und Sämlinge mit optimalem Wasser-Luft-Verhältnis."
|
||||
"243","https://growheads.de/llms-erde-list.txt","Hochwertige Blumenerde & Bio-Substrate: torffrei, organisch, vorgedüngt – ideal für Indoor-Grow, Urban Gardening, Kräuter, Gemüse & Cannabis-Anbau."
|
||||
"302","https://growheads.de/llms-erntemaschinen-list.txt","Erntemaschinen & Leaf Trimmer für professionelle Ernteverarbeitung – vom manuellen Trimmer bis zur automatisierten Profigerät, inkl. Zubehör & Ersatzteile."
|
||||
"280","https://growheads.de/llms-erntescheeren-list.txt","Hochwertige Erntescheren & Gartenscheren für präzise Pflanzenschnitte. Entdecken Sie Profi-Erntescheren aus Edelstahl & Japanstahl für Garten & Indoor-Grow."
|
||||
"424","https://growheads.de/llms-etiketten-schilder-list.txt","Etiketten & Schilder für Garten & Gewächshaus – wasserfeste Stecketiketten in vielen Farben für Pflanzenbeschriftung, Sortierung und Kennzeichnung."
|
||||
"278","https://growheads.de/llms-extraktion-list.txt","Hochwertige Extraktionszubehör & -geräte: Pollenmaschinen, Extraktorbeutel, DME-Gas, Rosin Bags & Infuser für saubere Pflanzen- und Öl-Extraktionen."
|
||||
"379","https://growheads.de/llms-geruchsneutralisation-list.txt","Effektive Geruchsneutralisation für Haushalt, Grow-Räume & Gewerbe – ONA & BIODOR Gel, Spray, Filter und Ozongeneratoren gegen Tabak-, Cannabis- & Tiergerüche."
|
||||
"359","https://growheads.de/llms-gewaechshaeuser-list.txt","Gewächshäuser & Anzuchtgewächshäuser für drinnen: beheizt, mit LED & Lüftung. Ideal für Kräuter, Gemüse & Stecklinge – für erfolgreiche Pflanzenanzucht."
|
||||
"539","https://growheads.de/llms-glaeser-dosen-list.txt","Gläser & Dosen für luftdichte, lichtgeschützte und geruchsneutrale Aufbewahrung von Kräutern, Lebensmitteln & Wertsachen. Vakuum-, Stash- & Miron-Glas."
|
||||
"710","https://growheads.de/llms-grinder-list.txt","Hochwertige Grinder & Kräutermühlen aus Aluminium, Holz & Edelstahl – 2-, 3- & 4-teilig, Pollinator, Non-Sticky & Design-Grinder für Tabak & Kräuter."
|
||||
"407","https://growheads.de/llms-grove-bags-list.txt","Grove Bags TerpLoc – professionelle Lagerung für Cannabis & Kräuter. Schimmelschutz, Feuchtigkeitskontrolle, Terpene & Aroma optimal bewahren."
|
||||
"408","https://growheads.de/llms-growcontrol-list.txt","GrowControl Steuerungen & Sensoren für präzises Klima-, CO₂- und Lichtmanagement im Indoor-Grow. Made in Germany, kompatibel mit EC-Lüftern & LED-Systemen."
|
||||
"373","https://growheads.de/llms-growtool-list.txt","GrowTool Zubehör für professionelle Bewässerung & GrowRacks: stabile Unterbauten, aeroponische Systeme, Adapter & Wasserkühler für optimales Indoor-Growing."
|
||||
"310","https://growheads.de/llms-heizmatten-list.txt","Heizmatten für Gewächshaus, Growbox & Terrarium: Effiziente Wurzelwärme, schnellere Keimung & gesundes Pflanzenwachstum mit Thermostat-Steuerung."
|
||||
"748","https://growheads.de/llms-koepfe-list.txt","Hochwertige Bong-Köpfe & Glasbowls: Entdecke Trichterköpfe, Flutschköpfe und Zenit Premium-Köpfe in 14,5 & 18,8 mm in vielen Farben online."
|
||||
"269","https://growheads.de/llms-kokos-list.txt","Entdecken Sie hochwertige Kokossubstrate & Kokosmatten für Hydroponik, Indoor-Grow & Topfkulturen – torffreie, nachhaltige Coco-Erden für starkes Wurzelwachstum."
|
||||
"364","https://growheads.de/llms-kunststofftoepfe-list.txt","Kunststofftöpfe für Pflanzen, Anzucht und Umtopfen: Viereckige Pflanztöpfe, Airpots & Mini-Pots in vielen Größen für gesundes Wurzelwachstum."
|
||||
"694","https://growheads.de/llms-lampen-list.txt","Entdecken Sie hochwertige LED Grow Lampen & Pflanzenlampen mit Vollspektrum für Indoor-Grow, Wachstum & Blüte. Effizient, dimmbar & langlebig."
|
||||
"261","https://growheads.de/llms-lampenzubehoer-list.txt","Hochwertiges Lampenzubehör für Growbox & Gewächshaus: Aufhängungen, Dimmer, Reflektoren, Netzteile & SANlight-Zubehör für optimales Pflanzenlicht."
|
||||
"387","https://growheads.de/llms-literatur-list.txt","Entdecke Fachliteratur zu Cannabis-Anbau, Bio-Grow, LED, Hydrokultur & Extraktion – praxisnahe Bücher für Indoor- und Outdoor-Gärtner, Anfänger & Profis."
|
||||
"658","https://growheads.de/llms-luftbe-und-entfeuchter-list.txt","Effektive Luftbefeuchter & Luftentfeuchter für Growroom & Indoor-Anbau. Optimale Luftfeuchtigkeit, Schimmelvorbeugung & gesundes Pflanzenwachstum."
|
||||
"403","https://growheads.de/llms-messbecher-mehr-list.txt","Messbecher, Pipetten & Einwegspritzen zum präzisen Abmessen von Flüssigkeiten – ideal für Dünger, Zusätze, Labor und Garten. Verschiedene Größen."
|
||||
"344","https://growheads.de/llms-messgeraete-list.txt","Präzise pH-, EC-, Temperatur- und Klimamessgeräte für Garten, Hydroponik & Labor. Entdecke Profi-Messinstrumente, Sonden und Kalibrierlösungen."
|
||||
"555","https://growheads.de/llms-mikroskope-list.txt","Mikroskope & Lupen für Hobby, Schule & Elektronik: Entdecken Sie 5x–100x Vergrößerung, USB-Mikroskope, LED-Modelle und mobile Zoom-Mikroskope."
|
||||
"923","https://growheads.de/llms-papes-blunts-list.txt","Entdecke hochwertige Papers & Blunts für perfekten Rauchgenuss – von klassischen Blättchen bis aromatisierten Blunt Wraps in vielen Größen und Stärken."
|
||||
"222","https://growheads.de/llms-pe-teile-list.txt","PE-Teile für Bewässerung: Absperrhähne, Kupplungen, T-Stücke & Endkappen für PE-Schläuche – ideal für Gartenbewässerung und Tropfbewässerung."
|
||||
"580","https://growheads.de/llms-perlite-blaehton-list.txt","Perlite & Blähton für Hydroponik, Hydrokultur & Gartenbau. Optimale Drainage, Belüftung und Wasserspeicherung für gesundes Wurzelwachstum."
|
||||
"921","https://growheads.de/llms-pfeifen-list.txt","Entdecken Sie Pfeifen für Aktivkohlefilter: langlebige Holzpfeifen und hochwertige Aluminium-Pfeifen mit Royal Filter Adapter für ein reines Raucherlebnis."
|
||||
"239","https://growheads.de/llms-pflanzenschutz-list.txt","Pflanzenschutz biologisch & chemiefrei: Nützlinge, Neemöl, Gelbtafeln & Raubmilben gegen Trauermücken, Thripse, Spinnmilben, Blattläuse & Weiße Fliege."
|
||||
"259","https://growheads.de/llms-pressen-list.txt","Hydraulische Rosin Pressen, Pollenpressen & Rosin Bags für professionelle, lösungsmittelfreie Extraktion und Harzpressung. Große Auswahl & Top-Marken."
|
||||
"297","https://growheads.de/llms-pumpen-list.txt","Entdecken Sie leistungsstarke Pumpen für Bewässerung, Hydroponik & Aquaristik: Tauchpumpen, Umwälzpumpen, Belüftungs- und Luftpumpen für jeden Bedarf."
|
||||
"519","https://growheads.de/llms-pumpsprueher-list.txt","Pumpsprüher & Drucksprüher für Garten, Haushalt & Industrie. Hochwertige 1–8L Sprüher für Pflanzenpflege, Reinigung und Pflanzenschutz online kaufen."
|
||||
"920","https://growheads.de/llms-raeucherstaebchen-list.txt","Entdecken Sie hochwertige Räucherstäbchen wie Goloka Nag Champa und Satya für Meditation, Ayurveda, Chakra-Harmonisierung und entspannende Duftmomente."
|
||||
"450","https://growheads.de/llms-restposten-list.txt","Günstige Restposten: stark reduzierte Markenartikel, Sonderposten und Einzelstücke für cleveres Sparen. Jetzt Restbestände kaufen und Schnäppchen sichern."
|
||||
"916","https://growheads.de/llms-rollen-bauen-list.txt","Entdecke Rolling Trays, Tin Boxen & Rolling Sets für perfektes Drehen – praktische Aufbewahrung, integriertes Zubehör & stylische Designs."
|
||||
"609","https://growheads.de/llms-schalldaempfer-list.txt","Schalldämpfer für Lüftungsanlagen: hochwertige Rohr- & Telefonieschalldämpfer zur effektiven Geräuschreduzierung in Wohnraum, Gewerbe & Technikräumen."
|
||||
"405","https://growheads.de/llms-schlaeuche-1-list.txt","Schläuche für Bewässerung & Garten: Tropfschläuche, Mikroschläuche und flexible Gartenschläuche in verschiedenen Durchmessern für präzise Wasserversorgung."
|
||||
"250","https://growheads.de/llms-schlaeuche-list.txt","Hochwertige Lüftungs- und Abluftschläuche: Aluflex-, Combi-, Phonic Trap & Sonodec für leise, effiziente Belüftung in Growroom, Werkstatt & Haus."
|
||||
"689","https://growheads.de/llms-seeds-list.txt","Entdecke hochwertige Samen: Cannabis-, Gemüse- und Kräutersamen für Indoor & Outdoor, inklusive Autoflower, CBD, Fast Version & Bio-Gartensaatgut."
|
||||
"915","https://growheads.de/llms-set-zubehoer-list.txt","Set-Zubehör für Grow & Indoor-Garten: Erde, Dünger-Starterkit, Ernteschere, Thermo-Hygrometer & WLAN Zeitschaltuhr für optimale Pflanzenpflege."
|
||||
"4","https://growheads.de/llms-sonstiges-list.txt","Sonstiges Garten- & Grow-Zubehör: LST Pflanzenbieger, Kabel, CBD-Aromaöle, Adventskalender, Schutzbrillen & Bambusstäbe günstig online kaufen."
|
||||
"354","https://growheads.de/llms-sprueher-list.txt","Sprüher & Sprühflaschen fürs Pflanzenwässern: Drucksprüher, Handsprüher, Gießstäbe & Hozelock-Spritzdüsen für Gewächshaus, Garten & Indoor-Grow."
|
||||
"706","https://growheads.de/llms-stecklinge-list.txt","Entdecke hochwertige Cannabis-Stecklinge: Top-Genetiken, feminisierte & Autoflower Sorten, hohe THC- und CBD-Werte, ideal für Indoor- & Outdoor-Grower."
|
||||
"298","https://growheads.de/llms-steinwolltrays-list.txt","Steinwolltrays & Anzuchtsysteme für Stecklinge & Samen – Grodan, Speedgrow & Joplug. Optimale Bewurzelung, Hydroponik-tauglich, pH-neutral & effizient."
|
||||
"314","https://growheads.de/llms-steuergeraete-list.txt","Steuergeräte für Indoor-Grow & Gewächshaus: Klimacontroller, Lüftersteuerungen, CO₂-Regler & Thermostate für optimales Grow-Klima online kaufen."
|
||||
"301","https://growheads.de/llms-stofftoepfe-list.txt","Stofftöpfe & Pflanzsäcke für gesundes Wurzelwachstum – atmungsaktive, nachhaltige Fabric Pots aus recyceltem Material für Indoor & Outdoor Anbau."
|
||||
"292","https://growheads.de/llms-trays-fluttische-list.txt","Trays & Fluttische für Growbox & Gewächshaus: stabile Pflanzschalen, Fluttischböden, Water Trays und Eisenracks für effiziente Bewässerung & Trocknung."
|
||||
"293","https://growheads.de/llms-trockennetze-list.txt","Trockennetze & Dry Bags für Kräuter, Blüten & Samen: platzsparend, geruchsarm & schimmelfrei trocknen – ideal für Growbox, Indoor & Balkon."
|
||||
"480","https://growheads.de/llms-tropfer-list.txt","Tropfer & Mikroschläuche für professionelle Tropfbewässerung – Zubehör, Verbinder und Systeme für Garten, Gewächshaus & Containerpflanzen."
|
||||
"214","https://growheads.de/llms-umluft-ventilatoren-list.txt","Umluft-Ventilatoren für Growbox, Growraum & Haushalt: leise Clip‑, Box‑ und Wandventilatoren mit Oszillation, EC-Motor, energieeffizient & langlebig."
|
||||
"220","https://growheads.de/llms-untersetzer-list.txt","Untersetzer & Auffangschalen für Pflanztöpfe: eckig & rund, verschiedene Größen, robust, wasserdicht – ideal für Indoor-Grow, Balkon & Zimmerpflanzen."
|
||||
"346","https://growheads.de/llms-vakuumbeutel-list.txt","Vakuumbeutel & Alu-Bügelbeutel für Lebensmittel, Fermentation & Lagerung – luftdicht, robust, BPA-frei. Passend zu allen gängigen Vakuumierern."
|
||||
"896","https://growheads.de/llms-vaporizer-list.txt","Vaporizer & E-Rigs für Kräuter & Konzentrate: Entdecke Premium-Verdampfer, Dab Tools & Zubehör von Puffco, Storz & Bickel, Wolkenkraft u.v.m."
|
||||
"374","https://growheads.de/llms-verbindungsteile-list.txt","Verbindungsteile für Lüftungsanlagen: Außen- & Innenverbinder, Reduzierstücke, Gummimuffen, Dichtbänder, T- und Y-Stücke für luftdichte Rohrverbindungen."
|
||||
"421","https://growheads.de/llms-vermehrungszubehoer-list.txt","Vermehrungszubehör für Stecklinge & Jungpflanzen: Bewurzelungsgel, Clonex Mist, Jiffy Quelltöpfe, Skalpelle & Substrate für erfolgreiche Pflanzenzucht."
|
||||
"187","https://growheads.de/llms-waagen-list.txt","Präzisionswaagen, Taschenwaagen & Paketwaagen: Entdecken Sie digitale Waagen, Juwelierwaagen und Eichgewichte für Labor, Versand, Haushalt & Hobby."
|
||||
"425","https://growheads.de/llms-wassertanks-list.txt","Wassertanks & Nährstofftanks für Bewässerung & Hydroponik – robuste Tanks, flexible Flex-Tanks, Tankdurchführungen & Zubehör für Growbox und Garten."
|
||||
"186","https://growheads.de/llms-wiegen-verpacken-list.txt","Wiegen & Verpacken: Präzisionswaagen, Vakuumbeutel, Grove Bags, Boveda, Integra Boost, Cliptütchen sowie Gläser & Dosen für sichere Lagerung."
|
||||
"693","https://growheads.de/llms-zelte-list.txt","Entdecke hochwertige Growzelte für Indoor-Growing – von kompakten Mini-Growboxen bis zu Profi-Zelten mit Mylar, PAR+ & stabilen Stahlrahmen."
|
||||
"226","https://growheads.de/llms-zeltzubehoer-list.txt","Zeltzubehör für Growbox & Growzelt: Scrog-Netze, Stütznetze, Space Booster, Stoffböden, Verbinder & Zubehör für stabile, effiziente Indoor-Grows."
|
||||
"686","https://growheads.de/llms-zubehoer-1-list.txt","Zubehör für Aktivkohlefilter, Vorfilter und Flansche: hochwertiges Lüftungs- & Filterzubehör für Prima Klima und Can Filters in Profi-Qualität."
|
||||
"741","https://growheads.de/llms-zubehoer-2-list.txt","Zubehör für Lüfter & Klima: EC-Controller, Temperaturregler, Netzstecker & Gewebeband für leisen, effizienten und sicheren Betrieb Ihrer Lüftungsanlage."
|
||||
"294","https://growheads.de/llms-zubehoer-3-list.txt","Praktisches Zubehör für Bewässerung, Hydroponik & Garten: Schläuche, Filter, Heizstäbe, Verbinder und mehr für effiziente Grow- & Bewässerungssysteme."
|
||||
"714","https://growheads.de/llms-zubehoer-list.txt","Zubehör für Bongs & Wasserpfeifen: Aktivkohle, Adapter, Filter, Köpfe, Vorkühler, Reinigungsmittel & Tools von Zenit, Smokebuddy, Black Leaf u.v.m."
|
||||
"392","https://growheads.de/llms-zuluftfilter-list.txt","Zuluftfilter für Growroom & Gewächshaus: saubere Frischluft, Schutz vor Pollen, Staub & Insekten, optimales Klima und gesundes Pflanzenwachstum."
|
||||
"308","https://growheads.de/llms-ab-und-zuluft-list.txt","Ab- und Zuluft für Growbox & Raumklima: leise EC-Lüfter, Rohrventilatoren, Iso-Boxen, Schläuche, Filter, Schalldämpfer & Zubehör für Profi-Lüftung."
|
||||
"248","https://growheads.de/llms-aktivkohlefilter-list.txt","Aktivkohlefilter für Growbox, Industrie & Lüftung: hochwertige Geruchsneutralisation, Luftreinigung und Zubehör von Can Filters, Prima Klima, Rhino u.v.m."
|
||||
"240","https://growheads.de/llms-anbauzubehoer-list.txt","Anbauzubehör für Indoor & Outdoor Grow: Kabel, Zeitschaltuhren, Pflanzentraining, Befestigung, Gewächshausheizung & mehr für Hobby- und Profigärtner."
|
||||
"286","https://growheads.de/llms-anzucht-list.txt","Anzucht-Zubehör für erfolgreiches Vorziehen: Steinwolltrays, Heizmatten, Gewächshäuser, Vermehrungszubehör sowie EazyPlug & Jiffy Substrate online kaufen."
|
||||
"247","https://growheads.de/llms-belueftung-list.txt","Belüftung für Growbox & Indoor-Grow: Umluft-Ventilatoren, Aktivkohlefilter, Ab- und Zuluft, Steuergeräte, Luftbefeuchter & Entfeuchter, Geruchsneutralisation."
|
||||
"221","https://growheads.de/llms-bewaesserung-list.txt","Bewässerungssysteme für Garten, Gewächshaus & Indoor-Grow: Pumpen, Schläuche, Tropfer, AutoPot, Blumat, Trays, Wassertanks & Zubehör günstig kaufen."
|
||||
"242","https://growheads.de/llms-boeden-list.txt","Böden & Substrate für Profi- und Hobby-Grower: Erde, Kokos, Perlite & Blähton für Indoor-Grow, Hydroponik und Gartenbau. Optimale Drainage & Nährstoffversorgung."
|
||||
"711","https://growheads.de/llms-bongs-list.txt","Bongs online kaufen: Glasbongs, Acrylbongs & Ölbongs von Black Leaf, Jelly Joker, Grace Glass, Boost, Zenit u.v.m. Für Kräuter, Öl & Dabs – große Auswahl."
|
||||
"258","https://growheads.de/llms-ernte-verarbeitung-list.txt","Ernte & Verarbeitung: Pressen, Extraktion, Erntescheren, Trockennetze & Erntemaschinen für effiziente, schonende Blüten- und Kräuterverarbeitung."
|
||||
"376","https://growheads.de/llms-grow-sets-list.txt","Entdecken Sie hochwertige Grow-Sets für Indoor-Growing: Komplettsets mit LED-Beleuchtung, Growbox, Abluftsystem & Zubehör für Anfänger und Profis."
|
||||
"709","https://growheads.de/llms-headshop-list.txt","Headshop mit Bongs, Vaporizern, Pfeifen, Dabbing‑Zubehör, Papes, Grinder, Filtern, Waagen, Rolling Trays & Räucherstäbchen – alles für dein Rauch‑Setup."
|
||||
"219","https://growheads.de/llms-toepfe-list.txt","Töpfe für Indoor- und Outdoorgrowing: Stofftöpfe, Air-Pots, Kunststofftöpfe & Untersetzer für optimales Wurzelwachstum und professionelle Pflanzenzucht."
|
||||
"695","https://growheads.de/llms-zubehoer-4-list.txt","Zubehör für Growbox & Indoor-Grow: Zeltzubehör, Anbauzubehör, Lampenzubehör, Messgeräte, Ernte & Verarbeitung sowie Dünger-Zubehör online kaufen."
|
||||
61
scripts/convert-images-to-avif.cjs
Normal file
@@ -0,0 +1,61 @@
|
||||
const sharp = require('sharp');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const imagesToConvert = [
|
||||
{ src: 'sh.png', dest: 'sh.avif' },
|
||||
{ src: 'seeds.jpg', dest: 'seeds.avif' },
|
||||
{ src: 'cutlings.jpg', dest: 'cutlings.avif' },
|
||||
{ src: 'gg.png', dest: 'gg.avif' },
|
||||
{ src: 'konfigurator.png', dest: 'konfigurator.avif' },
|
||||
{ src: 'maps.png', dest: 'maps.avif' }
|
||||
];
|
||||
|
||||
const run = async () => {
|
||||
const imagesDir = path.join(__dirname, '../public/assets/images');
|
||||
let hasError = false;
|
||||
|
||||
for (const image of imagesToConvert) {
|
||||
const inputPath = path.join(imagesDir, image.src);
|
||||
const outputPath = path.join(imagesDir, image.dest);
|
||||
|
||||
console.log('d');
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
console.warn(`⚠️ Input file not found: ${inputPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if output file exists and compare modification times
|
||||
// Only convert if source is newer or destination doesn't exist
|
||||
let shouldConvert = true;
|
||||
if (fs.existsSync(outputPath)) {
|
||||
const inputStat = fs.statSync(inputPath);
|
||||
const outputStat = fs.statSync(outputPath);
|
||||
if (inputStat.mtime <= outputStat.mtime) {
|
||||
shouldConvert = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldConvert) {
|
||||
try {
|
||||
await sharp(inputPath)
|
||||
.toFormat('avif')
|
||||
.toFile(outputPath);
|
||||
console.log(`✅ Converted ${image.src} to ${image.dest}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error converting ${image.src}:`, error.message);
|
||||
hasError = true;
|
||||
}
|
||||
} else {
|
||||
// Silent skip if already up to date to keep logs clean, or use verbose flag
|
||||
// console.log(`Skipping ${image.src} (already up to date)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
console.log('dsfs');
|
||||
run();
|
||||
26
scripts/convert-logo-to-avif.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const sharp = require('sharp');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const run = async () => {
|
||||
const inputPath = path.join(__dirname, '../public/assets/images/sh.png');
|
||||
const outputPath = path.join(__dirname, '../public/assets/images/sh.avif');
|
||||
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
console.error('Input file not found:', inputPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await sharp(inputPath)
|
||||
.toFormat('avif')
|
||||
.toFile(outputPath);
|
||||
console.log(`Successfully converted ${inputPath} to ${outputPath}`);
|
||||
} catch (error) {
|
||||
console.error('Error converting image:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
|
||||
344
scripts/validate-products-xml.cjs
Normal file
@@ -0,0 +1,344 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { DOMParser } = require('xmldom');
|
||||
|
||||
/**
|
||||
* Validates products.xml against Google Shopping RSS 2.0 requirements
|
||||
*/
|
||||
class ProductsXmlValidator {
|
||||
constructor(xmlFilePath) {
|
||||
this.xmlFilePath = xmlFilePath;
|
||||
this.errors = [];
|
||||
this.warnings = [];
|
||||
this.stats = {
|
||||
totalItems: 0,
|
||||
validItems: 0,
|
||||
invalidItems: 0
|
||||
};
|
||||
}
|
||||
|
||||
addError(message, itemId = null) {
|
||||
this.errors.push({ message, itemId, type: 'error' });
|
||||
}
|
||||
|
||||
addWarning(message, itemId = null) {
|
||||
this.warnings.push({ message, itemId, type: 'warning' });
|
||||
}
|
||||
|
||||
validateXmlStructure(xmlContent) {
|
||||
try {
|
||||
const parser = new DOMParser({
|
||||
errorHandler: {
|
||||
warning: (msg) => this.addWarning(`XML Warning: ${msg}`),
|
||||
error: (msg) => this.addError(`XML Error: ${msg}`),
|
||||
fatalError: (msg) => this.addError(`XML Fatal Error: ${msg}`)
|
||||
}
|
||||
});
|
||||
|
||||
const doc = parser.parseFromString(xmlContent, 'text/xml');
|
||||
|
||||
// Check for parsing errors
|
||||
const parserErrors = doc.getElementsByTagName('parsererror');
|
||||
if (parserErrors.length > 0) {
|
||||
this.addError('XML parsing failed - invalid XML structure');
|
||||
return null;
|
||||
}
|
||||
|
||||
return doc;
|
||||
} catch (error) {
|
||||
this.addError(`Failed to parse XML: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
validateRootStructure(doc) {
|
||||
// Check RSS root element
|
||||
const rssElement = doc.getElementsByTagName('rss')[0];
|
||||
if (!rssElement) {
|
||||
this.addError('Missing required <rss> root element');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check RSS version
|
||||
const version = rssElement.getAttribute('version');
|
||||
if (version !== '2.0') {
|
||||
this.addError(`Invalid RSS version: expected "2.0", got "${version}"`);
|
||||
}
|
||||
|
||||
// Check Google namespace
|
||||
const googleNamespace = rssElement.getAttribute('xmlns:g');
|
||||
if (googleNamespace !== 'http://base.google.com/ns/1.0') {
|
||||
this.addError(`Missing or invalid Google namespace: expected "http://base.google.com/ns/1.0", got "${googleNamespace}"`);
|
||||
}
|
||||
|
||||
// Check channel element
|
||||
const channelElement = doc.getElementsByTagName('channel')[0];
|
||||
if (!channelElement) {
|
||||
this.addError('Missing required <channel> element');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
validateChannelInfo(doc) {
|
||||
const channel = doc.getElementsByTagName('channel')[0];
|
||||
const requiredChannelElements = ['title', 'link', 'description'];
|
||||
|
||||
requiredChannelElements.forEach(elementName => {
|
||||
const element = channel.getElementsByTagName(elementName)[0];
|
||||
if (!element || !element.textContent.trim()) {
|
||||
this.addError(`Missing or empty required channel element: <${elementName}>`);
|
||||
}
|
||||
});
|
||||
|
||||
// Check language
|
||||
const language = channel.getElementsByTagName('language')[0];
|
||||
if (!language || !language.textContent.trim()) {
|
||||
this.addWarning('Missing <language> element in channel');
|
||||
} else if (!language.textContent.match(/^[a-z]{2}(-[A-Z]{2})?$/)) {
|
||||
this.addWarning(`Invalid language format: ${language.textContent} (should be like "de-DE")`);
|
||||
}
|
||||
}
|
||||
|
||||
validateItem(item, index) {
|
||||
const itemId = this.getItemId(item, index);
|
||||
this.stats.totalItems++;
|
||||
|
||||
// Required Google Shopping attributes
|
||||
const requiredAttributes = [
|
||||
'g:id',
|
||||
'g:title',
|
||||
'g:description',
|
||||
'g:link',
|
||||
'g:image_link',
|
||||
'g:condition',
|
||||
'g:availability',
|
||||
'g:price'
|
||||
];
|
||||
|
||||
let hasErrors = false;
|
||||
|
||||
requiredAttributes.forEach(attr => {
|
||||
const element = item.getElementsByTagName(attr)[0];
|
||||
if (!element || !element.textContent.trim()) {
|
||||
this.addError(`Missing required attribute: <${attr}>`, itemId);
|
||||
hasErrors = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Validate specific attribute formats
|
||||
this.validatePrice(item, itemId);
|
||||
this.validateCondition(item, itemId);
|
||||
this.validateAvailability(item, itemId);
|
||||
this.validateUrls(item, itemId);
|
||||
this.validateGtin(item, itemId);
|
||||
this.validateShippingWeight(item, itemId);
|
||||
|
||||
if (hasErrors) {
|
||||
this.stats.invalidItems++;
|
||||
} else {
|
||||
this.stats.validItems++;
|
||||
}
|
||||
}
|
||||
|
||||
getItemId(item, index) {
|
||||
const idElement = item.getElementsByTagName('g:id')[0];
|
||||
return idElement ? idElement.textContent.trim() : `item-${index + 1}`;
|
||||
}
|
||||
|
||||
validatePrice(item, itemId) {
|
||||
const priceElement = item.getElementsByTagName('g:price')[0];
|
||||
if (priceElement) {
|
||||
const priceText = priceElement.textContent.trim();
|
||||
// Price should be in format "XX.XX EUR" or similar
|
||||
if (!priceText.match(/^\d+(\.\d{2})?\s+[A-Z]{3}$/)) {
|
||||
this.addError(`Invalid price format: "${priceText}" (should be "XX.XX EUR")`, itemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validateCondition(item, itemId) {
|
||||
const conditionElement = item.getElementsByTagName('g:condition')[0];
|
||||
if (conditionElement) {
|
||||
const condition = conditionElement.textContent.trim();
|
||||
const validConditions = ['new', 'refurbished', 'used'];
|
||||
if (!validConditions.includes(condition)) {
|
||||
this.addError(`Invalid condition: "${condition}" (must be: ${validConditions.join(', ')})`, itemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validateAvailability(item, itemId) {
|
||||
const availabilityElement = item.getElementsByTagName('g:availability')[0];
|
||||
if (availabilityElement) {
|
||||
const availability = availabilityElement.textContent.trim();
|
||||
const validAvailability = ['in stock', 'out of stock', 'preorder', 'backorder'];
|
||||
if (!validAvailability.includes(availability)) {
|
||||
this.addError(`Invalid availability: "${availability}" (must be: ${validAvailability.join(', ')})`, itemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validateUrls(item, itemId) {
|
||||
const urlElements = ['g:link', 'g:image_link'];
|
||||
urlElements.forEach(elementName => {
|
||||
const element = item.getElementsByTagName(elementName)[0];
|
||||
if (element) {
|
||||
const url = element.textContent.trim();
|
||||
try {
|
||||
new URL(url);
|
||||
if (!url.startsWith('https://')) {
|
||||
this.addWarning(`URL should use HTTPS: ${url}`, itemId);
|
||||
}
|
||||
} catch (error) {
|
||||
this.addError(`Invalid URL in <${elementName}>: ${url}`, itemId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
validateGtin(item, itemId) {
|
||||
const gtinElement = item.getElementsByTagName('g:gtin')[0];
|
||||
if (gtinElement) {
|
||||
const gtin = gtinElement.textContent.trim();
|
||||
// GTIN should be 8, 12, 13, or 14 digits
|
||||
if (!gtin.match(/^\d{8}$|^\d{12,14}$/)) {
|
||||
this.addError(`Invalid GTIN format: "${gtin}" (should be 8, 12, 13, or 14 digits)`, itemId);
|
||||
}
|
||||
} else {
|
||||
this.addWarning(`Missing GTIN - recommended for better product matching`, itemId);
|
||||
}
|
||||
}
|
||||
|
||||
validateShippingWeight(item, itemId) {
|
||||
const weightElement = item.getElementsByTagName('g:shipping_weight')[0];
|
||||
if (weightElement) {
|
||||
const weight = weightElement.textContent.trim();
|
||||
// Weight should be in format "XX.XX g" or similar
|
||||
if (!weight.match(/^\d+(\.\d+)?\s+[a-zA-Z]+$/)) {
|
||||
this.addError(`Invalid shipping weight format: "${weight}" (should be "XX.XX g")`, itemId);
|
||||
}
|
||||
} else {
|
||||
this.addWarning(`Missing shipping weight`, itemId);
|
||||
}
|
||||
}
|
||||
|
||||
validateGoogleProductCategory(item, itemId) {
|
||||
const categoryElement = item.getElementsByTagName('g:google_product_category')[0];
|
||||
if (categoryElement) {
|
||||
const category = categoryElement.textContent.trim();
|
||||
// Should be a numeric category ID
|
||||
if (!category.match(/^\d+$/)) {
|
||||
this.addError(`Invalid Google product category: "${category}" (should be numeric)`, itemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async validate() {
|
||||
console.log(`🔍 Validating products.xml: ${this.xmlFilePath}`);
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(this.xmlFilePath)) {
|
||||
this.addError(`File not found: ${this.xmlFilePath}`);
|
||||
return this.getResults();
|
||||
}
|
||||
|
||||
// Read and parse XML
|
||||
const xmlContent = fs.readFileSync(this.xmlFilePath, 'utf8');
|
||||
const doc = this.validateXmlStructure(xmlContent);
|
||||
|
||||
if (!doc) {
|
||||
return this.getResults();
|
||||
}
|
||||
|
||||
// Validate root structure
|
||||
if (!this.validateRootStructure(doc)) {
|
||||
return this.getResults();
|
||||
}
|
||||
|
||||
// Validate channel information
|
||||
this.validateChannelInfo(doc);
|
||||
|
||||
// Validate all items
|
||||
const items = doc.getElementsByTagName('item');
|
||||
console.log(`📦 Found ${items.length} product items to validate`);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
this.validateItem(items[i], i);
|
||||
}
|
||||
|
||||
return this.getResults();
|
||||
}
|
||||
|
||||
getResults() {
|
||||
const hasErrors = this.errors.length > 0;
|
||||
const hasWarnings = this.warnings.length > 0;
|
||||
|
||||
return {
|
||||
valid: !hasErrors,
|
||||
stats: this.stats,
|
||||
errors: this.errors,
|
||||
warnings: this.warnings,
|
||||
summary: {
|
||||
totalIssues: this.errors.length + this.warnings.length,
|
||||
errorCount: this.errors.length,
|
||||
warningCount: this.warnings.length,
|
||||
validationPassed: !hasErrors
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
printResults(results) {
|
||||
console.log('\n📊 Validation Results:');
|
||||
console.log(` - Total items: ${results.stats.totalItems}`);
|
||||
console.log(` - Valid items: ${results.stats.validItems}`);
|
||||
console.log(` - Invalid items: ${results.stats.invalidItems}`);
|
||||
|
||||
if (results.errors.length > 0) {
|
||||
console.log(`\n❌ Errors (${results.errors.length}):`);
|
||||
results.errors.forEach((error, index) => {
|
||||
const itemInfo = error.itemId ? ` [${error.itemId}]` : '';
|
||||
console.log(` ${index + 1}. ${error.message}${itemInfo}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (results.warnings.length > 0) {
|
||||
console.log(`\n⚠️ Warnings (${results.warnings.length}):`);
|
||||
results.warnings.slice(0, 10).forEach((warning, index) => {
|
||||
const itemInfo = warning.itemId ? ` [${warning.itemId}]` : '';
|
||||
console.log(` ${index + 1}. ${warning.message}${itemInfo}`);
|
||||
});
|
||||
|
||||
if (results.warnings.length > 10) {
|
||||
console.log(` ... and ${results.warnings.length - 10} more warnings`);
|
||||
}
|
||||
}
|
||||
|
||||
if (results.valid) {
|
||||
console.log('\n✅ Validation passed! products.xml is valid for Google Shopping.');
|
||||
} else {
|
||||
console.log('\n❌ Validation failed! Please fix the errors above.');
|
||||
}
|
||||
|
||||
return results.valid;
|
||||
}
|
||||
}
|
||||
|
||||
// CLI usage
|
||||
if (require.main === module) {
|
||||
const xmlFilePath = process.argv[2] || path.join(__dirname, '../dist/products.xml');
|
||||
|
||||
const validator = new ProductsXmlValidator(xmlFilePath);
|
||||
validator.validate().then(results => {
|
||||
const isValid = validator.printResults(results);
|
||||
process.exit(isValid ? 0 : 1);
|
||||
}).catch(error => {
|
||||
console.error('❌ Validation failed:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = ProductsXmlValidator;
|
||||
245
src/App.js
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect, useRef, useContext, lazy, Suspense } from "react";
|
||||
import React, { useState, useEffect, lazy, Suspense } from "react";
|
||||
import { createTheme } from "@mui/material/styles";
|
||||
import {
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useNavigate
|
||||
} from "react-router-dom";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
@@ -14,11 +15,12 @@ import Fab from "@mui/material/Fab";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import SmartToyIcon from "@mui/icons-material/SmartToy";
|
||||
import PaletteIcon from "@mui/icons-material/Palette";
|
||||
import BugReportIcon from "@mui/icons-material/BugReport";
|
||||
import ScienceIcon from "@mui/icons-material/Science";
|
||||
|
||||
import SocketProvider from "./providers/SocketProvider.js";
|
||||
import SocketContext from "./contexts/SocketContext.js";
|
||||
import { CarouselProvider } from "./contexts/CarouselContext.js";
|
||||
import { ProductContextProvider } from "./context/ProductContext.js";
|
||||
import { CategoryContextProvider } from "./context/CategoryContext.js";
|
||||
import TitleUpdater from "./components/TitleUpdater.js";
|
||||
import config from "./config.js";
|
||||
import ScrollToTop from "./components/ScrollToTop.js";
|
||||
|
||||
@@ -32,11 +34,11 @@ import Header from "./components/Header.js";
|
||||
import Footer from "./components/Footer.js";
|
||||
import MainPageLayout from "./components/MainPageLayout.js";
|
||||
|
||||
// Lazy load all route components to reduce initial bundle size
|
||||
const Content = lazy(() => import(/* webpackChunkName: "content" */ "./components/Content.js"));
|
||||
const ProductDetailWithSocket = lazy(() => import(/* webpackChunkName: "product-detail" */ "./components/ProductDetailWithSocket.js"));
|
||||
const ProfilePageWithSocket = lazy(() => import(/* webpackChunkName: "profile" */ "./pages/ProfilePage.js"));
|
||||
const ResetPassword = lazy(() => import(/* webpackChunkName: "reset-password" */ "./pages/ResetPassword.js"));
|
||||
// TEMPORARILY DISABLE ALL LAZY LOADING TO ELIMINATE CircularProgress
|
||||
import Content from "./components/Content.js";
|
||||
import ProductDetail from "./components/ProductDetail.js";
|
||||
import ProfilePage from "./pages/ProfilePage.js";
|
||||
import ResetPassword from "./pages/ResetPassword.js";
|
||||
|
||||
// Lazy load admin pages - only loaded when admin users access them
|
||||
const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js"));
|
||||
@@ -48,6 +50,7 @@ const Datenschutz = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/D
|
||||
const AGB = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/AGB.js"));
|
||||
//const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); <Route path="/404" element={<NotFound404 />} />
|
||||
const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js"));
|
||||
const CategoriesPage = lazy(() => import(/* webpackChunkName: "categories" */ "./pages/CategoriesPage.js"));
|
||||
const Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
|
||||
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
|
||||
const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js"));
|
||||
@@ -63,44 +66,32 @@ const ThcTestPage = lazy(() => import(/* webpackChunkName: "thc-test" */ "./page
|
||||
// Lazy load payment success page
|
||||
const PaymentSuccess = lazy(() => import(/* webpackChunkName: "payment" */ "./components/PaymentSuccess.js"));
|
||||
|
||||
// Lazy load prerender component (development testing only)
|
||||
const PrerenderHome = lazy(() => import(/* webpackChunkName: "prerender-home" */ "./PrerenderHome.js"));
|
||||
|
||||
// Import theme from separate file to reduce main bundle size
|
||||
import defaultTheme from "./theme.js";
|
||||
// Lazy load theme customizer for development only
|
||||
const ThemeCustomizerDialog = lazy(() => import(/* webpackChunkName: "theme-customizer" */ "./components/ThemeCustomizerDialog.js"));
|
||||
import { createTheme } from "@mui/material/styles";
|
||||
|
||||
const deleteMessages = () => {
|
||||
console.log("Deleting messages");
|
||||
window.chatMessages = [];
|
||||
};
|
||||
|
||||
// Component to initialize telemetry service with socket
|
||||
const TelemetryInitializer = ({ socket }) => {
|
||||
const telemetryServiceRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (socket && !telemetryServiceRef.current) {
|
||||
//telemetryServiceRef.current = new TelemetryService(socket);
|
||||
//telemetryServiceRef.current.init();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (telemetryServiceRef.current) {
|
||||
telemetryServiceRef.current.destroy();
|
||||
telemetryServiceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
};
|
||||
|
||||
const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
|
||||
// State to manage chat visibility
|
||||
const [isChatOpen, setChatOpen] = useState(false);
|
||||
const [authVersion, setAuthVersion] = useState(0);
|
||||
// @note Theme customizer state for development mode
|
||||
const [isThemeCustomizerOpen, setThemeCustomizerOpen] = useState(false);
|
||||
// State to track active category for article pages
|
||||
const [articleCategoryId, setArticleCategoryId] = useState(null);
|
||||
|
||||
|
||||
// Remove duplicate theme state since it's passed as prop
|
||||
// const [dynamicTheme, setDynamicTheme] = useState(createTheme(defaultTheme));
|
||||
|
||||
// Get current location
|
||||
const location = useLocation();
|
||||
@@ -128,10 +119,44 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Extract categoryId from pathname if on category route
|
||||
// Clear article category when navigating away from article pages
|
||||
useEffect(() => {
|
||||
const isArticlePage = location.pathname.startsWith('/Artikel/');
|
||||
const isCategoryPage = location.pathname.startsWith('/Kategorie/');
|
||||
const isHomePage = location.pathname === '/';
|
||||
|
||||
// Only clear article category when navigating to non-article pages
|
||||
// (but keep it when going from category to article)
|
||||
if (!isArticlePage && !isCategoryPage && !isHomePage) {
|
||||
setArticleCategoryId(null);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
// Read article category from navigation state (when coming from product click)
|
||||
useEffect(() => {
|
||||
if (location.state && location.state.articleCategoryId !== undefined) {
|
||||
if (location.state.articleCategoryId !== null) {
|
||||
setArticleCategoryId(location.state.articleCategoryId);
|
||||
}
|
||||
// Clear the state so it doesn't persist on page refresh
|
||||
navigate(location.pathname, { replace: true, state: {} });
|
||||
}
|
||||
}, [location.state, navigate, location.pathname]);
|
||||
|
||||
// Extract categoryId from pathname if on category route, or use article category
|
||||
const getCategoryId = () => {
|
||||
const match = location.pathname.match(/^\/Kategorie\/(.+)$/);
|
||||
return match ? match[1] : null;
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
// For article pages, use the article category if available
|
||||
const isArticlePage = location.pathname.startsWith('/Artikel/');
|
||||
if (isArticlePage && articleCategoryId) {
|
||||
return articleCategoryId;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const categoryId = getCategoryId();
|
||||
@@ -156,35 +181,40 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
setThemeCustomizerOpen(!isThemeCustomizerOpen);
|
||||
};
|
||||
|
||||
// Handler to open GitHub issue reporting
|
||||
const handleReportIssue = () => {
|
||||
const issueTitle = encodeURIComponent("Fehlerbericht");
|
||||
const issueBody = encodeURIComponent(
|
||||
`**Seite:** ${window.location.href}
|
||||
**Browser:** ${navigator.userAgent.split(' ')[0]}
|
||||
**Datum:** ${new Date().toLocaleDateString('de-DE')}
|
||||
|
||||
**Problem:**
|
||||
[Beschreibe kurz das Problem]
|
||||
|
||||
**So ist es passiert:**
|
||||
1.
|
||||
2.
|
||||
|
||||
**Was sollte passieren:**
|
||||
[Was erwartet wurde]`
|
||||
);
|
||||
|
||||
const githubIssueUrl = `https://github.com/Growheads-de/shopFrontEnd/issues/new?title=${issueTitle}&body=${issueBody}`;
|
||||
window.open(githubIssueUrl, '_blank');
|
||||
};
|
||||
|
||||
// Check if we're in development mode
|
||||
const isDevelopment = process.env.NODE_ENV === "development";
|
||||
|
||||
const {socket,socketB} = useContext(SocketContext);
|
||||
console.log("AppContent: socket", socket);
|
||||
// Check if current route is a prerender test route
|
||||
const isPrerenderTestRoute = isDevelopment && location.pathname === "/prerenderTest/home";
|
||||
|
||||
// If it's a prerender test route, render it standalone without app layout
|
||||
if (isPrerenderTestRoute) {
|
||||
return (
|
||||
<LanguageProvider i18n={i18n}>
|
||||
<ThemeProvider theme={dynamicTheme}>
|
||||
<CssBaseline />
|
||||
<Suspense fallback={
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<CircularProgress color="primary" />
|
||||
</Box>
|
||||
}>
|
||||
<PrerenderHome />
|
||||
</Suspense>
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular app layout for all other routes
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -196,11 +226,19 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
bgcolor: "background.default",
|
||||
}}
|
||||
>
|
||||
<TitleUpdater />
|
||||
<ScrollToTop />
|
||||
<TelemetryInitializer socket={socket} />
|
||||
<Header active categoryId={categoryId} key={authVersion} />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box component="main" sx={{ flexGrow: 1 }}>
|
||||
<Suspense fallback={
|
||||
// Use prerender fallback if available, otherwise show loading spinner
|
||||
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: window.__PRERENDER_FALLBACK__.content,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
@@ -211,6 +249,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
>
|
||||
<CircularProgress color="primary" />
|
||||
</Box>
|
||||
)
|
||||
}>
|
||||
<CarouselProvider>
|
||||
<Routes>
|
||||
@@ -222,19 +261,19 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
{/* Category page - Render Content in parallel */}
|
||||
<Route
|
||||
path="/Kategorie/:categoryId"
|
||||
element={<Content socket={socket} socketB={socketB} />}
|
||||
element={<Content />}
|
||||
/>
|
||||
{/* Single product page */}
|
||||
<Route
|
||||
path="/Artikel/:seoName"
|
||||
element={<ProductDetailWithSocket />}
|
||||
element={<ProductDetail />}
|
||||
/>
|
||||
|
||||
{/* Search page - Render Content in parallel */}
|
||||
<Route path="/search" element={<Content socket={socket} socketB={socketB} />} />
|
||||
<Route path="/search" element={<Content />} />
|
||||
|
||||
{/* Profile page */}
|
||||
<Route path="/profile" element={<ProfilePageWithSocket />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
|
||||
{/* Payment success page for Mollie redirects */}
|
||||
<Route path="/payment/success" element={<PaymentSuccess />} />
|
||||
@@ -242,22 +281,23 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
{/* Reset password page */}
|
||||
<Route
|
||||
path="/resetPassword"
|
||||
element={<ResetPassword socket={socket} socketB={socketB} />}
|
||||
element={<ResetPassword />}
|
||||
/>
|
||||
|
||||
{/* Admin page */}
|
||||
<Route path="/admin" element={<AdminPage socket={socket} socketB={socketB} />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
|
||||
{/* Admin Users page */}
|
||||
<Route path="/admin/users" element={<UsersPage socket={socket} socketB={socketB} />} />
|
||||
<Route path="/admin/users" element={<UsersPage />} />
|
||||
|
||||
{/* Admin Server Logs page */}
|
||||
<Route path="/admin/logs" element={<ServerLogsPage socket={socket} socketB={socketB} />} />
|
||||
<Route path="/admin/logs" element={<ServerLogsPage />} />
|
||||
|
||||
{/* Legal pages */}
|
||||
<Route path="/datenschutz" element={<Datenschutz />} />
|
||||
<Route path="/agb" element={<AGB />} />
|
||||
<Route path="/sitemap" element={<Sitemap />} />
|
||||
<Route path="/Kategorien" element={<CategoriesPage />} />
|
||||
<Route path="/impressum" element={<Impressum />} />
|
||||
<Route
|
||||
path="/batteriegesetzhinweise"
|
||||
@@ -266,7 +306,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
<Route path="/widerrufsrecht" element={<Widerrufsrecht />} />
|
||||
|
||||
{/* Grow Tent Configurator */}
|
||||
<Route path="/Konfigurator" element={<GrowTentKonfigurator socket={socket} socketB={socketB} />} />
|
||||
<Route path="/Konfigurator" element={<GrowTentKonfigurator />} />
|
||||
|
||||
{/* Separate pages that are truly different */}
|
||||
<Route path="/presseverleih" element={<PresseverleihPage />} />
|
||||
@@ -280,11 +320,20 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
</Box>
|
||||
{/* Conditionally render the Chat Assistant */}
|
||||
{isChatOpen && (
|
||||
<Suspense fallback={<CircularProgress size={20} />}>
|
||||
<Suspense fallback={
|
||||
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: window.__PRERENDER_FALLBACK__.content,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CircularProgress size={20} />
|
||||
)
|
||||
}>
|
||||
<ChatAssistant
|
||||
open={isChatOpen}
|
||||
onClose={handleChatClose}
|
||||
socket={socket}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
@@ -306,7 +355,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
|
||||
{/* GitHub Issue Reporter FAB */}
|
||||
{/* GitHub Issue Reporter FAB
|
||||
<Tooltip title="Fehler oder Problem melden" placement="left">
|
||||
<Fab
|
||||
color="error"
|
||||
@@ -321,7 +370,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
>
|
||||
<BugReportIcon sx={{ fontSize: "1.2rem" }} />
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
</Tooltip>*/}
|
||||
|
||||
{/* Development-only Theme Customizer FAB */}
|
||||
{isDevelopment && (
|
||||
@@ -342,9 +391,38 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Development-only Prerender Test FAB */}
|
||||
{isDevelopment && (
|
||||
<Tooltip title="Test Prerender Home" placement="left">
|
||||
<Fab
|
||||
color="warning"
|
||||
aria-label="prerender test"
|
||||
size="small"
|
||||
sx={{
|
||||
position: "fixed",
|
||||
bottom: 31,
|
||||
right: 75,
|
||||
}}
|
||||
onClick={() => navigate('/prerenderTest/home')}
|
||||
>
|
||||
<ScienceIcon sx={{ fontSize: "1.2rem" }} />
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Development-only Theme Customizer Dialog */}
|
||||
{isDevelopment && isThemeCustomizerOpen && (
|
||||
<Suspense fallback={<CircularProgress size={20} />}>
|
||||
<Suspense fallback={
|
||||
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: window.__PRERENDER_FALLBACK__.content,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CircularProgress size={20} />
|
||||
)
|
||||
}>
|
||||
<ThemeCustomizerDialog
|
||||
open={isThemeCustomizerOpen}
|
||||
onClose={() => setThemeCustomizerOpen(false)}
|
||||
@@ -378,27 +456,16 @@ const App = () => {
|
||||
return (
|
||||
<LanguageProvider i18n={i18n}>
|
||||
<ThemeProvider theme={dynamicTheme}>
|
||||
<ProductContextProvider>
|
||||
<CategoryContextProvider>
|
||||
<CssBaseline />
|
||||
<SocketProvider
|
||||
url={config.apiBaseUrl}
|
||||
fallback={
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
}}
|
||||
>
|
||||
<CircularProgress color="primary" />
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<AppContent
|
||||
currentTheme={currentTheme}
|
||||
dynamicTheme={dynamicTheme}
|
||||
onThemeChange={handleThemeChange}
|
||||
/>
|
||||
</SocketProvider>
|
||||
</CategoryContextProvider>
|
||||
</ProductContextProvider>
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
);
|
||||
|
||||
@@ -44,7 +44,7 @@ const PrerenderAppContent = (socket) => (
|
||||
<CategoryList categoryId={209} activeCategoryId={null} socket={socket}/>
|
||||
</AppBar>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box component="main" sx={{ flexGrow: 1 }}>
|
||||
<CarouselProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<MainPageLayout />} />
|
||||
|
||||
118
src/PrerenderCategoriesPage.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import LegalPage from './pages/LegalPage.js';
|
||||
import CategoryBox from './components/CategoryBox.js';
|
||||
|
||||
const PrerenderCategoriesPage = ({ categoryData }) => {
|
||||
// Helper function to recursively collect all categories from the tree
|
||||
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;
|
||||
};
|
||||
|
||||
// The categoryData passed prop is the root tree (id: 209)
|
||||
const rootTree = categoryData;
|
||||
|
||||
const renderLevel1Section = (l1Node) => {
|
||||
// Collect all descendants (excluding the L1 node itself, which collectAllCategories would include first)
|
||||
const descendants = collectAllCategories(l1Node).slice(1);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
key={l1Node.id}
|
||||
elevation={1}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
alignItems: { xs: 'flex-start', md: 'flex-start' },
|
||||
gap: 3
|
||||
}}
|
||||
>
|
||||
{/* Level 1 Header/Box */}
|
||||
<Box sx={{
|
||||
minWidth: '150px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1
|
||||
}}>
|
||||
<CategoryBox
|
||||
id={l1Node.id}
|
||||
name={l1Node.name}
|
||||
seoName={l1Node.seoName}
|
||||
sx={{
|
||||
boxShadow: 4,
|
||||
width: '150px',
|
||||
height: '150px'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Descendants area */}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2
|
||||
}}>
|
||||
{descendants.map((cat) => (
|
||||
<CategoryBox
|
||||
key={cat.id}
|
||||
id={cat.id}
|
||||
name={cat.name}
|
||||
seoName={cat.seoName}
|
||||
sx={{
|
||||
width: '100px',
|
||||
height: '100px',
|
||||
minWidth: '100px',
|
||||
minHeight: '100px',
|
||||
boxShadow: 1,
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<Box>
|
||||
<Box>
|
||||
{rootTree && rootTree.children && rootTree.children.map((child) => (
|
||||
renderLevel1Section(child)
|
||||
))}
|
||||
{(!rootTree || !rootTree.children || rootTree.children.length === 0) && (
|
||||
<Typography>Keine Kategorien gefunden.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return <LegalPage title="Kategorien" content={content} />;
|
||||
};
|
||||
|
||||
export default PrerenderCategoriesPage;
|
||||
@@ -3,7 +3,7 @@ import { Box, AppBar, Toolbar, Container, Typography, Grid, Card, CardMedia, Car
|
||||
import Footer from './components/Footer.js';
|
||||
import { Logo, SearchBar, CategoryList } from './components/header/index.js';
|
||||
|
||||
const PrerenderCategory = ({ categoryId, categoryName, categorySeoName, productData }) => {
|
||||
const PrerenderCategory = ({ categoryId, categoryName, categorySeoName: _categorySeoName, productData }) => {
|
||||
const products = productData?.products || [];
|
||||
|
||||
return (
|
||||
@@ -111,7 +111,7 @@ const PrerenderCategory = ({ categoryId, categoryName, categorySeoName, productD
|
||||
component="img"
|
||||
height="200"
|
||||
image={product.pictureList && product.pictureList.trim()
|
||||
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.jpg`
|
||||
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.avif`
|
||||
: '/assets/images/nopicture.jpg'
|
||||
}
|
||||
alt={product.name}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
const React = require('react');
|
||||
const {
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Container
|
||||
} = require('@mui/material');
|
||||
const Footer = require('./components/Footer.js').default;
|
||||
const { Logo, CategoryList } = require('./components/header/index.js');
|
||||
const MainPageLayout = require('./components/MainPageLayout.js').default;
|
||||
const { CarouselProvider } = require('./contexts/CarouselContext.js');
|
||||
} from '@mui/material';
|
||||
import Footer from './components/Footer.js';
|
||||
import { Logo, CategoryList } from './components/header/index.js';
|
||||
|
||||
|
||||
class PrerenderHome extends React.Component {
|
||||
render() {
|
||||
@@ -29,10 +28,14 @@ class PrerenderHome extends React.Component {
|
||||
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
|
||||
React.createElement(
|
||||
Toolbar,
|
||||
{ sx: { minHeight: 64 } },
|
||||
{ sx: { minHeight: 64, py: { xs: 0.5, sm: 0 } } },
|
||||
React.createElement(
|
||||
Container,
|
||||
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center' } },
|
||||
{ maxWidth: 'lg', sx: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
px: { xs: 0, sm: 3 }
|
||||
} },
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
@@ -50,24 +53,78 @@ class PrerenderHome extends React.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 }
|
||||
}
|
||||
},
|
||||
React.createElement(Logo)
|
||||
React.createElement(Logo),
|
||||
// Invisible SearchBar placeholder on desktop
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
flexGrow: 1,
|
||||
height: 41, // Reserve space for SearchBar
|
||||
opacity: 0 // Invisible placeholder
|
||||
}
|
||||
}
|
||||
),
|
||||
// Invisible ButtonGroup placeholder
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: 'flex',
|
||||
alignItems: { xs: 'flex-end', sm: 'center' },
|
||||
transform: { xs: 'translateY(4px) translateX(9px)', sm: 'none' },
|
||||
ml: { xs: 0, sm: 0 },
|
||||
gap: { xs: 0.5, sm: 1 },
|
||||
opacity: 0 // Invisible placeholder
|
||||
}
|
||||
},
|
||||
// Placeholder for LanguageSwitcher (approx width)
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { width: 40, height: 40 } }
|
||||
),
|
||||
// Placeholder for LoginComponent (approx width)
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { width: 40, height: 40 } }
|
||||
),
|
||||
// Placeholder for Cart button (approx width)
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { width: 48, height: 40, ml: 1 } }
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
// Invisible SearchBar placeholder on mobile
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
width: '100%',
|
||||
mt: { xs: 1, sm: 0 },
|
||||
mb: { xs: 0.5, sm: 0 },
|
||||
px: { xs: 0, sm: 0 },
|
||||
height: 41, // Reserve space for SearchBar
|
||||
opacity: 0 // Invisible placeholder
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
React.createElement(CategoryList, { categoryId: 209, activeCategoryId: null })
|
||||
),
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { flexGrow: 1 } },
|
||||
React.createElement(CarouselProvider, null, React.createElement(MainPageLayout))
|
||||
),
|
||||
React.createElement(Footer)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { default: PrerenderHome };
|
||||
export default PrerenderHome;
|
||||
@@ -1,13 +1,13 @@
|
||||
const React = require('react');
|
||||
const {
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Container
|
||||
} = require('@mui/material');
|
||||
const Footer = require('./components/Footer.js').default;
|
||||
const { Logo } = require('./components/header/index.js');
|
||||
const NotFound404 = require('./pages/NotFound404.js').default;
|
||||
} from '@mui/material';
|
||||
import Footer from './components/Footer.js';
|
||||
import { Logo } from './components/header/index.js';
|
||||
import NotFound404 from './pages/NotFound404.js';
|
||||
|
||||
class PrerenderNotFound extends React.Component {
|
||||
render() {
|
||||
@@ -89,4 +89,4 @@ class PrerenderNotFound extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { default: PrerenderNotFound };
|
||||
export default PrerenderNotFound;
|
||||
@@ -1,18 +1,25 @@
|
||||
const React = require('react');
|
||||
const {
|
||||
import React from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardMedia,
|
||||
Grid,
|
||||
Box,
|
||||
Chip,
|
||||
Stack,
|
||||
AppBar,
|
||||
Toolbar
|
||||
} = require('@mui/material');
|
||||
const Footer = require('./components/Footer.js').default;
|
||||
const { Logo } = require('./components/header/index.js');
|
||||
Toolbar,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import Footer from './components/Footer.js';
|
||||
import { Logo } from './components/header/index.js';
|
||||
import ProductImage from './components/ProductImage.js';
|
||||
|
||||
// Utility function to clean product names by removing trailing number in parentheses
|
||||
const cleanProductName = (name) => {
|
||||
if (!name) return "";
|
||||
// Remove patterns like " (1)", " (3)", " (10)" at the end of the string
|
||||
return name.replace(/\s*\(\d+\)\s*$/, "").trim();
|
||||
};
|
||||
|
||||
class PrerenderProduct extends React.Component {
|
||||
render() {
|
||||
@@ -20,21 +27,29 @@ class PrerenderProduct extends React.Component {
|
||||
|
||||
if (!productData) {
|
||||
return React.createElement(
|
||||
Container,
|
||||
{ maxWidth: 'lg', sx: { py: 4 } },
|
||||
Box,
|
||||
{ sx: { p: 4, textAlign: "center" } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h4', component: 'h1', gutterBottom: true },
|
||||
'Product not found'
|
||||
{ variant: 'h5', gutterBottom: true },
|
||||
'Produkt nicht gefunden'
|
||||
),
|
||||
React.createElement(
|
||||
Typography,
|
||||
null,
|
||||
'Das gesuchte Produkt existiert nicht oder wurde entfernt.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const product = productData.product;
|
||||
const attributes = productData.attributes || [];
|
||||
const mainImage = product.pictureList && product.pictureList.trim()
|
||||
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.jpg`
|
||||
: '/assets/images/nopicture.jpg';
|
||||
|
||||
// Format price with tax
|
||||
const priceWithTax = new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(product.price);
|
||||
|
||||
return React.createElement(
|
||||
Box,
|
||||
@@ -53,137 +68,497 @@ class PrerenderProduct extends React.Component {
|
||||
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
|
||||
React.createElement(
|
||||
Toolbar,
|
||||
{ sx: { minHeight: 64 } },
|
||||
{ sx: { minHeight: 64, py: { xs: 0.5, sm: 0 } } },
|
||||
React.createElement(
|
||||
Container,
|
||||
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center' } },
|
||||
React.createElement(Logo)
|
||||
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center', px: { xs: 0, sm: 3 } } },
|
||||
// Desktop: simple layout, Mobile: column layout with SearchBar space
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
flexDirection: { xs: 'column', sm: 'row' }
|
||||
}
|
||||
},
|
||||
// First row: Logo and invisible placeholders to match SPA layout
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
justifyContent: { xs: 'space-between', sm: 'flex-start' }, // Match SPA layout
|
||||
minHeight: { xs: 52, sm: 'auto' },
|
||||
px: { xs: 0, sm: 0 }
|
||||
}
|
||||
},
|
||||
React.createElement(Logo),
|
||||
// Invisible SearchBar placeholder on desktop to match SPA spacing
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
flexGrow: 1,
|
||||
mx: { xs: 0, sm: 2, md: 4 },
|
||||
visibility: 'hidden',
|
||||
height: 40 // Match SearchBar height
|
||||
}
|
||||
}
|
||||
),
|
||||
// Invisible ButtonGroup placeholder to match SPA spacing
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: { xs: 'flex', sm: 'flex' },
|
||||
alignItems: { xs: 'flex-end', sm: 'center' },
|
||||
transform: { xs: 'translateY(4px) translateX(9px)', sm: 'none' },
|
||||
ml: { xs: 0, sm: 0 },
|
||||
visibility: 'hidden',
|
||||
width: { xs: 'auto', sm: '120px' }, // Approximate ButtonGroup width
|
||||
height: 40
|
||||
}
|
||||
}
|
||||
)
|
||||
),
|
||||
// Second row: SearchBar placeholder only on mobile
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
width: '100%',
|
||||
mt: { xs: 1, sm: 0 },
|
||||
mb: { xs: 0.5, sm: 0 },
|
||||
px: { xs: 0, sm: 0 },
|
||||
height: 41, // Small TextField height
|
||||
visibility: 'hidden'
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { flexGrow: 1 } },
|
||||
React.createElement(
|
||||
Container,
|
||||
{ maxWidth: 'lg', sx: { py: 4, flexGrow: 1 } },
|
||||
React.createElement(
|
||||
Grid,
|
||||
{ container: true, spacing: 4 },
|
||||
// Product Image
|
||||
React.createElement(
|
||||
Grid,
|
||||
{ item: true, xs: 12, md: 6 },
|
||||
React.createElement(
|
||||
Card,
|
||||
{ sx: { height: '100%' } },
|
||||
React.createElement(
|
||||
CardMedia,
|
||||
{
|
||||
component: 'img',
|
||||
height: '400',
|
||||
image: mainImage,
|
||||
alt: product.name,
|
||||
sx: { objectFit: 'contain', p: 2 }
|
||||
maxWidth: "lg",
|
||||
sx: {
|
||||
p: { xs: 2, md: 2 },
|
||||
pb: { xs: 4, md: 8 },
|
||||
flexGrow: 1
|
||||
}
|
||||
},
|
||||
// Back button (breadcrumbs section)
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
mb: 2,
|
||||
position: ["-webkit-sticky", "sticky"],
|
||||
top: {
|
||||
xs: "80px",
|
||||
sm: "80px",
|
||||
md: "80px",
|
||||
lg: "80px",
|
||||
},
|
||||
left: 0,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
zIndex: 999, // Just below the AppBar
|
||||
py: 0,
|
||||
px: 2,
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
ml: { xs: 0, md: 0 },
|
||||
display: "inline-flex",
|
||||
px: 0,
|
||||
py: 1,
|
||||
backgroundColor: "#2e7d32", // primary dark green
|
||||
borderRadius: 1,
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: "body2", color: "text.secondary" },
|
||||
React.createElement(
|
||||
'a',
|
||||
{
|
||||
href: "#",
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
},
|
||||
style: {
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
textDecoration: "none",
|
||||
color: "#fff",
|
||||
fontWeight: "bold",
|
||||
cursor: "pointer"
|
||||
}
|
||||
},
|
||||
this.props.t ? this.props.t('common.back') : 'Zurück'
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", md: "row" },
|
||||
gap: 4,
|
||||
background: "#fff",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
}
|
||||
},
|
||||
// Product Image Section
|
||||
React.createElement(
|
||||
ProductImage,
|
||||
{
|
||||
product: product,
|
||||
socket: null,
|
||||
socketB: null,
|
||||
fullscreenOpen: false,
|
||||
onOpenFullscreen: null,
|
||||
onCloseFullscreen: null
|
||||
}
|
||||
),
|
||||
// Product Details Section
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
flex: "1 1 60%",
|
||||
p: { xs: 2, md: 4 },
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}
|
||||
},
|
||||
// Product identifiers
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { mb: 1 } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'body2', color: 'text.secondary' },
|
||||
(this.props.t ? this.props.t('product.articleNumber') : 'Artikelnummer')+': '+product.articleNumber+' '+(product.gtin ? ` | GTIN: ${product.gtin}` : "")
|
||||
)
|
||||
),
|
||||
// Product title
|
||||
React.createElement(
|
||||
Typography,
|
||||
{
|
||||
variant: 'h4',
|
||||
component: 'h1',
|
||||
gutterBottom: true,
|
||||
sx: {
|
||||
fontWeight: 600,
|
||||
color: "#333"
|
||||
}
|
||||
},
|
||||
cleanProductName(product.name)
|
||||
),
|
||||
// Manufacturer if available - exact match to SPA: only render Box if manufacturer exists
|
||||
product.manufacturer && React.createElement(
|
||||
Box,
|
||||
{ sx: { display: "flex", alignItems: "center", mb: 2 } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'body2', sx: { fontStyle: "italic" } },
|
||||
(this.props.t ? this.props.t('product.manufacturer') : 'Hersteller')+': '+product.manufacturer
|
||||
)
|
||||
),
|
||||
// Attribute images and chips with action buttons section - exact replica of SPA version
|
||||
// SPA condition: (attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert]))
|
||||
// This essentially means "if there are any attributes at all"
|
||||
// For products with no attributes (like Vakuumbeutel), this section should NOT render
|
||||
(attributes.length > 0) && React.createElement(
|
||||
Box,
|
||||
{ sx: { display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2, gap: 2 } },
|
||||
// Left side - attributes
|
||||
React.createElement(
|
||||
Stack,
|
||||
{ direction: 'row', spacing: 2, sx: { flexWrap: "wrap", gap: 1, flex: 1 } },
|
||||
// In prerender: attributes.filter(attribute => attributeImages[attribute.kMerkmalWert]) = [] (empty)
|
||||
// Then: attributes.filter(attribute => !attributeImages[attribute.kMerkmalWert]) = all attributes as Chips
|
||||
...attributes.map((attribute, index) =>
|
||||
React.createElement(
|
||||
Chip,
|
||||
{
|
||||
key: attribute.kMerkmalWert || index,
|
||||
label: attribute.cWert,
|
||||
disabled: true,
|
||||
sx: { mb: 1 }
|
||||
}
|
||||
)
|
||||
)
|
||||
),
|
||||
// Product Details
|
||||
React.createElement(
|
||||
Grid,
|
||||
{ item: true, xs: 12, md: 6 },
|
||||
// Right side - action buttons (exact replica with invisible versions)
|
||||
React.createElement(
|
||||
Stack,
|
||||
{ spacing: 3 },
|
||||
{ direction: 'column', spacing: 1, sx: { flexShrink: 0 } },
|
||||
// "Frage zum Artikel" button - exact replica but invisible
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h3', component: 'h1', gutterBottom: true },
|
||||
product.name
|
||||
Button,
|
||||
{
|
||||
variant: "outlined",
|
||||
size: "small",
|
||||
sx: {
|
||||
fontSize: "0.75rem",
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
minWidth: "auto",
|
||||
whiteSpace: "nowrap",
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none"
|
||||
}
|
||||
},
|
||||
"Frage zum Artikel"
|
||||
),
|
||||
// "Artikel Bewerten" button - exact replica but invisible
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h6', color: 'text.secondary' },
|
||||
(this.props.t ? this.props.t('product.articleNumber') : 'Artikelnummer')+': '+product.articleNumber+' '+(product.gtin ? ` | GTIN: ${product.gtin}` : "")
|
||||
Button,
|
||||
{
|
||||
variant: "outlined",
|
||||
size: "small",
|
||||
sx: {
|
||||
fontSize: "0.75rem",
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
minWidth: "auto",
|
||||
whiteSpace: "nowrap",
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none"
|
||||
}
|
||||
},
|
||||
"Artikel Bewerten"
|
||||
),
|
||||
React.createElement(
|
||||
// "Verfügbarkeit anfragen" button - conditional, exact replica but invisible
|
||||
(product.available !== 1 && product.availableSupplier !== 1) && React.createElement(
|
||||
Button,
|
||||
{
|
||||
variant: "outlined",
|
||||
size: "small",
|
||||
sx: {
|
||||
fontSize: "0.75rem",
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
minWidth: "auto",
|
||||
whiteSpace: "nowrap",
|
||||
borderColor: "warning.main",
|
||||
color: "warning.main",
|
||||
"&:hover": {
|
||||
borderColor: "warning.dark",
|
||||
backgroundColor: "warning.light"
|
||||
},
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none"
|
||||
}
|
||||
},
|
||||
"Verfügbarkeit anfragen"
|
||||
)
|
||||
)
|
||||
),
|
||||
// Weight
|
||||
(product.weight && product.weight > 0) ? React.createElement(
|
||||
Box,
|
||||
{ sx: { mt: 1 } },
|
||||
{ sx: { mb: 2 } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h4', color: 'primary', fontWeight: 'bold' },
|
||||
new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(product.price)
|
||||
),
|
||||
product.vat && React.createElement(
|
||||
Typography,
|
||||
{ variant: 'body2', color: 'text.secondary' },
|
||||
`inkl. ${product.vat}% MwSt.`
|
||||
),
|
||||
(this.props.t ? this.props.t('product.weight', { weight: product.weight.toFixed(1).replace(".", ",") }) : `Gewicht: ${product.weight.toFixed(1).replace(".", ",")} kg`)
|
||||
)
|
||||
) : null,
|
||||
// Price and availability section
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
mt: "auto",
|
||||
transform: "translateY(-1px)", // Move 1px up
|
||||
p: 3,
|
||||
background: "#f9f9f9",
|
||||
borderRadius: 2,
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
justifyContent: "space-between",
|
||||
alignItems: { xs: "flex-start", sm: "flex-start" },
|
||||
gap: 2,
|
||||
}
|
||||
},
|
||||
// Left side - Price information (exact match to SPA)
|
||||
React.createElement(
|
||||
Box,
|
||||
null,
|
||||
React.createElement(
|
||||
Typography,
|
||||
{
|
||||
variant: 'body1',
|
||||
color: product.available ? 'success.main' : 'error.main',
|
||||
fontWeight: 'medium',
|
||||
sx: { mt: 1 }
|
||||
variant: "h4",
|
||||
color: "primary",
|
||||
sx: { fontWeight: "bold" }
|
||||
},
|
||||
product.available ? '✅ Verfügbar' : '❌ Nicht verfügbar'
|
||||
)
|
||||
priceWithTax
|
||||
),
|
||||
product.description && React.createElement(
|
||||
Box,
|
||||
{ sx: { mt: 2 } },
|
||||
// VAT info (exact match to SPA - direct Typography, no wrapper)
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h6', gutterBottom: true },
|
||||
'Beschreibung'
|
||||
{ variant: 'body2', color: 'text.secondary' },
|
||||
(this.props.t ? this.props.t('product.inclVat', { vat: product.vat }) : `inkl. ${product.vat}% MwSt.`) +
|
||||
(product.cGrundEinheit && product.fGrundPreis ?
|
||||
`; ${new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(product.fGrundPreis)}/${product.cGrundEinheit}` :
|
||||
"")
|
||||
),
|
||||
// Shipping class (exact match to SPA - direct Typography, conditional render)
|
||||
product.versandklasse &&
|
||||
product.versandklasse != "standard" &&
|
||||
product.versandklasse != "kostenlos" && React.createElement(
|
||||
Typography,
|
||||
{ variant: 'body2', color: 'text.secondary' },
|
||||
product.versandklasse
|
||||
)
|
||||
),
|
||||
// Right side - Complex cart button area structure (matching SPA exactly)
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
gap: 2,
|
||||
alignItems: "flex-start",
|
||||
}
|
||||
},
|
||||
// Empty steckling column placeholder - maintains flex positioning
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { display: "flex", flexDirection: "column" } }
|
||||
// Empty - no steckling for this product
|
||||
),
|
||||
// Main cart button column (exact match to SPA structure)
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}
|
||||
},
|
||||
// AddToCartButton placeholder - invisible button that reserves exact space
|
||||
React.createElement(
|
||||
Button,
|
||||
{
|
||||
variant: "contained",
|
||||
size: "large",
|
||||
sx: {
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none",
|
||||
height: "36px",
|
||||
width: "140px",
|
||||
minWidth: "140px",
|
||||
maxWidth: "140px"
|
||||
}
|
||||
},
|
||||
"In den Warenkorb"
|
||||
),
|
||||
// Delivery time Typography (exact match to SPA)
|
||||
React.createElement(
|
||||
Typography,
|
||||
{
|
||||
variant: 'caption',
|
||||
sx: {
|
||||
fontStyle: "italic",
|
||||
color: "text.secondary",
|
||||
textAlign: "center",
|
||||
mt: 1
|
||||
}
|
||||
},
|
||||
product.id && product.id.toString().endsWith("steckling") ?
|
||||
(this.props.t ? this.props.t('delivery.times.cutting14Days') : "Lieferzeit: 14 Tage") :
|
||||
product.available == 1 ?
|
||||
(this.props.t ? this.props.t('delivery.times.standard2to3Days') : "Lieferzeit: 2-3 Tage") :
|
||||
product.availableSupplier == 1 ?
|
||||
(this.props.t ? this.props.t('delivery.times.supplier7to9Days') : "Lieferzeit: 7-9 Tage") :
|
||||
""
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
// Product full description - separate card
|
||||
product.description && React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
mt: 4,
|
||||
p: 4,
|
||||
background: "#fff",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
mt: 2,
|
||||
lineHeight: 1.7,
|
||||
"& p": { mt: 0, mb: 2 },
|
||||
"& strong": { fontWeight: 600 },
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
'div',
|
||||
{
|
||||
dangerouslySetInnerHTML: { __html: product.description },
|
||||
dangerouslySetInnerHTML: {
|
||||
__html: sanitizeHtml(product.description, {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
|
||||
allowedAttributes: {
|
||||
'*': ['class', 'style'],
|
||||
'a': ['href', 'title'],
|
||||
'img': ['src', 'alt', 'width', 'height']
|
||||
},
|
||||
disallowedTagsMode: 'discard'
|
||||
})
|
||||
},
|
||||
style: {
|
||||
fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.5',
|
||||
color: '#33691E'
|
||||
lineHeight: '1.7',
|
||||
color: '#333'
|
||||
}
|
||||
}
|
||||
)
|
||||
),
|
||||
// Product specifications
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { mt: 2 } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h6', gutterBottom: true },
|
||||
'Produktdetails'
|
||||
),
|
||||
React.createElement(
|
||||
Stack,
|
||||
{ direction: 'row', spacing: 1, flexWrap: 'wrap', gap: 1 },
|
||||
product.manufacturer && React.createElement(
|
||||
Chip,
|
||||
{ label: `Hersteller: ${product.manufacturer}`, variant: 'outlined' }
|
||||
),
|
||||
product.weight && product.weight > 0 && React.createElement(
|
||||
Chip,
|
||||
{ label: `Gewicht: ${product.weight} kg`, variant: 'outlined' }
|
||||
),
|
||||
...attributes.map((attr, index) =>
|
||||
React.createElement(
|
||||
Chip,
|
||||
{
|
||||
key: index,
|
||||
label: `${attr.cName}: ${attr.cWert}`,
|
||||
variant: 'outlined',
|
||||
color: 'primary'
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -193,4 +568,4 @@ class PrerenderProduct extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { default: PrerenderProduct };
|
||||
export default PrerenderProduct;
|
||||
@@ -1,17 +1,11 @@
|
||||
const React = require('react');
|
||||
const {
|
||||
Box,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Container,
|
||||
import React from 'react';
|
||||
import {
|
||||
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;
|
||||
} from '@mui/material';
|
||||
import LegalPage from './pages/LegalPage.js';
|
||||
|
||||
const PrerenderSitemap = ({ categoryData }) => {
|
||||
// Process category data to flatten the hierarchy
|
||||
@@ -134,4 +128,4 @@ const PrerenderSitemap = ({ categoryData }) => {
|
||||
return React.createElement(LegalPage, { title: 'Sitemap', content: content });
|
||||
};
|
||||
|
||||
module.exports = { default: PrerenderSitemap };
|
||||
export default PrerenderSitemap;
|
||||
@@ -216,6 +216,7 @@ class AddToCartButton extends Component {
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleDecrement}
|
||||
aria-label="Menge verringern"
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<RemoveIcon />
|
||||
@@ -265,6 +266,7 @@ class AddToCartButton extends Component {
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleIncrement}
|
||||
aria-label="Menge erhöhen"
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<AddIcon />
|
||||
@@ -274,6 +276,7 @@ class AddToCartButton extends Component {
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleClearCart}
|
||||
aria-label={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "error.light" },
|
||||
@@ -287,6 +290,7 @@ class AddToCartButton extends Component {
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.toggleCart}
|
||||
aria-label={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "primary.light" },
|
||||
@@ -363,6 +367,7 @@ class AddToCartButton extends Component {
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleDecrement}
|
||||
aria-label="Menge verringern"
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<RemoveIcon />
|
||||
@@ -412,6 +417,7 @@ class AddToCartButton extends Component {
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleIncrement}
|
||||
aria-label="Menge erhöhen"
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<AddIcon />
|
||||
@@ -421,6 +427,7 @@ class AddToCartButton extends Component {
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleClearCart}
|
||||
aria-label={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "error.light" },
|
||||
@@ -434,6 +441,7 @@ class AddToCartButton extends Component {
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.toggleCart}
|
||||
aria-label={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "primary.light" },
|
||||
|
||||
@@ -62,11 +62,10 @@ class ArticleAvailabilityForm extends Component {
|
||||
// Emit data via socket
|
||||
console.log('Availability Inquiry Data to emit:', availabilityData);
|
||||
|
||||
if (this.props.socket) {
|
||||
this.props.socket.emit('availability_inquiry_submit', availabilityData);
|
||||
window.socketManager.emit('availability_inquiry_submit', availabilityData);
|
||||
|
||||
// Set up response handler
|
||||
this.props.socket.once('availability_inquiry_response', (response) => {
|
||||
window.socketManager.once('availability_inquiry_response', (response) => {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
@@ -80,7 +79,7 @@ class ArticleAvailabilityForm extends Component {
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.error || 'Ein Fehler ist aufgetreten'
|
||||
error: response.error || this.props.t("productDialogs.errorGeneric")
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,7 +88,6 @@ class ArticleAvailabilityForm extends Component {
|
||||
this.setState({ success: false, error: null });
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({ loading: true });
|
||||
|
||||
@@ -116,20 +114,21 @@ class ArticleAvailabilityForm extends Component {
|
||||
|
||||
render() {
|
||||
const { name, email, telegramId, notificationMethod, message, loading, success, error } = this.state;
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<Paper id="availability-form" sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
|
||||
Verfügbarkeit anfragen
|
||||
{t("productDialogs.availabilityTitle")}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Dieser Artikel ist derzeit nicht verfügbar. Gerne informieren wir Sie, sobald er wieder lieferbar ist.
|
||||
{t("productDialogs.availabilitySubtitle")}
|
||||
</Typography>
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
Vielen Dank für Ihre Anfrage! Wir werden Sie {notificationMethod === 'email' ? 'per E-Mail' : 'über Telegram'} informieren, sobald der Artikel wieder verfügbar ist.
|
||||
{notificationMethod === 'email' ? t("productDialogs.availabilitySuccessEmail") : t("productDialogs.availabilitySuccessTelegram")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -141,18 +140,18 @@ class ArticleAvailabilityForm extends Component {
|
||||
|
||||
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Name"
|
||||
label={t("productDialogs.nameLabel")}
|
||||
value={name}
|
||||
onChange={this.handleInputChange('name')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder="Ihr Name"
|
||||
placeholder={t("productDialogs.namePlaceholder")}
|
||||
/>
|
||||
|
||||
<FormControl component="fieldset" disabled={loading}>
|
||||
<FormLabel component="legend" sx={{ mb: 1 }}>
|
||||
Wie möchten Sie benachrichtigt werden?
|
||||
{t("productDialogs.notificationMethodLabel")}
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
value={notificationMethod}
|
||||
@@ -162,51 +161,51 @@ class ArticleAvailabilityForm extends Component {
|
||||
<FormControlLabel
|
||||
value="email"
|
||||
control={<Radio />}
|
||||
label="E-Mail"
|
||||
label={t("productDialogs.emailLabel")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="telegram"
|
||||
control={<Radio />}
|
||||
label="Telegram Bot"
|
||||
label={t("productDialogs.telegramBotLabel")}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{notificationMethod === 'email' && (
|
||||
<TextField
|
||||
label="E-Mail"
|
||||
label={t("productDialogs.emailLabel")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={this.handleInputChange('email')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder="ihre.email@example.com"
|
||||
placeholder={t("productDialogs.emailPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{notificationMethod === 'telegram' && (
|
||||
<TextField
|
||||
label="Telegram ID"
|
||||
label={t("productDialogs.telegramIdLabel")}
|
||||
value={telegramId}
|
||||
onChange={this.handleInputChange('telegramId')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder="@IhrTelegramName oder Telegram ID"
|
||||
helperText="Geben Sie Ihren Telegram-Benutzernamen (mit @) oder Ihre Telegram-ID ein"
|
||||
placeholder={t("productDialogs.telegramPlaceholder")}
|
||||
helperText={t("productDialogs.telegramHelper")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label="Nachricht (optional)"
|
||||
label={t("productDialogs.messageLabel")}
|
||||
value={message}
|
||||
onChange={this.handleInputChange('message')}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
disabled={loading}
|
||||
placeholder="Zusätzliche Informationen oder Fragen..."
|
||||
placeholder={t("productDialogs.messagePlaceholder")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
@@ -227,10 +226,10 @@ class ArticleAvailabilityForm extends Component {
|
||||
{loading ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Wird gesendet...
|
||||
{t("productDialogs.sending")}
|
||||
</>
|
||||
) : (
|
||||
'Verfügbarkeit anfragen'
|
||||
t("productDialogs.submitAvailability")
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -77,11 +77,11 @@ class ArticleQuestionForm extends Component {
|
||||
// Emit data via socket
|
||||
console.log('Article Question Data to emit:', questionData);
|
||||
|
||||
if (this.props.socket) {
|
||||
this.props.socket.emit('article_question_submit', questionData);
|
||||
|
||||
window.socketManager.emit('article_question_submit', questionData);
|
||||
|
||||
// Set up response handler
|
||||
this.props.socket.once('article_question_response', (response) => {
|
||||
window.socketManager.once('article_question_response', (response) => {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
@@ -98,7 +98,7 @@ class ArticleQuestionForm extends Component {
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.error || 'Ein Fehler ist aufgetreten'
|
||||
error: response.error || this.props.t("productDialogs.errorGeneric")
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,11 +107,10 @@ class ArticleQuestionForm extends Component {
|
||||
this.setState({ success: false, error: null });
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Fehler beim Verarbeiten der Fotos'
|
||||
error: this.props.t("productDialogs.errorPhotos")
|
||||
});
|
||||
}
|
||||
|
||||
@@ -141,20 +140,21 @@ class ArticleQuestionForm extends Component {
|
||||
|
||||
render() {
|
||||
const { name, email, question, loading, success, error } = this.state;
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
|
||||
Frage zum Artikel
|
||||
{t("productDialogs.questionTitle")}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Haben Sie eine Frage zu diesem Artikel? Wir helfen Ihnen gerne weiter.
|
||||
{t("productDialogs.questionSubtitle")}
|
||||
</Typography>
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
Vielen Dank für Ihre Frage! Wir werden uns schnellstmöglich bei Ihnen melden.
|
||||
{t("productDialogs.questionSuccess")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -166,28 +166,28 @@ class ArticleQuestionForm extends Component {
|
||||
|
||||
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Name"
|
||||
label={t("productDialogs.nameLabel")}
|
||||
value={name}
|
||||
onChange={this.handleInputChange('name')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder="Ihr Name"
|
||||
placeholder={t("productDialogs.namePlaceholder")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="E-Mail"
|
||||
label={t("productDialogs.emailLabel")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={this.handleInputChange('email')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder="ihre.email@example.com"
|
||||
placeholder={t("productDialogs.emailPlaceholder")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Ihre Frage"
|
||||
label={t("productDialogs.questionLabel")}
|
||||
value={question}
|
||||
onChange={this.handleInputChange('question')}
|
||||
required
|
||||
@@ -195,7 +195,7 @@ class ArticleQuestionForm extends Component {
|
||||
multiline
|
||||
rows={4}
|
||||
disabled={loading}
|
||||
placeholder="Beschreiben Sie Ihre Frage zu diesem Artikel..."
|
||||
placeholder={t("productDialogs.questionPlaceholder")}
|
||||
/>
|
||||
|
||||
<PhotoUpload
|
||||
@@ -203,7 +203,7 @@ class ArticleQuestionForm extends Component {
|
||||
onChange={this.handlePhotosChange}
|
||||
disabled={loading}
|
||||
maxFiles={3}
|
||||
label="Fotos zur Frage anhängen (optional)"
|
||||
label={t("productDialogs.photosLabelQuestion")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
@@ -220,10 +220,10 @@ class ArticleQuestionForm extends Component {
|
||||
{loading ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Wird gesendet...
|
||||
{t("productDialogs.sending")}
|
||||
</>
|
||||
) : (
|
||||
'Frage senden'
|
||||
t("productDialogs.submitQuestion")
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -85,11 +85,10 @@ class ArticleRatingForm extends Component {
|
||||
// Emit data via socket
|
||||
console.log('Article Rating Data to emit:', ratingData);
|
||||
|
||||
if (this.props.socket) {
|
||||
this.props.socket.emit('article_rating_submit', ratingData);
|
||||
window.socketManager.emit('article_rating_submit', ratingData);
|
||||
|
||||
// Set up response handler
|
||||
this.props.socket.once('article_rating_response', (response) => {
|
||||
window.socketManager.once('article_rating_response', (response) => {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
@@ -107,7 +106,7 @@ class ArticleRatingForm extends Component {
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.error || 'Ein Fehler ist aufgetreten'
|
||||
error: response.error || this.props.t("productDialogs.errorGeneric")
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,11 +115,10 @@ class ArticleRatingForm extends Component {
|
||||
this.setState({ success: false, error: null });
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Fehler beim Verarbeiten der Fotos'
|
||||
error: this.props.t("productDialogs.errorPhotos")
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,20 +149,21 @@ class ArticleRatingForm extends Component {
|
||||
|
||||
render() {
|
||||
const { name, email, rating, review, loading, success, error } = this.state;
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
|
||||
Artikel Bewerten
|
||||
{t("productDialogs.ratingTitle")}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Teilen Sie Ihre Erfahrungen mit diesem Artikel und helfen Sie anderen Kunden bei der Entscheidung.
|
||||
{t("productDialogs.ratingSubtitle")}
|
||||
</Typography>
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
Vielen Dank für Ihre Bewertung! Sie wird nach Prüfung veröffentlicht.
|
||||
{t("productDialogs.ratingSuccess")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -176,30 +175,30 @@ class ArticleRatingForm extends Component {
|
||||
|
||||
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Name"
|
||||
label={t("productDialogs.nameLabel")}
|
||||
value={name}
|
||||
onChange={this.handleInputChange('name')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder="Ihr Name"
|
||||
placeholder={t("productDialogs.namePlaceholder")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="E-Mail"
|
||||
label={t("productDialogs.emailLabel")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={this.handleInputChange('email')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder="ihre.email@example.com"
|
||||
helperText="Ihre E-Mail wird nicht veröffentlicht"
|
||||
placeholder={t("productDialogs.emailPlaceholder")}
|
||||
helperText={t("productDialogs.emailHelper")}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
Bewertung *
|
||||
{t("productDialogs.ratingLabel")}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Rating
|
||||
@@ -211,20 +210,20 @@ class ArticleRatingForm extends Component {
|
||||
emptyIcon={<StarIcon style={{ opacity: 0.55 }} fontSize="inherit" />}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{rating > 0 ? `${rating} von 5 Sternen` : 'Bitte bewerten'}
|
||||
{rating > 0 ? t("productDialogs.ratingStars", { rating }) : t("productDialogs.pleaseRate")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label="Ihre Bewertung (optional)"
|
||||
label={t("productDialogs.reviewLabel")}
|
||||
value={review}
|
||||
onChange={this.handleInputChange('review')}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
disabled={loading}
|
||||
placeholder="Beschreiben Sie Ihre Erfahrungen mit diesem Artikel..."
|
||||
placeholder={t("productDialogs.reviewPlaceholder")}
|
||||
/>
|
||||
|
||||
<PhotoUpload
|
||||
@@ -232,7 +231,7 @@ class ArticleRatingForm extends Component {
|
||||
onChange={this.handlePhotosChange}
|
||||
disabled={loading}
|
||||
maxFiles={5}
|
||||
label="Fotos zur Bewertung anhängen (optional)"
|
||||
label={t("productDialogs.photosLabelRating")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
@@ -249,10 +248,10 @@ class ArticleRatingForm extends Component {
|
||||
{loading ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Wird gesendet...
|
||||
{t("productDialogs.sending")}
|
||||
</>
|
||||
) : (
|
||||
'Bewertung abgeben'
|
||||
t("productDialogs.submitRating")
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -74,7 +74,6 @@ class CartDropdown extends Component {
|
||||
{cartItems.map((item) => (
|
||||
<CartItem
|
||||
key={item.id}
|
||||
socket={this.props.socket}
|
||||
item={item}
|
||||
id={item.id}
|
||||
/>
|
||||
|
||||
@@ -20,17 +20,16 @@ class CartItem extends Component {
|
||||
this.setState({image:window.tinyPicCache[picid],loading:false, error: false})
|
||||
}else{
|
||||
this.setState({image: null, loading: true, error: false});
|
||||
if(this.props.socket){
|
||||
this.props.socket.emit('getPic', { bildId:picid, size:'tiny' }, (res) => {
|
||||
|
||||
window.socketManager.emit('getPic', { bildId:picid, size:'tiny' }, (res) => {
|
||||
if(res.success){
|
||||
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
|
||||
this.setState({image: window.tinyPicCache[picid], loading: false});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleIncrement = () => {
|
||||
const { item, onQuantityChange } = this.props;
|
||||
@@ -76,11 +75,25 @@ class CartItem extends Component {
|
||||
component="div"
|
||||
sx={{ fontWeight: 'bold', mb: 0.5 }}
|
||||
>
|
||||
{item.seoName ? (
|
||||
<Link to={`/Artikel/${item.seoName}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
{item.name}
|
||||
</Link>
|
||||
) : (
|
||||
item.name
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
{item.komponenten && Array.isArray(item.komponenten) && (
|
||||
<Box sx={{ ml: 2, mb: 1 }}>
|
||||
{item.komponenten.map((comp, index) => (
|
||||
<Typography key={index} variant="body2" color="text.secondary">
|
||||
{comp.name}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1, mt: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
@@ -147,7 +160,7 @@ class CartItem extends Component {
|
||||
display: "block"
|
||||
}}
|
||||
>
|
||||
{this.props.id.toString().endsWith("steckling") ?
|
||||
{this.props.id?.toString().endsWith("steckling") ?
|
||||
(this.props.t ? this.props.t('delivery.times.cutting14Days') : "Lieferzeit: 14 Tage") :
|
||||
item.available == 1 ?
|
||||
(this.props.t ? this.props.t('delivery.times.standard2to3Days') : "Lieferzeit: 2-3 Tage") :
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SocketContext from '../contexts/SocketContext.js';
|
||||
|
||||
|
||||
// @note SwashingtonCP font is now loaded globally via index.css
|
||||
|
||||
@@ -22,7 +22,7 @@ const CategoryBox = ({
|
||||
const [imageUrl, setImageUrl] = useState(null);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const context = useContext(SocketContext);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl = null;
|
||||
@@ -47,7 +47,7 @@ const CategoryBox = ({
|
||||
// Create fresh blob URL from cached binary data
|
||||
try {
|
||||
const uint8Array = new Uint8Array(cachedImageData);
|
||||
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
|
||||
const blob = new Blob([uint8Array], { type: 'image/avif' });
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageUrl(objectUrl);
|
||||
setImageError(false);
|
||||
@@ -60,11 +60,10 @@ const CategoryBox = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// If socket is available and connected, fetch the image
|
||||
if (context && context.socket && context.socket.connected && id && !isLoading) {
|
||||
if (id && !isLoading) {
|
||||
setIsLoading(true);
|
||||
|
||||
context.socket.emit('getCategoryPic', { categoryId: id }, (response) => {
|
||||
window.socketManager.emit('getCategoryPic', { categoryId: id }, (response) => {
|
||||
setIsLoading(false);
|
||||
|
||||
if (response.success) {
|
||||
@@ -74,7 +73,7 @@ const CategoryBox = ({
|
||||
try {
|
||||
// Convert binary data to blob URL
|
||||
const uint8Array = new Uint8Array(imageData);
|
||||
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
|
||||
const blob = new Blob([uint8Array], { type: 'image/avif' });
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageUrl(objectUrl);
|
||||
setImageError(false);
|
||||
@@ -119,7 +118,7 @@ const CategoryBox = ({
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [context, context?.socket?.connected, id, isLoading]);
|
||||
}, [id, isLoading]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -159,7 +158,7 @@ const CategoryBox = ({
|
||||
position: 'relative',
|
||||
backgroundImage: ((typeof window !== 'undefined' && window.__PRERENDER_FALLBACK__) ||
|
||||
(typeof global !== 'undefined' && global.window && global.window.__PRERENDER_FALLBACK__))
|
||||
? `url("/assets/images/cat${id}.jpg")`
|
||||
? `url("/assets/images/cat${id}.avif")`
|
||||
: (imageUrl && !imageError ? `url("${imageUrl}")` : 'none'),
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
|
||||
@@ -81,18 +81,6 @@ class ChatAssistant extends Component {
|
||||
if (prevState.messages !== this.state.messages || prevState.isTyping !== this.state.isTyping) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected) {
|
||||
// Socket just connected, add listeners
|
||||
this.addSocketListeners();
|
||||
} else if (wasConnected && !isNowConnected) {
|
||||
// Socket just disconnected, remove listeners
|
||||
this.removeSocketListeners();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -104,19 +92,18 @@ class ChatAssistant extends Component {
|
||||
}
|
||||
|
||||
addSocketListeners = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
// Remove existing listeners first to avoid duplicates
|
||||
|
||||
this.removeSocketListeners();
|
||||
this.props.socket.on('aiassyResponse', this.handleBotResponse);
|
||||
this.props.socket.on('aiassyStatus', this.handleStateResponse);
|
||||
}
|
||||
window.socketManager.on('aiassyResponse', this.handleBotResponse);
|
||||
window.socketManager.on('aiassyStatus', this.handleStateResponse);
|
||||
|
||||
}
|
||||
|
||||
removeSocketListeners = () => {
|
||||
if (this.props.socket) {
|
||||
this.props.socket.off('aiassyResponse', this.handleBotResponse);
|
||||
this.props.socket.off('aiassyStatus', this.handleStateResponse);
|
||||
}
|
||||
|
||||
window.socketManager.off('aiassyResponse', this.handleBotResponse);
|
||||
window.socketManager.off('aiassyStatus', this.handleStateResponse);
|
||||
|
||||
}
|
||||
|
||||
handleBotResponse = (msgId,response) => {
|
||||
@@ -194,8 +181,8 @@ class ChatAssistant extends Component {
|
||||
};
|
||||
}, () => {
|
||||
// Emit message to socket server after state is updated
|
||||
if (userMessage.trim() && this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('aiassyMessage', userMessage);
|
||||
if (userMessage.trim()) {
|
||||
window.socketManager.emit('aiassyMessage', userMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -300,12 +287,10 @@ class ChatAssistant extends Component {
|
||||
reader.onloadend = () => {
|
||||
const base64Audio = reader.result.split(',')[1];
|
||||
// Send audio data to server
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('aiassyAudioMessage', {
|
||||
window.socketManager.emit('aiassyAudioMessage', {
|
||||
audio: base64Audio,
|
||||
format: 'wav'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -389,12 +374,12 @@ class ChatAssistant extends Component {
|
||||
reader.onloadend = () => {
|
||||
const base64Image = reader.result.split(',')[1];
|
||||
// Send image data to server
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('aiassyPicMessage', {
|
||||
|
||||
window.socketManager.emit('aiassyPicMessage', {
|
||||
image: base64Image,
|
||||
format: 'jpeg'
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
};
|
||||
|
||||
@@ -480,16 +465,16 @@ class ChatAssistant extends Component {
|
||||
elevation={4}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: { xs: 16, sm: 80 },
|
||||
right: { xs: 16, sm: 16 },
|
||||
left: { xs: 16, sm: 'auto' },
|
||||
top: { xs: 16, sm: 'auto' },
|
||||
width: { xs: 'calc(100vw - 32px)', sm: 450, md: 600, lg: 750 },
|
||||
height: { xs: 'calc(100vh - 32px)', sm: 600, md: 650, lg: 700 },
|
||||
bottom: { xs: 0, sm: 80 },
|
||||
right: { xs: 0, sm: 16 },
|
||||
left: { xs: 0, sm: 'auto' },
|
||||
top: { xs: 0, sm: 'auto' },
|
||||
width: { xs: '100vw', sm: 450, md: 600, lg: 750 },
|
||||
height: { xs: '100vh', sm: 600, md: 650, lg: 700 },
|
||||
maxWidth: { xs: 'none', sm: 450, md: 600, lg: 750 },
|
||||
maxHeight: { xs: 'calc(100vh - 72px)', sm: 600, md: 650, lg: 700 },
|
||||
maxHeight: { xs: '100vh', sm: 600, md: 650, lg: 700 },
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
borderRadius: { xs: 0, sm: 2 },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: 1300,
|
||||
@@ -518,7 +503,7 @@ class ChatAssistant extends Component {
|
||||
<Typography component="span" color={this.state.atDatabase ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🛢</Typography>
|
||||
<Typography component="span" color={this.state.atWeb ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🌐</Typography>
|
||||
</Typography>
|
||||
<IconButton onClick={onClose} size="small" sx={{ color: 'primary.contrastText' }}>
|
||||
<IconButton onClick={onClose} size="small" aria-label="Assistent schließen" sx={{ color: 'primary.contrastText' }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
@@ -581,6 +566,8 @@ class ChatAssistant extends Component {
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
gap: { xs: 1, sm: 0 },
|
||||
p: 1,
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
@@ -619,11 +606,13 @@ class ChatAssistant extends Component {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
{isRecording ? (
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={this.stopRecording}
|
||||
sx={{ ml: 1 }}
|
||||
aria-label="Aufnahme stoppen"
|
||||
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||
>
|
||||
<StopIcon />
|
||||
</IconButton>
|
||||
@@ -631,7 +620,8 @@ class ChatAssistant extends Component {
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={this.startRecording}
|
||||
sx={{ ml: 1 }}
|
||||
aria-label="Sprachaufnahme starten"
|
||||
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||
disabled={isTyping || inputsDisabled}
|
||||
>
|
||||
<MicIcon />
|
||||
@@ -641,7 +631,8 @@ class ChatAssistant extends Component {
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={this.handleImageUpload}
|
||||
sx={{ ml: 1 }}
|
||||
aria-label="Bild hochladen"
|
||||
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||
disabled={isTyping || isRecording || inputsDisabled}
|
||||
>
|
||||
<PhotoCameraIcon />
|
||||
@@ -649,13 +640,14 @@ class ChatAssistant extends Component {
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ ml: 1 }}
|
||||
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||
onClick={this.handleSendMessage}
|
||||
disabled={isTyping || isRecording || inputsDisabled}
|
||||
>
|
||||
Senden
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import CategoryBox from './CategoryBox.js';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js';
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
import { withCategory } from '../context/CategoryContext.js';
|
||||
|
||||
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
@@ -27,13 +28,13 @@ const withRouter = (ClassComponent) => {
|
||||
};
|
||||
};
|
||||
|
||||
function getCachedCategoryData(categoryId) {
|
||||
function getCachedCategoryData(categoryId, language = 'de') {
|
||||
if (!window.productCache) {
|
||||
window.productCache = {};
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = `categoryProducts_${categoryId}`;
|
||||
const cacheKey = `categoryProducts_${categoryId}_${language}`;
|
||||
const cachedData = window.productCache[cacheKey];
|
||||
|
||||
if (cachedData) {
|
||||
@@ -81,7 +82,7 @@ function getFilteredProducts(unfilteredProducts, attributes, t) {
|
||||
|
||||
const uniqueAttributes = [...new Set((attributes || []).map(attr => attr.kMerkmalWert ? attr.kMerkmalWert.toString() : ''))];
|
||||
const uniqueManufacturers = [...new Set((unfilteredProducts || []).filter(product => product.manufacturerId).map(product => product.manufacturerId ? product.manufacturerId.toString() : ''))];
|
||||
const uniqueManufacturersWithName = [...new Set((unfilteredProducts || []).filter(product => product.manufacturerId).map(product => ({id:product.manufacturerId ? product.manufacturerId.toString() : '',value:product.manufacturer})))];
|
||||
const uniqueManufacturersWithName = [...new Set((unfilteredProducts || []).filter(product => product.manufacturerId).map(product => ({ id: product.manufacturerId ? product.manufacturerId.toString() : '', value: product.manufacturer })))];
|
||||
const activeAttributeFilters = attributeFilters.filter(filter => uniqueAttributes.includes(filter));
|
||||
const activeManufacturerFilters = manufacturerFilters.filter(filter => uniqueManufacturers.includes(filter));
|
||||
const attributeFiltersByGroup = {};
|
||||
@@ -97,7 +98,7 @@ function getFilteredProducts(unfilteredProducts, attributes, t) {
|
||||
|
||||
let filteredProducts = (unfilteredProducts || []).filter(product => {
|
||||
const availabilityFilter = sessionStorage.getItem('filter_availability');
|
||||
let inStockMatch = availabilityFilter == 1 ? true : (product.available>0);
|
||||
let inStockMatch = availabilityFilter == 1 ? true : (product.available > 0);
|
||||
|
||||
// Check if there are any new products in the entire set
|
||||
const hasNewProducts = (unfilteredProducts || []).some(product => isNew(product.neu));
|
||||
@@ -106,8 +107,8 @@ function getFilteredProducts(unfilteredProducts, attributes, t) {
|
||||
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;
|
||||
if( (availabilityFilter != 1)&&availabilityFilters.includes('3') && ((product.available) || (!product.available && product.incoming))){
|
||||
const soon2Match = (availabilityFilter != 1) && availabilityFilters.includes('3') ? (product.available) || (!product.available && product.incoming) : true;
|
||||
if ((availabilityFilter != 1) && availabilityFilters.includes('3') && ((product.available) || (!product.available && product.incoming))) {
|
||||
inStockMatch = true;
|
||||
soonMatch = true;
|
||||
console.log("soon2Match", product.cName);
|
||||
@@ -133,11 +134,11 @@ function getFilteredProducts(unfilteredProducts, attributes, t) {
|
||||
|
||||
const activeAttributeFiltersWithNames = activeAttributeFilters.map(filter => {
|
||||
const attribute = attributes.find(attr => attr.kMerkmalWert.toString() === filter);
|
||||
return {name: attribute.cName, value: attribute.cWert, id: attribute.kMerkmalWert};
|
||||
return { name: attribute.cName, value: attribute.cWert, id: attribute.kMerkmalWert };
|
||||
});
|
||||
const activeManufacturerFiltersWithNames = activeManufacturerFilters.map(filter => {
|
||||
const manufacturer = uniqueManufacturersWithName.find(manufacturer => manufacturer.id === filter);
|
||||
return {name: manufacturer.value, value: manufacturer.id};
|
||||
return { name: manufacturer.value, value: manufacturer.id };
|
||||
});
|
||||
|
||||
// Extract active availability filters
|
||||
@@ -150,22 +151,22 @@ function getFilteredProducts(unfilteredProducts, attributes, t) {
|
||||
|
||||
// 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: t ? t('product.inStock') : 'auf Lager'});
|
||||
activeAvailabilityFilters.push({ id: '1', name: t ? t('product.inStock') : '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: t ? t('product.new') : 'Neu'});
|
||||
activeAvailabilityFilters.push({ id: '2', name: t ? t('product.new') : '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: t ? t('product.comingSoon') : 'Bald verfügbar'});
|
||||
activeAvailabilityFilters.push({ id: '3', name: t ? t('product.comingSoon') : 'Bald verfügbar' });
|
||||
}
|
||||
|
||||
return {filteredProducts,activeAttributeFilters:activeAttributeFiltersWithNames,activeManufacturerFilters:activeManufacturerFiltersWithNames,activeAvailabilityFilters};
|
||||
return { filteredProducts, activeAttributeFilters: activeAttributeFiltersWithNames, activeManufacturerFilters: activeManufacturerFiltersWithNames, activeAvailabilityFilters };
|
||||
}
|
||||
function setCachedCategoryData(categoryId, data) {
|
||||
function setCachedCategoryData(categoryId, data, language = 'de') {
|
||||
if (!window.productCache) {
|
||||
window.productCache = {};
|
||||
}
|
||||
@@ -174,9 +175,10 @@ function setCachedCategoryData(categoryId, data) {
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = `categoryProducts_${categoryId}`;
|
||||
if(data.products) for(const product of data.products) {
|
||||
window.productDetailCache[product.id] = product;
|
||||
const cacheKey = `categoryProducts_${categoryId}_${language}`;
|
||||
if (data.products) for (const product of data.products) {
|
||||
const productCacheKey = `product_${product.id}_${language}`;
|
||||
window.productDetailCache[productCacheKey] = product;
|
||||
}
|
||||
window.productCache[cacheKey] = {
|
||||
...data,
|
||||
@@ -197,60 +199,107 @@ class Content extends Component {
|
||||
unfilteredProducts: [],
|
||||
filteredProducts: [],
|
||||
attributes: [],
|
||||
childCategories: []
|
||||
childCategories: [],
|
||||
lastFetchedLanguage: props.i18n?.language || 'de'
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if(this.props.params.categoryId) {this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
const currentLanguage = this.props.i18n?.language || 'de';
|
||||
if (this.props.params.categoryId) {
|
||||
this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
|
||||
this.fetchCategoryData(this.props.params.categoryId);
|
||||
})}
|
||||
})
|
||||
}
|
||||
else if (this.props.searchParams?.get('q')) {
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
|
||||
this.fetchSearchData(this.props.searchParams?.get('q'));
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if(this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId)) {
|
||||
const currentLanguage = this.props.i18n?.language || 'de';
|
||||
const categoryChanged = this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId);
|
||||
const searchChanged = this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q'));
|
||||
|
||||
if (categoryChanged) {
|
||||
// Clear context for new category loading
|
||||
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
|
||||
this.props.categoryContext.setCurrentCategory(null);
|
||||
}
|
||||
|
||||
window.currentSearchQuery = null;
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
|
||||
this.fetchCategoryData(this.props.params.categoryId);
|
||||
});
|
||||
return; // Don't check language change if category changed
|
||||
}
|
||||
else if (this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q'))) {
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
else if (searchChanged) {
|
||||
this.setState({ loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage }, () => {
|
||||
this.fetchSearchData(this.props.searchParams?.get('q'));
|
||||
})
|
||||
});
|
||||
return; // Don't check language change if search changed
|
||||
}
|
||||
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
// Re-fetch products when language changes to get translated content
|
||||
const languageChanged = currentLanguage !== this.state.lastFetchedLanguage;
|
||||
|
||||
if (!wasConnected && isNowConnected && !this.state.loaded) {
|
||||
// Socket just connected and we haven't loaded data yet, retry loading
|
||||
console.log('Content componentDidUpdate:', {
|
||||
languageChanged,
|
||||
lastFetchedLang: this.state.lastFetchedLanguage,
|
||||
currentLang: currentLanguage,
|
||||
prevPropsLang: prevProps.i18n?.language,
|
||||
hasCategoryId: !!this.props.params.categoryId,
|
||||
categoryId: this.props.params.categoryId,
|
||||
hasSearchQuery: !!this.props.searchParams?.get('q')
|
||||
});
|
||||
|
||||
if (languageChanged) {
|
||||
console.log('Content: Language changed! Re-fetching data...');
|
||||
// Re-fetch current data with new language
|
||||
// Note: Language is now part of the cache key, so it will automatically fetch fresh data
|
||||
if (this.props.params.categoryId) {
|
||||
// Re-fetch category data with new language
|
||||
console.log('Content: Re-fetching category', this.props.params.categoryId);
|
||||
this.setState({ loaded: false, lastFetchedLanguage: currentLanguage }, () => {
|
||||
this.fetchCategoryData(this.props.params.categoryId);
|
||||
});
|
||||
} else if (this.props.searchParams?.get('q')) {
|
||||
// Re-fetch search data with new language
|
||||
console.log('Content: Re-fetching search', this.props.searchParams?.get('q'));
|
||||
this.setState({ loaded: false, lastFetchedLanguage: currentLanguage }, () => {
|
||||
this.fetchSearchData(this.props.searchParams?.get('q'));
|
||||
});
|
||||
} else {
|
||||
// If not viewing category or search, just re-filter existing products
|
||||
console.log('Content: Just re-filtering existing products');
|
||||
this.setState({ lastFetchedLanguage: currentLanguage });
|
||||
this.filterProducts();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processData(response) {
|
||||
const unfilteredProducts = response.products;
|
||||
const rawProducts = response.products;
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
|
||||
|
||||
if (!window.individualProductCache) {
|
||||
window.individualProductCache = {};
|
||||
}
|
||||
//console.log("processData", unfilteredProducts);
|
||||
if(unfilteredProducts) unfilteredProducts.forEach(product => {
|
||||
window.individualProductCache[product.id] = {
|
||||
data: product,
|
||||
|
||||
const unfilteredProducts = [];
|
||||
|
||||
//console.log("processData", rawProducts);
|
||||
if (rawProducts) rawProducts.forEach(product => {
|
||||
const effectiveProduct = product.translatedProduct || product;
|
||||
const cacheKey = `${effectiveProduct.id}_${currentLanguage}`;
|
||||
|
||||
window.individualProductCache[cacheKey] = {
|
||||
data: effectiveProduct,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
unfilteredProducts.push(effectiveProduct);
|
||||
});
|
||||
|
||||
this.setState({
|
||||
@@ -266,34 +315,49 @@ class Content extends Component {
|
||||
attributes: response.attributes,
|
||||
childCategories: response.childCategories || [],
|
||||
loaded: true
|
||||
}, () => {
|
||||
console.log('Content: processData finished', {
|
||||
hasContext: !!this.props.categoryContext,
|
||||
categoryName: response.categoryName,
|
||||
name: response.name
|
||||
});
|
||||
|
||||
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
|
||||
if (response.categoryName || response.name) {
|
||||
console.log('Content: Setting category context');
|
||||
this.props.categoryContext.setCurrentCategory({
|
||||
id: this.props.params.categoryId,
|
||||
name: response.categoryName || response.name
|
||||
});
|
||||
} else {
|
||||
console.log('Content: No category name found to set in context');
|
||||
}
|
||||
} else {
|
||||
console.warn('Content: categoryContext prop is missing!');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
fetchCategoryData(categoryId) {
|
||||
const cachedData = getCachedCategoryData(categoryId);
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
|
||||
const cachedData = getCachedCategoryData(categoryId, currentLanguage);
|
||||
if (cachedData) {
|
||||
this.processDataWithCategoryTree(cachedData, categoryId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
// The componentDidUpdate will retry when socket connects
|
||||
console.log("Socket not connected yet, waiting for connection to fetch category data");
|
||||
return;
|
||||
}
|
||||
console.log(`productList:${categoryId}`);
|
||||
this.props.socket.off(`productList:${categoryId}`);
|
||||
window.socketManager.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) => {
|
||||
window.socketManager.on(`productList:${categoryId}`, (response) => {
|
||||
console.log("getCategoryProducts full response", response);
|
||||
receivedFullResponse = true;
|
||||
setCachedCategoryData(categoryId, response);
|
||||
setCachedCategoryData(categoryId, response, currentLanguage);
|
||||
if (response && response.products !== undefined) {
|
||||
this.processDataWithCategoryTree(response, categoryId);
|
||||
} else {
|
||||
@@ -301,12 +365,14 @@ class Content extends Component {
|
||||
}
|
||||
});
|
||||
|
||||
this.props.socket.emit("getCategoryProducts", { categoryId: categoryId },
|
||||
window.socketManager.emit(
|
||||
"getCategoryProducts",
|
||||
{ categoryId: categoryId, language: currentLanguage, requestTranslation: currentLanguage === 'de' ? false : true },
|
||||
(response) => {
|
||||
console.log("getCategoryProducts stub response", response);
|
||||
// Only process stub response if we haven't received the full response yet
|
||||
if (!receivedFullResponse) {
|
||||
setCachedCategoryData(categoryId, response);
|
||||
setCachedCategoryData(categoryId, response, currentLanguage);
|
||||
if (response && response.products !== undefined) {
|
||||
this.processDataWithCategoryTree(response, categoryId);
|
||||
} else {
|
||||
@@ -320,15 +386,17 @@ class Content extends Component {
|
||||
}
|
||||
|
||||
processDataWithCategoryTree(response, categoryId) {
|
||||
console.log("---------------processDataWithCategoryTree", response, categoryId);
|
||||
// Get child categories from the cached category tree
|
||||
let childCategories = [];
|
||||
try {
|
||||
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
|
||||
if (categoryTreeCache && categoryTreeCache.categoryTree) {
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
|
||||
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
|
||||
if (categoryTreeCache) {
|
||||
// If categoryId is a string (SEO name), find by seoName, otherwise by ID
|
||||
const targetCategory = typeof categoryId === 'string'
|
||||
? this.findCategoryBySeoName(categoryTreeCache.categoryTree, categoryId)
|
||||
: this.findCategoryById(categoryTreeCache.categoryTree, categoryId);
|
||||
? this.findCategoryBySeoName(categoryTreeCache, categoryId)
|
||||
: this.findCategoryById(categoryTreeCache, categoryId);
|
||||
|
||||
if (targetCategory && targetCategory.children) {
|
||||
childCategories = targetCategory.children;
|
||||
@@ -344,6 +412,27 @@ class Content extends Component {
|
||||
childCategories
|
||||
};
|
||||
|
||||
// Attempt to set category name from the tree if missing in response
|
||||
if (!enhancedResponse.categoryName && !enhancedResponse.name) {
|
||||
// Try to find name in the tree using the ID or SEO name
|
||||
try {
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
|
||||
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
|
||||
|
||||
if (categoryTreeCache) {
|
||||
const targetCategory = typeof categoryId === 'string'
|
||||
? this.findCategoryBySeoName(categoryTreeCache, categoryId)
|
||||
: this.findCategoryById(categoryTreeCache, categoryId);
|
||||
|
||||
if (targetCategory && targetCategory.name) {
|
||||
enhancedResponse.categoryName = targetCategory.name;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error finding category name in tree:', err);
|
||||
}
|
||||
}
|
||||
|
||||
this.processData(enhancedResponse);
|
||||
}
|
||||
|
||||
@@ -365,17 +454,18 @@ class Content extends Component {
|
||||
}
|
||||
|
||||
fetchSearchData(query) {
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
// The componentDidUpdate will retry when socket connects
|
||||
console.log("Socket not connected yet, waiting for connection to fetch search data");
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.socket.emit("getSearchProducts", { query },
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
|
||||
window.socketManager.emit(
|
||||
"getSearchProducts",
|
||||
{ query, language: currentLanguage, requestTranslation: currentLanguage === 'de' ? false : true },
|
||||
(response) => {
|
||||
if (response && response.products) {
|
||||
this.processData(response);
|
||||
// Map products to use translatedProduct if available
|
||||
const enhancedResponse = {
|
||||
...response,
|
||||
products: response.products.map(p => p.translatedProduct || p)
|
||||
};
|
||||
this.processData(enhancedResponse);
|
||||
} else {
|
||||
console.log("fetchSearchData in Content failed", response);
|
||||
}
|
||||
@@ -416,28 +506,30 @@ class Content extends Component {
|
||||
const seoName = this.props.params.categoryId;
|
||||
|
||||
// Get the category tree from cache
|
||||
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
|
||||
if (!categoryTreeCache || !categoryTreeCache.categoryTree) {
|
||||
return null;
|
||||
}
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
|
||||
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
|
||||
|
||||
// Find the category by seoName
|
||||
const category = this.findCategoryBySeoName(categoryTreeCache.categoryTree, seoName);
|
||||
const category = this.findCategoryBySeoName(categoryTreeCache, seoName);
|
||||
return category ? category.id : null;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
|
||||
this.props.categoryContext.setCurrentCategory(null);
|
||||
}
|
||||
}
|
||||
|
||||
renderParentCategoryNavigation = () => {
|
||||
const currentCategoryId = this.getCurrentCategoryId();
|
||||
if (!currentCategoryId) return null;
|
||||
|
||||
// Get the category tree from cache
|
||||
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
|
||||
if (!categoryTreeCache || !categoryTreeCache.categoryTree) {
|
||||
return null;
|
||||
}
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
|
||||
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
|
||||
|
||||
// Find the current category in the tree
|
||||
const currentCategory = this.findCategoryById(categoryTreeCache.categoryTree, currentCategoryId);
|
||||
const currentCategory = this.findCategoryById(categoryTreeCache, currentCategoryId);
|
||||
if (!currentCategory) {
|
||||
return null;
|
||||
}
|
||||
@@ -448,7 +540,7 @@ class Content extends Component {
|
||||
}
|
||||
|
||||
// Find the parent category
|
||||
const parentCategory = this.findCategoryById(categoryTreeCache.categoryTree, currentCategory.parentId);
|
||||
const parentCategory = this.findCategoryById(categoryTreeCache, currentCategory.parentId);
|
||||
if (!parentCategory) {
|
||||
return null;
|
||||
}
|
||||
@@ -466,11 +558,14 @@ class Content extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
// console.log('Content props:', this.props);
|
||||
// Check if we should show category boxes instead of product list
|
||||
const showCategoryBoxes = this.state.loaded &&
|
||||
this.state.unfilteredProducts.length === 0 &&
|
||||
this.state.childCategories.length > 0;
|
||||
|
||||
console.log("showCategoryBoxes", showCategoryBoxes, this.state.unfilteredProducts.length, this.state.childCategories.length);
|
||||
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: { xs: 0, sm: 2 }, px: { xs: 0, sm: 3 }, flexGrow: 1, display: 'grid', gridTemplateRows: '1fr' }}>
|
||||
@@ -495,7 +590,7 @@ class Content extends Component {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 3, flexWrap: 'wrap' }}>
|
||||
{/* Parent Category Box */}
|
||||
<Box sx={{ mt:2,position: 'relative', flexShrink: 0 }}>
|
||||
<Box sx={{ mt: 2, position: 'relative', flexShrink: 0 }}>
|
||||
<CategoryBox
|
||||
id={parentCategory.id}
|
||||
seoName={parentCategory.seoName}
|
||||
@@ -516,7 +611,8 @@ class Content extends Component {
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
<KeyboardArrowUpIcon sx={{ color: 'white', fontSize: '1.2rem' }} />
|
||||
</Box>
|
||||
@@ -565,7 +661,8 @@ class Content extends Component {
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
<KeyboardArrowUpIcon sx={{ color: 'white', fontSize: '1.2rem' }} />
|
||||
</Box>
|
||||
@@ -596,15 +693,16 @@ class Content extends Component {
|
||||
filteredProducts={this.state.filteredProducts}
|
||||
attributes={this.state.attributes}
|
||||
searchParams={this.props.searchParams}
|
||||
onFilterChange={()=>{this.filterProducts()}}
|
||||
onFilterChange={() => { this.filterProducts() }}
|
||||
dataType={this.state.dataType}
|
||||
dataParam={this.state.dataParam}
|
||||
categoryName={this.state.categoryName}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{(this.getCurrentCategoryId() == 706 || this.getCurrentCategoryId() == 689) &&
|
||||
{(this.props.params.categoryId == 'Stecklinge___' || this.props.params.categoryId == 'Seeds___') &&
|
||||
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
|
||||
<Typography variant="h6" sx={{mt:3}}>
|
||||
<Typography variant="h6" sx={{ mt: 3 }}>
|
||||
{this.props.t ? this.props.t('navigation.otherCategories') : 'Andere Kategorien'}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -614,7 +712,7 @@ class Content extends Component {
|
||||
component={Link}
|
||||
to="/Kategorie/Seeds"
|
||||
sx={{
|
||||
p:0,
|
||||
p: 0,
|
||||
mt: 1,
|
||||
textDecoration: 'none',
|
||||
color: 'text.primary',
|
||||
@@ -634,12 +732,26 @@ class Content extends Component {
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
bgcolor: '#e1f0d3',
|
||||
backgroundImage: 'url("/assets/images/seeds.jpg")',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
position: 'relative'
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<img
|
||||
src="/assets/images/seeds.avif"
|
||||
alt="Seeds"
|
||||
fetchPriority="high"
|
||||
loading="eager"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)'
|
||||
}}
|
||||
/>
|
||||
{/* Overlay text - optional */}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
@@ -657,7 +769,7 @@ class Content extends Component {
|
||||
</Paper>
|
||||
}
|
||||
|
||||
{this.props.params.categoryId == 'Seeds' && <Paper
|
||||
{this.props.params.categoryId == 'Seeds___' && <Paper
|
||||
component={Link}
|
||||
to="/Kategorie/Stecklinge"
|
||||
sx={{
|
||||
@@ -681,12 +793,26 @@ class Content extends Component {
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
bgcolor: '#e8f5d6',
|
||||
backgroundImage: 'url("/assets/images/cutlings.jpg")',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
position: 'relative'
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<img
|
||||
src="/assets/images/cutlings.avif"
|
||||
alt="Stecklinge"
|
||||
fetchPriority="high"
|
||||
loading="eager"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)'
|
||||
}}
|
||||
/>
|
||||
{/* Overlay text - optional */}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
@@ -706,14 +832,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()}}
|
||||
onFilterChange={() => { this.filterProducts() }}
|
||||
dataType={this.state.dataType}
|
||||
dataParam={this.state.dataParam}
|
||||
/>
|
||||
@@ -726,4 +850,4 @@ class Content extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(withI18n()(Content));
|
||||
export default withRouter(withI18n()(withCategory(Content)));
|
||||
@@ -267,7 +267,7 @@ class Filter extends Component {
|
||||
)}
|
||||
</Typography>
|
||||
{isXsScreen && (
|
||||
<IconButton size="small" sx={{ p: 0 }}>
|
||||
<IconButton size="small" aria-label={isCollapsed ? "Filter erweitern" : "Filter einklappen"} sx={{ p: 0 }}>
|
||||
{isCollapsed ? <ExpandMoreIcon /> : <ExpandLessIcon />}
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
@@ -247,7 +247,7 @@ class Footer extends Component {
|
||||
<StyledRouterLink to="/widerrufsrecht">{this.props.t ? this.props.t('footer.legal.widerrufsrecht') : 'Widerrufsrecht'}</StyledRouterLink>
|
||||
</Stack>
|
||||
|
||||
{/* Payment Methods Section */}
|
||||
{/* Payment Methods Section
|
||||
<Stack
|
||||
direction="column"
|
||||
spacing={1}
|
||||
@@ -264,7 +264,7 @@ class Footer extends Component {
|
||||
<Box component="img" src="/assets/images/cards.png" alt="Cash" sx={{ height: { xs: 80, md: 95 } }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
*/}
|
||||
{/* Google Services Badge Section */}
|
||||
<Stack
|
||||
direction="column"
|
||||
@@ -275,9 +275,9 @@ class Footer extends Component {
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={{ xs: 1, md: 2 }}
|
||||
sx={{pb: '10px'}}
|
||||
sx={{pt: '10px', height: { xs: 50, md: 60 }, transform: 'translateY(-3px)'}}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
alignItems="flex-end"
|
||||
>
|
||||
<Link
|
||||
href="https://reviewthis.biz/growheads"
|
||||
@@ -286,17 +286,21 @@ class Footer extends Component {
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
position: 'relative',
|
||||
zIndex: 9999
|
||||
zIndex: 9999,
|
||||
display: 'inline-block',
|
||||
height: { xs: 57, md: 67 },
|
||||
lineHeight: 1
|
||||
}}
|
||||
onMouseEnter={this.handleReviewsMouseEnter}
|
||||
onMouseLeave={this.handleReviewsMouseLeave}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/assets/images/gg.png"
|
||||
src="/assets/images/gg.avif"
|
||||
alt="Google Reviews"
|
||||
sx={{
|
||||
height: { xs: 50, md: 60 },
|
||||
width: { xs: 105, md: 126 },
|
||||
cursor: 'pointer',
|
||||
transition: 'all 2s ease',
|
||||
'&:hover': {
|
||||
@@ -312,17 +316,21 @@ class Footer extends Component {
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
position: 'relative',
|
||||
zIndex: 9999
|
||||
zIndex: 9999,
|
||||
display: 'inline-block',
|
||||
height: { xs: 47, md: 67 },
|
||||
lineHeight: 1
|
||||
}}
|
||||
onMouseEnter={this.handleMapsMouseEnter}
|
||||
onMouseLeave={this.handleMapsMouseLeave}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/assets/images/maps.png"
|
||||
src="/assets/images/maps.avif"
|
||||
alt="Google Maps"
|
||||
sx={{
|
||||
height: { xs: 40, md: 50 },
|
||||
width: { xs: 38, md: 49 },
|
||||
cursor: 'pointer',
|
||||
transition: 'all 2s ease',
|
||||
filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))',
|
||||
@@ -344,6 +352,9 @@ class Footer extends Component {
|
||||
<Typography variant="body2" sx={{ fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
|
||||
© {new Date().getFullYear()} <StyledDomainLink href="https://growheads.de" target="_blank" rel="noopener noreferrer">GrowHeads.de</StyledDomainLink>
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: { xs: '9px', md: '9px' }, lineHeight: 1.5, mt: 1 }}>
|
||||
<StyledDomainLink href="https://telegraf.growheads.de" target="_blank" rel="noreferrer">Telegraf - sicherer Chat mit unseren Mitarbeitern</StyledDomainLink>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { Component } from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
import GoogleAuthContext from '../contexts/GoogleAuthContext.js';
|
||||
import { withI18n } from '../i18n/index.js';
|
||||
// import { withI18n } from '../i18n/withTranslation.js'; // Temporarily commented out for debugging
|
||||
|
||||
class GoogleLoginButton extends Component {
|
||||
static contextType = GoogleAuthContext;
|
||||
@@ -187,17 +187,20 @@ class GoogleLoginButton extends Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { disabled, style, className, text = (this.props.t ? this.props.t('auth.loginWithGoogle') : 'Mit Google anmelden') } = this.props;
|
||||
const { disabled, style, className, text = 'Loading...'} = this.props;
|
||||
const { isInitializing, isPrompting } = this.state;
|
||||
const isLoading = isInitializing || isPrompting || (this.context && !this.context.isLoaded);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<GoogleIcon />}
|
||||
onClick={this.handleClick}
|
||||
disabled={disabled || isLoading}
|
||||
style={{ backgroundColor: '#4285F4', color: 'white', ...style }}
|
||||
fullWidth
|
||||
style={{backgroundColor: '#4285F4', color: 'white', ...style }}
|
||||
className={className}
|
||||
>
|
||||
{isLoading ? 'Loading...' : text}
|
||||
@@ -206,4 +209,4 @@ class GoogleLoginButton extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default withI18n(GoogleLoginButton);
|
||||
export default GoogleLoginButton; // Temporarily removed withI18n for debugging
|
||||
@@ -4,7 +4,6 @@ import Toolbar from '@mui/material/Toolbar';
|
||||
import Container from '@mui/material/Container';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
import SocketContext from '../contexts/SocketContext.js';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
// Import extracted components
|
||||
@@ -12,7 +11,6 @@ import { Logo, SearchBar, ButtonGroupWithRouter, CategoryList } from './header/i
|
||||
|
||||
// Main Header Component
|
||||
class Header extends Component {
|
||||
static contextType = SocketContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -36,8 +34,7 @@ class Header extends Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
// Get socket directly from context in render method
|
||||
const {socket,socketB} = this.context;
|
||||
|
||||
const { isHomePage, isProfilePage, isAktionenPage, isFilialePage } = this.props;
|
||||
|
||||
return (
|
||||
@@ -75,7 +72,7 @@ class Header extends Component {
|
||||
transform: { xs: 'translateY(4px) translateX(9px)', sm: 'none' },
|
||||
ml: { xs: 0, sm: 0 }
|
||||
}}>
|
||||
<ButtonGroupWithRouter socket={socket}/>
|
||||
<ButtonGroupWithRouter/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -94,7 +91,7 @@ class Header extends Component {
|
||||
</Box>
|
||||
</Container>
|
||||
</Toolbar>
|
||||
{(isHomePage || this.props.categoryId || isProfilePage || isAktionenPage || isFilialePage) && <CategoryList categoryId={209} activeCategoryId={this.props.categoryId} socket={socket} socketB={socketB} />}
|
||||
{(isHomePage || this.props.categoryId || isProfilePage || isAktionenPage || isFilialePage || this.props.isArtikel) && <CategoryList categoryId={209} activeCategoryId={this.props.categoryId}/>}
|
||||
</AppBar>
|
||||
);
|
||||
}
|
||||
@@ -107,11 +104,12 @@ const HeaderWithContext = (props) => {
|
||||
const isProfilePage = location.pathname === '/profile';
|
||||
const isAktionenPage = location.pathname === '/aktionen';
|
||||
const isFilialePage = location.pathname === '/filiale';
|
||||
const isArtikel = location.pathname.startsWith('/Artikel/');
|
||||
|
||||
return (
|
||||
<SocketContext.Consumer>
|
||||
{({socket,socketB}) => <Header {...props} socket={socket} socketB={socketB} isHomePage={isHomePage} isProfilePage={isProfilePage} isAktionenPage={isAktionenPage} isFilialePage={isFilialePage} />}
|
||||
</SocketContext.Consumer>
|
||||
|
||||
<Header {...props} isHomePage={isHomePage} isArtikel={isArtikel} isProfilePage={isProfilePage} isAktionenPage={isAktionenPage} isFilialePage={isFilialePage} />
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -12,9 +12,7 @@ import LoupeIcon from '@mui/icons-material/Loupe';
|
||||
class Images extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { mainPic:0,pics:[]};
|
||||
|
||||
console.log('Images constructor',props);
|
||||
this.state = { mainPic:0,pics:[] };
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
@@ -25,6 +23,9 @@ class Images extends Component {
|
||||
this.updatePics();
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
window.productImageUrl = null;
|
||||
}
|
||||
|
||||
updatePics = (newMainPic = this.state.mainPic) => {
|
||||
if (!window.tinyPicCache) window.tinyPicCache = {};
|
||||
@@ -41,6 +42,7 @@ class Images extends Component {
|
||||
|
||||
for(const bildId of bildIds){
|
||||
if(bildId == mainPicId){
|
||||
if(window.productImageUrl) continue;
|
||||
|
||||
if(window.largePicCache[bildId]){
|
||||
pics.push(window.largePicCache[bildId]);
|
||||
@@ -51,10 +53,10 @@ class Images extends Component {
|
||||
pics.push(window.smallPicCache[bildId]);
|
||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||
}else if(window.tinyPicCache[bildId]){
|
||||
pics.push(bildId);
|
||||
pics.push(window.tinyPicCache[bildId]);
|
||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||
}else{
|
||||
pics.push(bildId);
|
||||
pics.push(`/assets/images/prod${bildId}.avif`);
|
||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||
}
|
||||
}else{
|
||||
@@ -69,7 +71,8 @@ class Images extends Component {
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('pics',pics);
|
||||
console.log('DEBUG: pics array contents:', pics);
|
||||
console.log('DEBUG: pics array types:', pics.map(p => typeof p + ': ' + p));
|
||||
this.setState({ pics, mainPic: newMainPic });
|
||||
}else{
|
||||
if(this.state.pics.length > 0) this.setState({ pics:[], mainPic: newMainPic });
|
||||
@@ -77,9 +80,11 @@ class Images extends Component {
|
||||
}
|
||||
|
||||
loadPic = (size,bildId,index) => {
|
||||
this.props.socketB.emit('getPic', { bildId, size }, (res) => {
|
||||
|
||||
|
||||
window.socketManager.emit('getPic', { bildId, size }, (res) => {
|
||||
if(res.success){
|
||||
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
|
||||
|
||||
if(size === 'medium') window.mediumPicCache[bildId] = url;
|
||||
if(size === 'small') window.smallPicCache[bildId] = url;
|
||||
@@ -101,27 +106,53 @@ class Images extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
// SPA version - full functionality with static fallback
|
||||
const getImageSrc = () => {
|
||||
if(window.productImageUrl) return window.productImageUrl;
|
||||
|
||||
// If dynamic image is loaded, use it
|
||||
if (this.state.pics[this.state.mainPic]) {
|
||||
return this.state.pics[this.state.mainPic];
|
||||
}
|
||||
// Otherwise, use static fallback (same as prerender)
|
||||
if (!this.props.pictureList || !this.props.pictureList.trim()) {
|
||||
return '/assets/images/nopicture.jpg';
|
||||
}
|
||||
return `/assets/images/prod${this.props.pictureList.split(',')[0].trim()}.avif`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{this.state.pics[this.state.mainPic] && (
|
||||
<Box sx={{ position: 'relative', display: 'inline-block' }}>
|
||||
<Box sx={{ position: 'relative', display: 'inline-block', width: '499px', maxWidth: '100%' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="400"
|
||||
fetchPriority="high"
|
||||
loading="eager"
|
||||
alt={this.props.productName || 'Produktbild'}
|
||||
onError={(e) => {
|
||||
// Ensure alt text is always present even on error
|
||||
if (!e.target.alt) {
|
||||
e.target.alt = this.props.productName || 'Produktbild';
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
width: '499px',
|
||||
maxWidth: '100%',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.02)'
|
||||
}
|
||||
}}
|
||||
image={this.state.pics[this.state.mainPic]}
|
||||
image={getImageSrc()}
|
||||
onClick={this.props.onOpenFullscreen}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
disableRipple
|
||||
aria-label="Zoom-Symbol"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
@@ -137,7 +168,6 @@ class Images extends Component {
|
||||
<LoupeIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
<Stack direction="row" spacing={2} sx={{ justifyContent: 'flex-start', mt: 1,mb: 1 }}>
|
||||
{this.state.pics.filter(pic => pic !== null && pic !== this.state.pics[this.state.mainPic]).map((pic, filterIndex) => {
|
||||
// Find the original index in the full pics array
|
||||
@@ -169,6 +199,13 @@ class Images extends Component {
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="80"
|
||||
alt={`${this.props.productName || 'Produktbild'} - Bild ${originalIndex + 1}`}
|
||||
onError={(e) => {
|
||||
// Ensure alt text is always present even on error
|
||||
if (!e.target.alt) {
|
||||
e.target.alt = `${this.props.productName || 'Produktbild'} - Bild ${originalIndex + 1}`;
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
@@ -223,6 +260,7 @@ class Images extends Component {
|
||||
{/* Close Button */}
|
||||
<IconButton
|
||||
onClick={this.props.onCloseFullscreen}
|
||||
aria-label="Vollbild schließen"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
@@ -241,6 +279,13 @@ class Images extends Component {
|
||||
{this.state.pics[this.state.mainPic] && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
alt={this.props.productName || 'Produktbild'}
|
||||
onError={(e) => {
|
||||
// Ensure alt text is always present even on error
|
||||
if (!e.target.alt) {
|
||||
e.target.alt = this.props.productName || 'Produktbild';
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
width: '90vw',
|
||||
@@ -294,6 +339,13 @@ class Images extends Component {
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="60"
|
||||
alt={`${this.props.productName || 'Produktbild'} - Miniaturansicht ${originalIndex + 1}`}
|
||||
onError={(e) => {
|
||||
// Ensure alt text is always present even on error
|
||||
if (!e.target.alt) {
|
||||
e.target.alt = `${this.props.productName || 'Produktbild'} - Miniaturansicht ${originalIndex + 1}`;
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
|
||||
@@ -24,10 +24,14 @@ class LanguageSwitcher extends Component {
|
||||
this.setState({ anchorEl: null });
|
||||
};
|
||||
|
||||
handleLanguageChange = (language) => {
|
||||
handleLanguageChange = async (language) => {
|
||||
const { languageContext } = this.props;
|
||||
if (languageContext) {
|
||||
languageContext.changeLanguage(language);
|
||||
try {
|
||||
await languageContext.changeLanguage(language);
|
||||
} catch (error) {
|
||||
console.error('Failed to change language:', error);
|
||||
}
|
||||
}
|
||||
this.handleClose();
|
||||
};
|
||||
@@ -56,6 +60,7 @@ class LanguageSwitcher extends Component {
|
||||
'ru': () => import('country-flag-icons/react/3x2').then(m => m.RU),
|
||||
'sk': () => import('country-flag-icons/react/3x2').then(m => m.SK),
|
||||
'sl': () => import('country-flag-icons/react/3x2').then(m => m.SI),
|
||||
'sq': () => import('country-flag-icons/react/3x2').then(m => m.AL),
|
||||
'sr': () => import('country-flag-icons/react/3x2').then(m => m.RS),
|
||||
'sv': () => import('country-flag-icons/react/3x2').then(m => m.SE),
|
||||
'tr': () => import('country-flag-icons/react/3x2').then(m => m.TR),
|
||||
@@ -126,8 +131,8 @@ class LanguageSwitcher extends Component {
|
||||
const { languageContext } = this.props;
|
||||
|
||||
if (anchorEl && !prevState.anchorEl && languageContext) {
|
||||
// Menu just opened, lazy load all flags
|
||||
languageContext.availableLanguages.forEach(lang => {
|
||||
// Menu just opened, lazy load flags for all languages (not just available ones)
|
||||
languageContext.allLanguages.forEach(lang => {
|
||||
if (!this.state.loadedFlags[lang]) {
|
||||
this.loadFlagComponent(lang);
|
||||
}
|
||||
@@ -153,6 +158,7 @@ class LanguageSwitcher extends Component {
|
||||
'ru': 'RU',
|
||||
'sk': 'SK',
|
||||
'sl': 'SI',
|
||||
'sq': 'AL',
|
||||
'sr': 'RS',
|
||||
'sv': 'SE',
|
||||
'tr': 'TR',
|
||||
@@ -180,6 +186,7 @@ class LanguageSwitcher extends Component {
|
||||
'ru': 'Русский',
|
||||
'sk': 'Slovenčina',
|
||||
'sl': 'Slovenščina',
|
||||
'sq': 'Shqip',
|
||||
'sr': 'Српски',
|
||||
'sv': 'Svenska',
|
||||
'tr': 'Türkçe',
|
||||
@@ -197,7 +204,7 @@ class LanguageSwitcher extends Component {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { currentLanguage, availableLanguages } = languageContext;
|
||||
const { currentLanguage, allLanguages } = languageContext;
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
return (
|
||||
@@ -237,7 +244,8 @@ class LanguageSwitcher extends Component {
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
{availableLanguages.map((language) => (
|
||||
{allLanguages.map((language) => {
|
||||
return (
|
||||
<MenuItem
|
||||
key={language}
|
||||
onClick={() => this.handleLanguageChange(language)}
|
||||
@@ -259,7 +267,8 @@ class LanguageSwitcher extends Component {
|
||||
{this.getLanguageLabel(language)}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import CartSyncDialog from './CartSyncDialog.js';
|
||||
import { localAndArchiveServer, mergeCarts } from '../utils/cartUtils.js';
|
||||
import config from '../config.js';
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
|
||||
// Lazy load GoogleAuthProvider
|
||||
const GoogleAuthProvider = lazy(() => import('../providers/GoogleAuthProvider.js'));
|
||||
@@ -171,30 +172,22 @@ export class LoginComponent extends Component {
|
||||
|
||||
handleLogin = () => {
|
||||
const { email, password } = this.state;
|
||||
const { socket, location, navigate } = this.props;
|
||||
const { location, navigate } = this.props;
|
||||
|
||||
if (!email || !password) {
|
||||
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: '' });
|
||||
|
||||
// Call verifyUser socket endpoint
|
||||
if (!socket || !socket.connected) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Verbindung zum Server verloren. Bitte versuchen Sie es erneut.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('verifyUser', { email, password }, (response) => {
|
||||
window.socketManager.emit('verifyUser', { email, password }, (response) => {
|
||||
console.log('LoginComponent: verifyUser', response);
|
||||
if (response.success) {
|
||||
sessionStorage.setItem('user', JSON.stringify(response.user));
|
||||
@@ -216,9 +209,9 @@ export class LoginComponent extends Component {
|
||||
const serverCartArr = newCart ? Object.values(newCart) : [];
|
||||
|
||||
if (serverCartArr.length === 0) {
|
||||
if (socket && socket.connected) {
|
||||
socket.emit('updateCart', window.cart);
|
||||
}
|
||||
|
||||
window.socketManager.emit('updateCart', window.cart);
|
||||
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else if (localCartArr.length === 0 && serverCartArr.length > 0) {
|
||||
@@ -245,7 +238,7 @@ export class LoginComponent extends Component {
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || 'Anmeldung fehlgeschlagen'
|
||||
error: response.message || (this.props.t ? this.props.t('auth.errors.loginFailed') : 'Anmeldung fehlgeschlagen')
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -253,50 +246,49 @@ export class LoginComponent extends Component {
|
||||
|
||||
handleRegister = () => {
|
||||
const { email, password, confirmPassword } = this.state;
|
||||
const { socket } = this.props;
|
||||
|
||||
if (!email || !password || !confirmPassword) {
|
||||
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
this.setState({ error: 'Passwörter stimmen nicht überein' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.passwordsNotMatchShort') : 'Passwörter stimmen nicht überein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
this.setState({ error: 'Das Passwort muss mindestens 8 Zeichen lang sein' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.passwordMinLength') : 'Das Passwort muss mindestens 8 Zeichen lang sein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: '' });
|
||||
|
||||
// Call createUser socket endpoint
|
||||
if (!socket || !socket.connected) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Verbindung zum Server verloren. Bitte versuchen Sie es erneut.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('createUser', { email, password }, (response) => {
|
||||
window.socketManager.emit('createUser', { email, password }, (response) => {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
success: 'Registrierung erfolgreich. Sie können sich jetzt anmelden.',
|
||||
success: this.props.t ? this.props.t('auth.success.registerComplete') : 'Registrierung erfolgreich. Sie können sich jetzt anmelden.',
|
||||
tabValue: 0 // Switch to login tab
|
||||
});
|
||||
} else {
|
||||
let errorMessage = this.props.t ? this.props.t('auth.errors.registerFailed') : 'Registrierung fehlgeschlagen';
|
||||
|
||||
if (response.cause === 'emailExists') {
|
||||
errorMessage = this.props.t ? this.props.t('auth.errors.emailExists') : 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits. Bitte verwenden Sie eine andere E-Mail-Adresse oder melden Sie sich an.';
|
||||
} else if (response.message) {
|
||||
errorMessage = response.message;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || 'Registrierung fehlgeschlagen'
|
||||
error: errorMessage
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -311,22 +303,7 @@ export class LoginComponent extends Component {
|
||||
};
|
||||
|
||||
handleLogout = () => {
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
// If socket is not connected, just clear local storage
|
||||
sessionStorage.removeItem('user');
|
||||
window.cart = [];
|
||||
window.dispatchEvent(new CustomEvent('cart'));
|
||||
window.dispatchEvent(new CustomEvent('userLoggedOut'));
|
||||
this.setState({
|
||||
isLoggedIn: false,
|
||||
user: null,
|
||||
isAdmin: false,
|
||||
anchorEl: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.socket.emit('logout', (response) => {
|
||||
window.socketManager.emit('logout', (response) => {
|
||||
if(response.success){
|
||||
sessionStorage.removeItem('user');
|
||||
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
||||
@@ -343,22 +320,21 @@ export class LoginComponent extends Component {
|
||||
|
||||
handleForgotPassword = () => {
|
||||
const { email } = this.state;
|
||||
const { socket } = this.props;
|
||||
|
||||
if (!email) {
|
||||
this.setState({ error: 'Bitte geben Sie Ihre E-Mail-Adresse ein' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.enterEmail') : 'Bitte geben Sie Ihre E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: '' });
|
||||
|
||||
// Call resetPassword socket endpoint
|
||||
socket.emit('resetPassword', {
|
||||
|
||||
window.socketManager.emit('resetPassword', {
|
||||
email,
|
||||
domain: window.location.origin
|
||||
}, (response) => {
|
||||
@@ -366,12 +342,12 @@ export class LoginComponent extends Component {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
success: 'Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.'
|
||||
success: this.props.t ? this.props.t('auth.resetPassword.emailSent') : 'Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.'
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || 'Fehler beim Senden der E-Mail'
|
||||
error: response.message || (this.props.t ? this.props.t('auth.resetPassword.emailError') : 'Fehler beim Senden der E-Mail')
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -379,13 +355,11 @@ export class LoginComponent extends Component {
|
||||
|
||||
// Google login functionality
|
||||
handleGoogleLoginSuccess = (credentialResponse) => {
|
||||
const { socket, location, navigate } = this.props;
|
||||
const { location, navigate } = this.props;
|
||||
this.setState({ loading: true, error: '' });
|
||||
console.log('beforeG',credentialResponse)
|
||||
|
||||
|
||||
|
||||
socket.emit('verifyGoogleUser', { credential: credentialResponse.credential }, (response) => {
|
||||
window.socketManager.emit('verifyGoogleUser', { credential: credentialResponse.credential }, (response) => {
|
||||
console.log('google respo',response);
|
||||
if (response.success) {
|
||||
sessionStorage.setItem('user', JSON.stringify(response.user));
|
||||
@@ -407,7 +381,7 @@ export class LoginComponent extends Component {
|
||||
const serverCartArr = newCart ? Object.values(newCart) : [];
|
||||
|
||||
if (serverCartArr.length === 0) {
|
||||
socket.emit('updateCart', window.cart);
|
||||
window.socketManager.emit('updateCart', window.cart);
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else if (localCartArr.length === 0 && serverCartArr.length > 0) {
|
||||
@@ -434,7 +408,7 @@ export class LoginComponent extends Component {
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Google-Anmeldung fehlgeschlagen',
|
||||
error: this.props.t ? this.props.t('auth.errors.googleLoginFailed') : 'Google-Anmeldung fehlgeschlagen',
|
||||
showGoogleAuth: false // Reset Google auth state on failed login
|
||||
});
|
||||
}
|
||||
@@ -444,7 +418,7 @@ export class LoginComponent extends Component {
|
||||
handleGoogleLoginError = (error) => {
|
||||
console.error('Google Login Error:', error);
|
||||
this.setState({
|
||||
error: 'Google-Anmeldung fehlgeschlagen',
|
||||
error: this.props.t ? this.props.t('auth.errors.googleLoginFailed') : 'Google-Anmeldung fehlgeschlagen',
|
||||
showGoogleAuth: false, // Reset Google auth state on error
|
||||
loading: false
|
||||
});
|
||||
@@ -457,7 +431,7 @@ export class LoginComponent extends Component {
|
||||
localAndArchiveServer(localCartSync, serverCartSync);
|
||||
break;
|
||||
case 'deleteServer':
|
||||
this.props.socket.emit('updateCart', window.cart)
|
||||
window.socketManager.emit('updateCart', window.cart)
|
||||
break;
|
||||
case 'useServer':
|
||||
window.cart = serverCartSync;
|
||||
@@ -644,17 +618,18 @@ export class LoginComponent extends Component {
|
||||
<Suspense fallback={
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PersonIcon />}
|
||||
sx={{ width: '100%', backgroundColor: '#4285F4', color: 'white' }}
|
||||
startIcon={<GoogleIcon />}
|
||||
disabled
|
||||
fullWidth
|
||||
style={{backgroundColor: '#4285F4', color: 'white' }}
|
||||
>
|
||||
Mit Google anmelden
|
||||
Loading...
|
||||
</Button>
|
||||
}>
|
||||
<GoogleAuthProvider clientId={config.googleClientId}>
|
||||
<GoogleLoginButton
|
||||
onSuccess={this.handleGoogleLoginSuccess}
|
||||
onError={this.handleGoogleLoginError}
|
||||
text="Mit Google anmelden"
|
||||
style={{ width: '100%', backgroundColor: '#4285F4' }}
|
||||
autoInitiate={true}
|
||||
/>
|
||||
|
||||
@@ -12,571 +12,69 @@ import SharedCarousel from "./SharedCarousel.js";
|
||||
import { getCombinedAnimatedBorderStyles } from "../utils/animatedBorderStyles.js";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const MainPageLayout = () => {
|
||||
const location = useLocation();
|
||||
const currentPath = location.pathname;
|
||||
const { t } = useTranslation();
|
||||
const [starHovered, setStarHovered] = React.useState(false);
|
||||
|
||||
// Determine which page we're on
|
||||
const isHome = currentPath === "/";
|
||||
const isAktionen = currentPath === "/aktionen";
|
||||
const isFiliale = currentPath === "/filiale";
|
||||
|
||||
// Add CSS animations for rotating stars
|
||||
React.useEffect(() => {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes rotateClockwise {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes rotateCounterClockwise {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(-360deg); }
|
||||
}
|
||||
|
||||
.star-rotate-slow-cw {
|
||||
animation: rotateClockwise 60s linear infinite;
|
||||
}
|
||||
|
||||
.star-rotate-slow-ccw {
|
||||
animation: rotateCounterClockwise 45s linear infinite;
|
||||
}
|
||||
|
||||
.star-rotate-medium-cw {
|
||||
animation: rotateClockwise 30s linear infinite;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(style);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get navigation config based on current page
|
||||
const getNavigationConfig = () => {
|
||||
if (isHome) {
|
||||
return {
|
||||
leftNav: { text: t('navigation.aktionen'), link: "/aktionen" },
|
||||
rightNav: { text: t('navigation.filiale'), link: "/filiale" }
|
||||
};
|
||||
} else if (isAktionen) {
|
||||
return {
|
||||
leftNav: { text: t('navigation.filiale'), link: "/filiale" },
|
||||
rightNav: { text: t('navigation.home'), link: "/" }
|
||||
};
|
||||
} else if (isFiliale) {
|
||||
return {
|
||||
leftNav: { text: t('navigation.home'), link: "/" },
|
||||
rightNav: { text: t('navigation.aktionen'), link: "/aktionen" }
|
||||
};
|
||||
}
|
||||
return { leftNav: null, rightNav: null };
|
||||
};
|
||||
|
||||
const allTitles = {
|
||||
home: t('titles.home'),
|
||||
aktionen: t('titles.aktionen'),
|
||||
filiale: t('titles.filiale')
|
||||
};
|
||||
|
||||
// Define all content boxes for layered rendering
|
||||
const allContentBoxes = {
|
||||
home: [
|
||||
{
|
||||
title: t('sections.seeds'),
|
||||
image: "/assets/images/seeds.jpg",
|
||||
bgcolor: "#e1f0d3",
|
||||
link: "/Kategorie/Seeds"
|
||||
},
|
||||
{
|
||||
title: t('sections.stecklinge'),
|
||||
image: "/assets/images/cutlings.jpg",
|
||||
bgcolor: "#e8f5d6",
|
||||
link: "/Kategorie/Stecklinge"
|
||||
}
|
||||
],
|
||||
aktionen: [
|
||||
{
|
||||
title: t('sections.oilPress'),
|
||||
image: "/assets/images/presse.jpg",
|
||||
bgcolor: "#e1f0d3",
|
||||
link: "/presseverleih"
|
||||
},
|
||||
{
|
||||
title: t('sections.thcTest'),
|
||||
image: "/assets/images/purpl.jpg",
|
||||
bgcolor: "#e8f5d6",
|
||||
link: "/thc-test"
|
||||
}
|
||||
],
|
||||
filiale: [
|
||||
{
|
||||
title: t('sections.address1'),
|
||||
image: "/assets/images/filiale1.jpg",
|
||||
bgcolor: "#e1f0d3",
|
||||
link: "/filiale"
|
||||
},
|
||||
{
|
||||
title: t('sections.address2'),
|
||||
image: "/assets/images/filiale2.jpg",
|
||||
bgcolor: "#e8f5d6",
|
||||
link: "/filiale"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Get opacity for each page layer
|
||||
const getOpacity = (pageType) => {
|
||||
if (pageType === "home" && isHome) return 1;
|
||||
if (pageType === "aktionen" && isAktionen) return 1;
|
||||
if (pageType === "filiale" && isFiliale) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const navConfig = getNavigationConfig();
|
||||
|
||||
// Navigation text mapping for translation
|
||||
const navTexts = [
|
||||
{ key: 'aktionen', text: t('navigation.aktionen'), link: '/aktionen' },
|
||||
{ key: 'filiale', text: t('navigation.filiale'), link: '/filiale' },
|
||||
{ key: 'home', text: t('navigation.home'), link: '/' }
|
||||
];
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 2 }}>
|
||||
<style>{getCombinedAnimatedBorderStyles(['seeds', 'cutlings'])}</style>
|
||||
|
||||
{/* Main Navigation Header */}
|
||||
<Box sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 4,
|
||||
mt: 2,
|
||||
px: 0,
|
||||
transition: "all 0.3s ease-in-out",
|
||||
// Portrait phone: stack title above navigation
|
||||
flexDirection: {
|
||||
xs: "column",
|
||||
sm: "row"
|
||||
}
|
||||
}}>
|
||||
{/* Title for portrait phones - shown first */}
|
||||
<Box sx={{
|
||||
display: { xs: "block", sm: "none" },
|
||||
mb: { xs: 2, sm: 0 },
|
||||
width: "100%",
|
||||
textAlign: "center",
|
||||
position: "relative"
|
||||
}}>
|
||||
{Object.entries(allTitles).map(([pageType, title]) => (
|
||||
<Typography
|
||||
key={pageType}
|
||||
variant="h3"
|
||||
component="h1"
|
||||
sx={{
|
||||
fontFamily: "SwashingtonCP",
|
||||
fontSize: { xs: "2.125rem", sm: "2.125rem", md: "3rem" },
|
||||
textAlign: "center",
|
||||
color: "primary.main",
|
||||
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)",
|
||||
transition: "opacity 0.5s ease-in-out",
|
||||
opacity: getOpacity(pageType),
|
||||
position: pageType === "home" ? "relative" : "absolute",
|
||||
top: pageType !== "home" ? 0 : "auto",
|
||||
left: pageType !== "home" ? 0 : "auto",
|
||||
transform: "none",
|
||||
width: "100%",
|
||||
pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none",
|
||||
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" },
|
||||
wordWrap: "break-word",
|
||||
hyphens: "auto"
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Navigation container for portrait phones */}
|
||||
<Box sx={{
|
||||
display: { xs: "flex", sm: "contents" },
|
||||
width: { xs: "100%", sm: "auto" },
|
||||
justifyContent: { xs: "space-between", sm: "initial" },
|
||||
alignItems: "center"
|
||||
}}>
|
||||
{/* Left Navigation - Layered rendering */}
|
||||
<Box sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexShrink: 0,
|
||||
justifyContent: "flex-start",
|
||||
position: "relative",
|
||||
mr: { xs: 0, sm: 2 }
|
||||
}}>
|
||||
{navTexts.map((navItem, index) => {
|
||||
const isActive = navConfig.leftNav && navConfig.leftNav.text === navItem.text;
|
||||
const link = navItem.link;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={navItem.key}
|
||||
component={Link}
|
||||
to={link}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
transition: "all 0.3s ease",
|
||||
opacity: isActive ? 1 : 0,
|
||||
position: index === 0 ? "relative" : "absolute",
|
||||
left: index !== 0 ? 0 : "auto",
|
||||
pointerEvents: isActive ? "auto" : "none",
|
||||
"&:hover": {
|
||||
transform: "translateX(-5px)",
|
||||
color: "primary.main"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronLeft sx={{ fontSize: "2rem", mr: 1 }} />
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: "SwashingtonCP",
|
||||
fontSize: { xs: "1.25rem", sm: "1.25rem", md: "2.125rem" },
|
||||
textShadow: "2px 2px 4px rgba(0, 0, 0, 0.3)",
|
||||
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" },
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
>
|
||||
{navItem.text}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* Center Title - Layered rendering - Hidden on portrait phones, shown on larger screens */}
|
||||
<Box sx={{
|
||||
flex: 1,
|
||||
display: { xs: "none", sm: "flex" },
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
px: 0,
|
||||
position: "relative",
|
||||
minWidth: 0
|
||||
}}>
|
||||
{Object.entries(allTitles).map(([pageType, title]) => (
|
||||
<Typography
|
||||
key={pageType}
|
||||
variant="h3"
|
||||
component="h1"
|
||||
sx={{
|
||||
fontFamily: "SwashingtonCP",
|
||||
fontSize: { xs: "2.125rem", sm: "2.125rem", md: "3rem" },
|
||||
textAlign: "center",
|
||||
color: "primary.main",
|
||||
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)",
|
||||
transition: "opacity 0.5s ease-in-out",
|
||||
opacity: getOpacity(pageType),
|
||||
position: pageType === "home" ? "relative" : "absolute",
|
||||
top: pageType !== "home" ? "50%" : "auto",
|
||||
left: pageType !== "home" ? "50%" : "auto",
|
||||
transform: pageType !== "home" ? "translate(-50%, -50%)" : "none",
|
||||
width: "100%",
|
||||
pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none",
|
||||
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" },
|
||||
wordWrap: "break-word",
|
||||
hyphens: "auto"
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Right Navigation - Layered rendering */}
|
||||
<Box sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexShrink: 0,
|
||||
justifyContent: "flex-end",
|
||||
position: "relative",
|
||||
ml: { xs: 0, sm: 2 }
|
||||
}}>
|
||||
{navTexts.map((navItem, index) => {
|
||||
const isActive = navConfig.rightNav && navConfig.rightNav.text === navItem.text;
|
||||
const link = navItem.link;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={navItem.key}
|
||||
component={Link}
|
||||
to={link}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
transition: "all 0.3s ease",
|
||||
opacity: isActive ? 1 : 0,
|
||||
position: index === 0 ? "relative" : "absolute",
|
||||
right: index !== 0 ? 0 : "auto",
|
||||
pointerEvents: isActive ? "auto" : "none",
|
||||
"&:hover": {
|
||||
transform: "translateX(5px)",
|
||||
color: "primary.main"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: "SwashingtonCP",
|
||||
fontSize: { xs: "1.25rem", sm: "1.25rem", md: "2.125rem" },
|
||||
textShadow: "2px 2px 4px rgba(0, 0, 0, 0.3)",
|
||||
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" },
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
>
|
||||
{navItem.text}
|
||||
</Typography>
|
||||
<ChevronRight sx={{ fontSize: "2rem", ml: 1 }} />
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Content Boxes - Layered rendering */}
|
||||
<Box sx={{ position: "relative", mb: 4 }}>
|
||||
{Object.entries(allContentBoxes).map(([pageType, contentBoxes]) => (
|
||||
<Grid
|
||||
key={pageType}
|
||||
container
|
||||
spacing={0}
|
||||
sx={{
|
||||
transition: "opacity 0.5s ease-in-out",
|
||||
opacity: getOpacity(pageType),
|
||||
position: pageType === "home" ? "relative" : "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none"
|
||||
}}
|
||||
>
|
||||
{contentBoxes.map((box, index) => (
|
||||
const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity, translatedContent }) => (
|
||||
<Grid key={`${pageType}-${index}`} item xs={12} sm={6} sx={{ p: 2, width: "50%", position: 'relative' }}>
|
||||
{/* Multi-pointed star for seeds box - moved to Grid level */}
|
||||
{index === 0 && pageType === "home" && (
|
||||
<div
|
||||
style={{
|
||||
{index === 0 && pageType === "filiale" && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '-45px',
|
||||
top: '-55px',
|
||||
left: '-45px',
|
||||
width: '180px',
|
||||
height: '180px',
|
||||
zIndex: 999,
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onMouseEnter={() => setStarHovered(true)}
|
||||
onMouseLeave={() => setStarHovered(false)}
|
||||
>
|
||||
{/* Background star - slightly larger and rotated */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="168"
|
||||
height="168"
|
||||
className="star-rotate-slow-cw"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-9px',
|
||||
left: '-9px',
|
||||
transform: 'rotate(20deg)'
|
||||
pointerEvents: 'none',
|
||||
'& *': { pointerEvents: 'none' },
|
||||
filter: 'drop-shadow(6px 6px 12px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 20px rgba(255, 215, 0, 0.8)) drop-shadow(0 0 40px rgba(255, 215, 0, 0.4))',
|
||||
display: { xs: 'none', sm: 'block' }
|
||||
}}
|
||||
>
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#B8860B"
|
||||
stroke="none"
|
||||
/>
|
||||
<svg viewBox="0 0 60 60" width="168" height="168" className="star-rotate-slow-cw" style={{ position: 'absolute', top: '-9px', left: '-9px', transform: 'rotate(20deg)' }}>
|
||||
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#B8860B" />
|
||||
</svg>
|
||||
|
||||
{/* Middle star - medium size with different rotation */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="159"
|
||||
height="159"
|
||||
className="star-rotate-slow-ccw"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-4.5px',
|
||||
left: '-4.5px',
|
||||
transform: 'rotate(-25deg)'
|
||||
}}
|
||||
>
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#DAA520"
|
||||
stroke="none"
|
||||
/>
|
||||
<svg viewBox="0 0 60 60" width="159" height="159" className="star-rotate-slow-ccw" style={{ position: 'absolute', top: '-4.5px', left: '-4.5px', transform: 'rotate(-25deg)' }}>
|
||||
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#DAA520" />
|
||||
</svg>
|
||||
|
||||
{/* Foreground star - main star with text */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="150"
|
||||
height="150"
|
||||
className="star-rotate-medium-cw"
|
||||
>
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#FFD700"
|
||||
stroke="none"
|
||||
/>
|
||||
<svg viewBox="0 0 60 60" width="150" height="150" className="star-rotate-medium-cw">
|
||||
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#FFD700" />
|
||||
</svg>
|
||||
|
||||
{/* Text positioned in the center of the star */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '45%',
|
||||
left: '43%',
|
||||
transform: 'translate(-50%, -50%) rotate(-10deg)',
|
||||
color: 'white',
|
||||
fontWeight: '900',
|
||||
fontSize: '20px',
|
||||
textShadow: '0px 3px 6px rgba(0,0,0,0.5)',
|
||||
zIndex: 1000,
|
||||
textAlign: 'center',
|
||||
lineHeight: '1.1',
|
||||
width: '135px',
|
||||
transition: 'opacity 0.3s ease',
|
||||
opacity: starHovered ? 0 : 1
|
||||
}}
|
||||
>
|
||||
{t('sections.showUsPhoto')}
|
||||
</div>
|
||||
|
||||
{/* Hover text */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '45%',
|
||||
left: '43%',
|
||||
transform: 'translate(-50%, -50%) rotate(-10deg)',
|
||||
color: 'white',
|
||||
fontWeight: '900',
|
||||
fontSize: '20px',
|
||||
textShadow: '0px 3px 6px rgba(0,0,0,0.5)',
|
||||
zIndex: 1000,
|
||||
textAlign: 'center',
|
||||
lineHeight: '1.1',
|
||||
width: '135px',
|
||||
opacity: starHovered ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease'
|
||||
}}
|
||||
>
|
||||
{t('sections.selectSeedRate')}
|
||||
<div style={{ position: 'absolute', top: '45%', left: '43%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', transition: 'opacity 0.3s ease', opacity: starHovered ? 0 : 1 }}>
|
||||
{translatedContent.showUsPhoto}
|
||||
</div>
|
||||
<div style={{ position: 'absolute', top: '45%', left: '43%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', opacity: starHovered ? 1 : 0, transition: 'opacity 0.3s ease' }}>
|
||||
{translatedContent.selectSeedRate}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Multi-pointed star for stecklinge box - bottom right */}
|
||||
{index === 1 && pageType === "home" && (
|
||||
<div
|
||||
style={{
|
||||
{index === 1 && pageType === "filiale" && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: '-65px',
|
||||
bottom: '-45px',
|
||||
right: '-65px',
|
||||
width: '180px',
|
||||
height: '180px',
|
||||
zIndex: 999,
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'pointer'
|
||||
pointerEvents: 'none',
|
||||
'& *': { pointerEvents: 'none' },
|
||||
filter: 'drop-shadow(6px 6px 12px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 20px rgba(175, 238, 238, 0.8)) drop-shadow(0 0 40px rgba(175, 238, 238, 0.4))',
|
||||
display: { xs: 'none', sm: 'block' }
|
||||
}}
|
||||
>
|
||||
{/* Background star - slightly larger and rotated */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="168"
|
||||
height="168"
|
||||
className="star-rotate-slow-ccw"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-9px',
|
||||
left: '-9px',
|
||||
transform: 'rotate(20deg)'
|
||||
}}
|
||||
>
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#5F9EA0"
|
||||
stroke="none"
|
||||
/>
|
||||
<svg viewBox="0 0 60 60" width="168" height="168" className="star-rotate-slow-ccw" style={{ position: 'absolute', top: '-9px', left: '-9px', transform: 'rotate(20deg)' }}>
|
||||
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#5F9EA0" />
|
||||
</svg>
|
||||
|
||||
{/* Middle star - medium size with different rotation */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="159"
|
||||
height="159"
|
||||
className="star-rotate-medium-cw"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-4.5px',
|
||||
left: '-4.5px',
|
||||
transform: 'rotate(-25deg)'
|
||||
}}
|
||||
>
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#7FCDCD"
|
||||
stroke="none"
|
||||
/>
|
||||
<svg viewBox="0 0 60 60" width="159" height="159" className="star-rotate-medium-cw" style={{ position: 'absolute', top: '-4.5px', left: '-4.5px', transform: 'rotate(-25deg)' }}>
|
||||
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#7FCDCD" />
|
||||
</svg>
|
||||
|
||||
{/* Foreground star - main star with text */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="150"
|
||||
height="150"
|
||||
className="star-rotate-slow-cw"
|
||||
>
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#AFEEEE"
|
||||
stroke="none"
|
||||
/>
|
||||
<svg viewBox="0 0 60 60" width="150" height="150" className="star-rotate-slow-cw">
|
||||
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#AFEEEE" />
|
||||
</svg>
|
||||
|
||||
{/* Text positioned in the center of the star */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '42%',
|
||||
left: '45%',
|
||||
transform: 'translate(-50%, -50%) rotate(10deg)',
|
||||
color: 'white',
|
||||
fontWeight: '900',
|
||||
fontSize: '20px',
|
||||
textShadow: '0px 3px 6px rgba(0,0,0,0.5)',
|
||||
zIndex: 1000,
|
||||
textAlign: 'center',
|
||||
lineHeight: '1.1',
|
||||
width: '135px'
|
||||
}}
|
||||
>
|
||||
{t('sections.indoorSeason')}
|
||||
</div>
|
||||
<div style={{ position: 'absolute', top: '42%', left: '45%', transform: 'translate(-50%, -50%) rotate(10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px' }}>
|
||||
{translatedContent.indoorSeason}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<div className={`animated-border-card ${index === 0 ? 'seeds-card' : 'cutlings-card'}`}>
|
||||
<Paper
|
||||
component={Link}
|
||||
@@ -592,53 +90,192 @@ const MainPageLayout = () => {
|
||||
flexDirection: "column",
|
||||
boxShadow: 10,
|
||||
transition: "all 0.3s ease",
|
||||
"&:hover": {
|
||||
transform: "translateY(-5px)",
|
||||
boxShadow: 20,
|
||||
},
|
||||
"&:hover": { transform: "translateY(-5px)", boxShadow: 20 },
|
||||
}}
|
||||
onMouseEnter={index === 0 && pageType === "filiale" ? () => setStarHovered(true) : undefined}
|
||||
onMouseLeave={index === 0 && pageType === "filiale" ? () => setStarHovered(false) : undefined}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
bgcolor: box.bgcolor,
|
||||
backgroundImage: `url("${box.image}")`,
|
||||
backgroundSize: "contain",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bgcolor: "rgba(27, 94, 32, 0.8)",
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: "1.6rem",
|
||||
color: "white",
|
||||
fontFamily: "SwashingtonCP",
|
||||
}}
|
||||
>
|
||||
{box.title}
|
||||
</Typography>
|
||||
<Box sx={{ height: "100%", bgcolor: box.bgcolor, position: "relative", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
{opacity === 1 && (
|
||||
<img src={box.image} alt={box.title} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain", position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)" }} />
|
||||
)}
|
||||
<Box sx={{ position: "absolute", bottom: 0, left: 0, right: 0, bgcolor: "rgba(27, 94, 32, 0.8)", p: 1 }}>
|
||||
<Typography sx={{ fontSize: "1.6rem", color: "white", fontFamily: "SwashingtonCP" }}>{box.title}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</div>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
const MainPageLayout = () => {
|
||||
const location = useLocation();
|
||||
const currentPath = location.pathname;
|
||||
const { t } = useTranslation();
|
||||
const [starHovered, setStarHovered] = React.useState(false);
|
||||
const translatedContent = {
|
||||
showUsPhoto: t('sections.showUsPhoto'),
|
||||
selectSeedRate: t('sections.selectSeedRate'),
|
||||
indoorSeason: t('sections.indoorSeason')
|
||||
};
|
||||
|
||||
const isHome = currentPath === "/";
|
||||
const isAktionen = currentPath === "/aktionen";
|
||||
const isFiliale = currentPath === "/filiale";
|
||||
|
||||
React.useEffect(() => {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes rotateClockwise {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes rotateCounterClockwise {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(-360deg); }
|
||||
}
|
||||
.star-rotate-slow-cw {
|
||||
animation: rotateClockwise 60s linear infinite;
|
||||
}
|
||||
.star-rotate-slow-ccw {
|
||||
animation: rotateCounterClockwise 45s linear infinite;
|
||||
}
|
||||
.star-rotate-medium-cw {
|
||||
animation: rotateClockwise 30s linear infinite;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
return () => document.head.removeChild(style);
|
||||
}, []);
|
||||
|
||||
const getNavigationConfig = () => {
|
||||
if (isHome) return { leftNav: { text: t('navigation.aktionen'), link: "/aktionen" }, rightNav: { text: t('navigation.filiale'), link: "/filiale" } };
|
||||
if (isAktionen) return { leftNav: { text: t('navigation.filiale'), link: "/filiale" }, rightNav: { text: t('navigation.home'), link: "/" } };
|
||||
if (isFiliale) return { leftNav: { text: t('navigation.home'), link: "/" }, rightNav: { text: t('navigation.aktionen'), link: "/aktionen" } };
|
||||
return { leftNav: null, rightNav: null };
|
||||
};
|
||||
|
||||
const allTitles = {
|
||||
home: t('titles.home'),
|
||||
aktionen: t('titles.aktionen'),
|
||||
filiale: t('titles.filiale')
|
||||
};
|
||||
|
||||
const allContentBoxes = {
|
||||
home: [
|
||||
{ title: t('sections.seeds'), image: "/assets/images/seeds.avif", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" },
|
||||
{ title: t('sections.konfigurator'), image: "/assets/images/konfigurator.avif", bgcolor: "#e8f5d6", link: "/Konfigurator" }
|
||||
],
|
||||
aktionen: [
|
||||
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/presseverleih" },
|
||||
{ title: t('sections.thcTest'), image: "/assets/images/purpl.jpg", bgcolor: "#e8f5d6", link: "/thc-test" }
|
||||
],
|
||||
filiale: [
|
||||
{ title: t('sections.address1'), image: "/assets/images/filiale1.jpg", bgcolor: "#e1f0d3", link: "/filiale" },
|
||||
{ title: t('sections.address2'), image: "/assets/images/filiale2.jpg", bgcolor: "#e8f5d6", link: "/filiale" }
|
||||
]
|
||||
};
|
||||
|
||||
const getOpacity = (pageType) => {
|
||||
if (pageType === "home" && isHome) return 1;
|
||||
if (pageType === "aktionen" && isAktionen) return 1;
|
||||
if (pageType === "filiale" && isFiliale) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const navConfig = getNavigationConfig();
|
||||
const navTexts = [
|
||||
{ key: 'aktionen', text: t('navigation.aktionen'), link: '/aktionen' },
|
||||
{ key: 'filiale', text: t('navigation.filiale'), link: '/filiale' },
|
||||
{ key: 'home', text: t('navigation.home'), link: '/' }
|
||||
];
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 2 }}>
|
||||
<style>{getCombinedAnimatedBorderStyles(['seeds', 'cutlings'])}</style>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 4, mt: 2, px: 0, transition: "all 0.3s ease-in-out", flexDirection: { xs: "column", sm: "row" } }}>
|
||||
<Box sx={{ display: { xs: "block", sm: "none" }, mb: { xs: 2, sm: 0 }, width: "100%", textAlign: "center", position: "relative" }}>
|
||||
{Object.entries(allTitles).map(([pageType, title]) => (
|
||||
<Typography key={pageType} variant="h3" component="h1" sx={{
|
||||
fontFamily: "SwashingtonCP", fontSize: { xs: "2.125rem", sm: "2.125rem", md: "3rem" }, textAlign: "center", color: "primary.main",
|
||||
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)", transition: "opacity 0.5s ease-in-out", opacity: getOpacity(pageType),
|
||||
position: pageType === "home" ? "relative" : "absolute", top: pageType !== "home" ? 0 : "auto", left: pageType !== "home" ? 0 : "auto",
|
||||
transform: "none", width: "100%", pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none",
|
||||
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, wordWrap: "break-word", hyphens: "auto"
|
||||
}}>{title}</Typography>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: { xs: "flex", sm: "contents" }, width: { xs: "100%", sm: "auto" }, justifyContent: { xs: "space-between", sm: "initial" }, alignItems: "center" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", flexShrink: 0, justifyContent: "flex-start", position: "relative", mr: { xs: 0, sm: 2 } }}>
|
||||
{navTexts.map((navItem, index) => {
|
||||
const isActive = navConfig.leftNav && navConfig.leftNav.text === navItem.text;
|
||||
return (
|
||||
<Box key={navItem.key} component={Link} to={navItem.link} sx={{
|
||||
display: "flex", alignItems: "center", textDecoration: "none", color: "inherit", transition: "all 0.3s ease",
|
||||
opacity: isActive ? 1 : 0, position: index === 0 ? "relative" : "absolute", left: index !== 0 ? 0 : "auto",
|
||||
pointerEvents: isActive ? "auto" : "none", "&:hover": { transform: "translateX(-5px)", color: "primary.main" }
|
||||
}}>
|
||||
<ChevronLeft sx={{ fontSize: "2rem", mr: 1 }} />
|
||||
<Typography sx={{
|
||||
fontFamily: "SwashingtonCP", fontSize: { xs: "1.25rem", sm: "1.25rem", md: "2.125rem" },
|
||||
textShadow: "2px 2px 4px rgba(0, 0, 0, 0.3)", lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, whiteSpace: "nowrap"
|
||||
}}>{navItem.text}</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, display: { xs: "none", sm: "flex" }, justifyContent: "center", alignItems: "center", px: 0, position: "relative", minWidth: 0 }}>
|
||||
{Object.entries(allTitles).map(([pageType, title]) => (
|
||||
<Typography key={pageType} variant="h3" component="h1" sx={{
|
||||
fontFamily: "SwashingtonCP", fontSize: { xs: "2.125rem", sm: "2.125rem", md: "3rem" }, textAlign: "center", color: "primary.main",
|
||||
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)", transition: "opacity 0.5s ease-in-out", opacity: getOpacity(pageType),
|
||||
position: pageType === "home" ? "relative" : "absolute", top: pageType !== "home" ? "50%" : "auto", left: pageType !== "home" ? "50%" : "auto",
|
||||
transform: pageType !== "home" ? "translate(-50%, -50%)" : "none", width: "100%", pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none",
|
||||
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, wordWrap: "break-word", hyphens: "auto"
|
||||
}}>{title}</Typography>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", flexShrink: 0, justifyContent: "flex-end", position: "relative", ml: { xs: 0, sm: 2 } }}>
|
||||
{navTexts.map((navItem, index) => {
|
||||
const isActive = navConfig.rightNav && navConfig.rightNav.text === navItem.text;
|
||||
return (
|
||||
<Box key={navItem.key} component={Link} to={navItem.link} sx={{
|
||||
display: "flex", alignItems: "center", textDecoration: "none", color: "inherit", transition: "all 0.3s ease",
|
||||
opacity: isActive ? 1 : 0, position: index === 0 ? "relative" : "absolute", right: index !== 0 ? 0 : "auto",
|
||||
pointerEvents: isActive ? "auto" : "none", "&:hover": { transform: "translateX(5px)", color: "primary.main" }
|
||||
}}>
|
||||
<Typography sx={{
|
||||
fontFamily: "SwashingtonCP", fontSize: { xs: "1.25rem", sm: "1.25rem", md: "2.125rem" },
|
||||
textShadow: "2px 2px 4px rgba(0, 0, 0, 0.3)", lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, whiteSpace: "nowrap"
|
||||
}}>{navItem.text}</Typography>
|
||||
<ChevronRight sx={{ fontSize: "2rem", ml: 1 }} />
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ position: "relative", mb: 4 }}>
|
||||
{Object.entries(allContentBoxes).map(([pageType, contentBoxes]) => (
|
||||
<Grid key={pageType} container spacing={0} sx={{
|
||||
transition: "opacity 0.5s ease-in-out", opacity: getOpacity(pageType),
|
||||
position: pageType === "home" ? "relative" : "absolute", top: 0, left: 0, width: "100%", pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none"
|
||||
}}>
|
||||
{contentBoxes.map((box, index) => (
|
||||
<ContentBox
|
||||
key={`${pageType}-${index}`}
|
||||
box={box}
|
||||
index={index}
|
||||
pageType={pageType}
|
||||
starHovered={starHovered}
|
||||
setStarHovered={setStarHovered}
|
||||
opacity={getOpacity(pageType)}
|
||||
translatedContent={translatedContent}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Shared Carousel */}
|
||||
<SharedCarousel />
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import SocketContext from '../contexts/SocketContext.js';
|
||||
|
||||
class PaymentSuccess extends Component {
|
||||
static contextType = SocketContext;
|
||||
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -73,19 +72,10 @@ class PaymentSuccess extends Component {
|
||||
};
|
||||
|
||||
checkMolliePaymentStatus = (paymentId) => {
|
||||
const { socket } = this.context;
|
||||
|
||||
if (!socket || !socket.connected) {
|
||||
console.error('Socket not connected');
|
||||
this.setState({
|
||||
redirectUrl: '/profile#cart',
|
||||
processing: false,
|
||||
error: 'Connection error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('checkMollieIntent', { paymentId }, (response) => {
|
||||
|
||||
window.socketManager.emit('checkMollieIntent', { paymentId }, (response) => {
|
||||
if (response.success) {
|
||||
console.log('Payment Status:', response.payment.status);
|
||||
console.log('Is Paid:', response.payment.isPaid);
|
||||
|
||||
@@ -8,10 +8,9 @@ import {
|
||||
Grid,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Delete,
|
||||
CloudUpload
|
||||
} from '@mui/icons-material';
|
||||
import Delete from '@mui/icons-material/Delete';
|
||||
import CloudUpload from '@mui/icons-material/CloudUpload';
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
|
||||
class PhotoUpload extends Component {
|
||||
constructor(props) {
|
||||
@@ -32,7 +31,7 @@ class PhotoUpload extends Component {
|
||||
// Validate file count
|
||||
if (this.state.files.length + selectedFiles.length > maxFiles) {
|
||||
this.setState({
|
||||
error: `Maximal ${maxFiles} Dateien erlaubt`
|
||||
error: this.props.t("productDialogs.photoUploadErrorMaxFiles", { max: maxFiles })
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -45,14 +44,14 @@ class PhotoUpload extends Component {
|
||||
for (const file of selectedFiles) {
|
||||
if (!validTypes.includes(file.type)) {
|
||||
this.setState({
|
||||
error: 'Nur Bilddateien (JPEG, PNG, GIF, WebP) sind erlaubt'
|
||||
error: this.props.t("productDialogs.photoUploadErrorFileType")
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
this.setState({
|
||||
error: `Datei zu groß. Maximum: ${Math.round(maxSize / (1024 * 1024))}MB`
|
||||
error: this.props.t("productDialogs.photoUploadErrorFileSize", { maxSize: Math.round(maxSize / (1024 * 1024)) })
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -169,12 +168,12 @@ class PhotoUpload extends Component {
|
||||
|
||||
render() {
|
||||
const { files, previews, error } = this.state;
|
||||
const { disabled, label } = this.props;
|
||||
const { disabled, label, t } = this.props;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
|
||||
{label || 'Fotos anhängen (optional)'}
|
||||
{label || t("productDialogs.photoUploadLabelDefault")}
|
||||
</Typography>
|
||||
|
||||
<input
|
||||
@@ -194,7 +193,7 @@ class PhotoUpload extends Component {
|
||||
disabled={disabled}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
Fotos auswählen
|
||||
{t("productDialogs.photoUploadSelect")}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
@@ -230,6 +229,7 @@ class PhotoUpload extends Component {
|
||||
size="small"
|
||||
onClick={() => this.handleRemoveFile(index)}
|
||||
disabled={disabled}
|
||||
aria-label={t("productDialogs.photoUploadRemove")}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
@@ -270,10 +270,10 @@ class PhotoUpload extends Component {
|
||||
|
||||
{files.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
{files.length} Datei(en) ausgewählt
|
||||
{t("productDialogs.photoUploadSelectedFiles", { count: files.length })}
|
||||
{previews.length > 0 && previews.some(p => p.originalSize && p.compressedSize) && (
|
||||
<span style={{ marginLeft: '8px' }}>
|
||||
(komprimiert für Upload)
|
||||
{t("productDialogs.photoUploadCompressed")}
|
||||
</span>
|
||||
)}
|
||||
</Typography>
|
||||
@@ -283,4 +283,4 @@ class PhotoUpload extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default PhotoUpload;
|
||||
export default withI18n()(PhotoUpload);
|
||||
@@ -7,10 +7,67 @@ import Typography from '@mui/material/Typography';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import AddToCartButton from './AddToCartButton.js';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
|
||||
// Helper function to find level 1 category ID from any category ID
|
||||
const findLevel1CategoryId = (categoryId) => {
|
||||
try {
|
||||
const currentLanguage = 'de'; // Default to German
|
||||
const categoryTreeCache = window.categoryService?.getSync(209, currentLanguage);
|
||||
|
||||
if (!categoryTreeCache || !categoryTreeCache.children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper function to find category by ID and get its level 1 parent
|
||||
const findCategoryAndLevel1 = (categories, targetId) => {
|
||||
for (const category of categories) {
|
||||
if (category.id === targetId) {
|
||||
// Found the category, now find its level 1 parent
|
||||
return findLevel1Parent(categoryTreeCache.children, category);
|
||||
}
|
||||
|
||||
if (category.children && category.children.length > 0) {
|
||||
const result = findCategoryAndLevel1(category.children, targetId);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper function to find the level 1 parent (direct child of root category 209)
|
||||
const findLevel1Parent = (level1Categories, category) => {
|
||||
// If this category's parent is 209, it's already level 1
|
||||
if (category.parentId === 209) {
|
||||
return category.id;
|
||||
}
|
||||
|
||||
// Otherwise, find the parent and check if it's level 1
|
||||
for (const level1Category of level1Categories) {
|
||||
if (level1Category.id === category.parentId) {
|
||||
return level1Category.id;
|
||||
}
|
||||
|
||||
// If parent has children, search recursively
|
||||
if (level1Category.children && level1Category.children.length > 0) {
|
||||
const result = findLevel1Parent(level1Category.children, category);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return findCategoryAndLevel1(categoryTreeCache.children, parseInt(categoryId));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error finding level 1 category:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
class Product extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -27,10 +84,24 @@ class Product extends Component {
|
||||
this.state = {image:window.smallPicCache[bildId],loading:false, error: false}
|
||||
}else{
|
||||
this.state = {image: null, loading: true, error: false};
|
||||
console.log("Product: Fetching image from socketB", this.props.socketB);
|
||||
this.props.socketB.emit('getPic', { bildId, size:'small' }, (res) => {
|
||||
|
||||
this.loadImage(bildId);
|
||||
}
|
||||
}else{
|
||||
this.state = {image: null, loading: false, error: false};
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
}
|
||||
|
||||
loadImage = (bildId) => {
|
||||
|
||||
console.log('loadImagevisSocket', bildId);
|
||||
window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => {
|
||||
if(res.success){
|
||||
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
|
||||
if (this._isMounted) {
|
||||
this.setState({image: window.smallPicCache[bildId], loading: false});
|
||||
} else {
|
||||
@@ -46,16 +117,9 @@ class Product extends Component {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}else{
|
||||
this.state = {image: null, loading: false, error: false};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
@@ -66,6 +130,23 @@ class Product extends Component {
|
||||
// In a real app, this would update a cart state in a parent component or Redux store
|
||||
}
|
||||
|
||||
handleProductClick = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { categoryId } = this.props;
|
||||
|
||||
// Find the level 1 category for this product
|
||||
const level1CategoryId = categoryId ? findLevel1CategoryId(categoryId) : null;
|
||||
|
||||
// Navigate to the product page WITH the category information in the state
|
||||
const navigate = this.props.navigate;
|
||||
if (navigate) {
|
||||
navigate(`/Artikel/${this.props.seoName}`, {
|
||||
state: { articleCategoryId: level1CategoryId }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
id, name, price, available, manufacturer, seoName,
|
||||
@@ -246,15 +327,15 @@ class Product extends Component {
|
||||
)}
|
||||
|
||||
<Box
|
||||
component={Link}
|
||||
to={`/Artikel/${seoName}`}
|
||||
onClick={this.handleProductClick}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit'
|
||||
color: 'inherit',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
@@ -276,6 +357,14 @@ class Product extends Component {
|
||||
height={ window.innerWidth < 600 ? "240" : "180" }
|
||||
image="/assets/images/nopicture.jpg"
|
||||
alt={name}
|
||||
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}
|
||||
loading={this.props.priority === 'high' ? 'eager' : 'lazy'}
|
||||
onError={(e) => {
|
||||
// Ensure alt text is always present even on error
|
||||
if (!e.target.alt) {
|
||||
e.target.alt = name || 'Produktbild';
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
borderTopLeftRadius: '8px',
|
||||
@@ -289,6 +378,14 @@ class Product extends Component {
|
||||
height={ window.innerWidth < 600 ? "240" : "180" }
|
||||
image={this.state.image}
|
||||
alt={name}
|
||||
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}
|
||||
loading={this.props.priority === 'high' ? 'eager' : 'lazy'}
|
||||
onError={(e) => {
|
||||
// Ensure alt text is always present even on error
|
||||
if (!e.target.alt) {
|
||||
e.target.alt = name || 'Produktbild';
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
borderTopLeftRadius: '8px',
|
||||
@@ -330,18 +427,47 @@ class Product extends Component {
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<div style={{padding:'0px',margin:'0px',minHeight:'3.8em'}}>
|
||||
<div style={{padding:'0px',margin:'0px'}}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="primary"
|
||||
sx={{ mt: 'auto', fontWeight: 'bold', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<span>{new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price)}</span>
|
||||
<Box sx={{ position: 'relative', display: 'inline-block' }}>
|
||||
{this.props.rebate && this.props.rebate > 0 && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
left: -8,
|
||||
fontWeight: 'bold',
|
||||
color: 'red',
|
||||
textDecoration: 'line-through',
|
||||
opacity: 0.4,
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
fontSize: 'inherit'
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const rebatePct = this.props.rebate / 100;
|
||||
const originalPrice = Math.round((price / (1 - rebatePct)) * 10) / 10;
|
||||
return new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(originalPrice);
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ position: 'relative', zIndex: 2 }}>{new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price)}</span>
|
||||
</Box>
|
||||
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>({this.props.t ? this.props.t('product.inclVatFooter', { vat }) : `incl. ${vat}% USt.,*`})</small>
|
||||
|
||||
|
||||
|
||||
</Typography>
|
||||
</div>
|
||||
<div style={{ minHeight: '1.5em' }}>
|
||||
{cGrundEinheit && fGrundPreis && fGrundPreis != price && (<Typography variant="body2" color="text.secondary" sx={{ m: 0,p: 0 }}>
|
||||
({new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(fGrundPreis)}/{cGrundEinheit})
|
||||
</Typography> )}
|
||||
@@ -355,6 +481,7 @@ class Product extends Component {
|
||||
component={Link}
|
||||
to={`/Artikel/${seoName}`}
|
||||
size="small"
|
||||
aria-label="Produktdetails anzeigen"
|
||||
sx={{ mr: 1, color: 'text.secondary' }}
|
||||
>
|
||||
<ZoomInIcon />
|
||||
@@ -367,4 +494,10 @@ class Product extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default withI18n()(Product);
|
||||
// Wrapper component to provide navigate hook
|
||||
const ProductWithNavigation = (props) => {
|
||||
const navigate = useNavigate();
|
||||
return <Product {...props} navigate={navigate} />;
|
||||
};
|
||||
|
||||
export default withI18n()(ProductWithNavigation);
|
||||
|
||||
461
src/components/ProductCarousel.js
Normal file
@@ -0,0 +1,461 @@
|
||||
import React from 'react';
|
||||
import { Link } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import ChevronLeft from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRight from "@mui/icons-material/ChevronRight";
|
||||
import Product from "./Product.js";
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import { withLanguage } from '../i18n/withTranslation.js';
|
||||
|
||||
const ITEM_WIDTH = 250 + 16; // 250px width + 16px gap
|
||||
const AUTO_SCROLL_SPEED = 0.5; // px per frame (~60fps, so ~30px/sec)
|
||||
const AUTOSCROLL_RESTART_DELAY = 5000; // 5 seconds of inactivity before restarting autoscroll
|
||||
const SCROLLBAR_FLASH_DURATION = 3000; // 3 seconds to show the virtual scrollbar
|
||||
|
||||
class ProductCarousel extends React.Component {
|
||||
_isMounted = false;
|
||||
products = [];
|
||||
originalProducts = [];
|
||||
animationFrame = null;
|
||||
autoScrollActive = true;
|
||||
translateX = 0;
|
||||
inactivityTimer = null;
|
||||
scrollbarTimer = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { i18n } = props;
|
||||
|
||||
this.state = {
|
||||
products: [],
|
||||
currentLanguage: (i18n && i18n.language) || 'de',
|
||||
showScrollbar: false,
|
||||
};
|
||||
|
||||
this.carouselTrackRef = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
|
||||
|
||||
console.log("ProductCarousel componentDidMount: Loading products for categoryId", this.props.categoryId, "language", currentLanguage);
|
||||
this.loadProducts(currentLanguage);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
console.log("ProductCarousel componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
|
||||
if (prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
|
||||
this.setState({ products: [] }, () => {
|
||||
this.loadProducts(this.props.languageContext?.currentLanguage || this.props.i18n.language);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
loadProducts = (language) => {
|
||||
const { categoryId } = this.props;
|
||||
|
||||
window.socketManager.emit(
|
||||
"getCategoryProducts",
|
||||
{
|
||||
categoryId: categoryId === "neu" ? "neu" : categoryId,
|
||||
language: language,
|
||||
requestTranslation: language === 'de' ? false : true
|
||||
},
|
||||
(response) => {
|
||||
console.log("ProductCarousel getCategoryProducts response:", response);
|
||||
if (this._isMounted && response && response.products && response.products.length > 0) {
|
||||
// Filter products to only show those with pictures
|
||||
const productsWithPictures = response.products.filter(product =>
|
||||
product.pictureList && product.pictureList.length > 0
|
||||
);
|
||||
console.log("ProductCarousel: Filtered", productsWithPictures.length, "products with pictures from", response.products.length, "total");
|
||||
|
||||
if (productsWithPictures.length > 0) {
|
||||
// Take random 15 products and shuffle them
|
||||
const shuffledProducts = this.shuffleArray(productsWithPictures.slice(0, 15));
|
||||
console.log("ProductCarousel: Selected and shuffled", shuffledProducts.length, "products");
|
||||
|
||||
this.originalProducts = shuffledProducts;
|
||||
// Duplicate for seamless looping
|
||||
this.products = [...shuffledProducts, ...shuffledProducts];
|
||||
this.setState({ products: this.products });
|
||||
this.startAutoScroll();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
this.stopAutoScroll();
|
||||
this.clearInactivityTimer();
|
||||
this.clearScrollbarTimer();
|
||||
}
|
||||
|
||||
startAutoScroll = () => {
|
||||
this.autoScrollActive = true;
|
||||
if (!this.animationFrame) {
|
||||
this.animationFrame = requestAnimationFrame(this.handleAutoScroll);
|
||||
}
|
||||
};
|
||||
|
||||
stopAutoScroll = () => {
|
||||
this.autoScrollActive = false;
|
||||
if (this.animationFrame) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
this.animationFrame = null;
|
||||
}
|
||||
};
|
||||
|
||||
clearInactivityTimer = () => {
|
||||
if (this.inactivityTimer) {
|
||||
clearTimeout(this.inactivityTimer);
|
||||
this.inactivityTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
clearScrollbarTimer = () => {
|
||||
if (this.scrollbarTimer) {
|
||||
clearTimeout(this.scrollbarTimer);
|
||||
this.scrollbarTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
startInactivityTimer = () => {
|
||||
this.clearInactivityTimer();
|
||||
this.inactivityTimer = setTimeout(() => {
|
||||
if (this._isMounted) {
|
||||
this.startAutoScroll();
|
||||
}
|
||||
}, AUTOSCROLL_RESTART_DELAY);
|
||||
};
|
||||
|
||||
showScrollbarFlash = () => {
|
||||
this.clearScrollbarTimer();
|
||||
this.setState({ showScrollbar: true });
|
||||
|
||||
this.scrollbarTimer = setTimeout(() => {
|
||||
if (this._isMounted) {
|
||||
this.setState({ showScrollbar: false });
|
||||
}
|
||||
}, SCROLLBAR_FLASH_DURATION);
|
||||
};
|
||||
|
||||
handleAutoScroll = () => {
|
||||
if (!this.autoScrollActive || this.originalProducts.length === 0) return;
|
||||
|
||||
this.translateX -= AUTO_SCROLL_SPEED;
|
||||
this.updateTrackTransform();
|
||||
|
||||
const originalItemCount = this.originalProducts.length;
|
||||
const maxScroll = ITEM_WIDTH * originalItemCount;
|
||||
|
||||
// Check if we've scrolled past the first set of items
|
||||
if (Math.abs(this.translateX) >= maxScroll) {
|
||||
// Reset to beginning seamlessly
|
||||
this.translateX = 0;
|
||||
this.updateTrackTransform();
|
||||
}
|
||||
|
||||
this.animationFrame = requestAnimationFrame(this.handleAutoScroll);
|
||||
};
|
||||
|
||||
updateTrackTransform = () => {
|
||||
if (this.carouselTrackRef.current) {
|
||||
this.carouselTrackRef.current.style.transform = `translateX(${this.translateX}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
handleLeftClick = () => {
|
||||
this.stopAutoScroll();
|
||||
this.scrollBy(1);
|
||||
this.showScrollbarFlash();
|
||||
this.startInactivityTimer();
|
||||
};
|
||||
|
||||
handleRightClick = () => {
|
||||
this.stopAutoScroll();
|
||||
this.scrollBy(-1);
|
||||
this.showScrollbarFlash();
|
||||
this.startInactivityTimer();
|
||||
};
|
||||
|
||||
scrollBy = (direction) => {
|
||||
if (this.originalProducts.length === 0) return;
|
||||
|
||||
// direction: 1 = left (scroll content right), -1 = right (scroll content left)
|
||||
const originalItemCount = this.originalProducts.length;
|
||||
const maxScroll = ITEM_WIDTH * originalItemCount;
|
||||
|
||||
this.translateX += direction * ITEM_WIDTH;
|
||||
|
||||
// Handle wrap-around when scrolling left (positive translateX)
|
||||
if (this.translateX > 0) {
|
||||
this.translateX = -(maxScroll - ITEM_WIDTH);
|
||||
}
|
||||
// Handle wrap-around when scrolling right (negative translateX beyond limit)
|
||||
else if (Math.abs(this.translateX) >= maxScroll) {
|
||||
this.translateX = 0;
|
||||
}
|
||||
|
||||
this.updateTrackTransform();
|
||||
|
||||
// Force scrollbar to update immediately after wrap-around
|
||||
if (this.state.showScrollbar) {
|
||||
this.forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
renderVirtualScrollbar = () => {
|
||||
if (!this.state.showScrollbar || this.originalProducts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const originalItemCount = this.originalProducts.length;
|
||||
const viewportWidth = 1080; // carousel container max-width
|
||||
const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH);
|
||||
|
||||
// Calculate which item is currently at the left edge (first visible)
|
||||
let currentItemIndex;
|
||||
|
||||
if (this.translateX === 0) {
|
||||
currentItemIndex = 0;
|
||||
} else if (this.translateX > 0) {
|
||||
const maxScroll = ITEM_WIDTH * originalItemCount;
|
||||
const effectivePosition = maxScroll + this.translateX;
|
||||
currentItemIndex = Math.floor(effectivePosition / ITEM_WIDTH);
|
||||
} else {
|
||||
currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH);
|
||||
}
|
||||
|
||||
// Ensure we stay within bounds
|
||||
currentItemIndex = Math.max(0, Math.min(currentItemIndex, originalItemCount - 1));
|
||||
|
||||
// Calculate scrollbar position
|
||||
const lastPossibleFirstItem = Math.max(0, originalItemCount - itemsInView);
|
||||
const thumbPosition = lastPossibleFirstItem > 0 ? Math.min((currentItemIndex / lastPossibleFirstItem) * 100, 100) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="virtual-scrollbar"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '5px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '200px',
|
||||
height: '4px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: '2px',
|
||||
zIndex: 1000,
|
||||
opacity: this.state.showScrollbar ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease-in-out'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="scrollbar-thumb"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: `${thumbPosition}%`,
|
||||
width: '20px',
|
||||
height: '4px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: '2px',
|
||||
transform: 'translateX(-50%)',
|
||||
transition: 'left 0.2s ease-out'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, title } = this.props;
|
||||
const { products } = this.state;
|
||||
|
||||
if (!products || products.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Box
|
||||
component={Link}
|
||||
to="/Kategorie/neu"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textDecoration: "none",
|
||||
color: "primary.main",
|
||||
mb: 2,
|
||||
transition: "all 0.3s ease",
|
||||
"&:hover": {
|
||||
transform: "translateX(5px)",
|
||||
color: "primary.dark"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: "SwashingtonCP",
|
||||
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
|
||||
}}
|
||||
>
|
||||
{title || t('product.new')}
|
||||
</Typography>
|
||||
<ChevronRight sx={{ fontSize: "2.5rem", ml: 1 }} />
|
||||
</Box>
|
||||
|
||||
<div
|
||||
className="product-carousel-wrapper"
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'visible',
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
padding: '0 20px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
{/* Left Arrow */}
|
||||
<IconButton
|
||||
aria-label="Vorherige Produkte anzeigen"
|
||||
onClick={this.handleLeftClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '8px',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 1200,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</IconButton>
|
||||
|
||||
{/* Right Arrow */}
|
||||
<IconButton
|
||||
aria-label="Nächste Produkte anzeigen"
|
||||
onClick={this.handleRightClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
right: '8px',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 1200,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
>
|
||||
<ChevronRight />
|
||||
</IconButton>
|
||||
|
||||
<div
|
||||
className="product-carousel-container"
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'visible',
|
||||
padding: '20px 0',
|
||||
width: '100%',
|
||||
maxWidth: '1080px',
|
||||
margin: '0 auto',
|
||||
zIndex: 1,
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="product-carousel-track"
|
||||
ref={this.carouselTrackRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
transition: 'none',
|
||||
alignItems: 'flex-start',
|
||||
width: 'fit-content',
|
||||
overflow: 'visible',
|
||||
position: 'relative',
|
||||
transform: 'translateX(0px)',
|
||||
margin: '0 auto'
|
||||
}}
|
||||
>
|
||||
{products.map((product, index) => (
|
||||
<div
|
||||
key={`${product.id}-${index}`}
|
||||
className="product-carousel-item"
|
||||
style={{
|
||||
flex: '0 0 250px',
|
||||
width: '250px',
|
||||
maxWidth: '250px',
|
||||
minWidth: '250px',
|
||||
boxSizing: 'border-box',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<Product
|
||||
id={product.id}
|
||||
name={product.name}
|
||||
seoName={product.seoName}
|
||||
price={product.price}
|
||||
currency={product.currency}
|
||||
available={product.available}
|
||||
manufacturer={product.manufacturer}
|
||||
vat={product.vat}
|
||||
cGrundEinheit={product.cGrundEinheit}
|
||||
fGrundPreis={product.fGrundPreis}
|
||||
incoming={product.incomingDate}
|
||||
neu={product.neu}
|
||||
thc={product.thc}
|
||||
floweringWeeks={product.floweringWeeks}
|
||||
versandklasse={product.versandklasse}
|
||||
weight={product.weight}
|
||||
pictureList={product.pictureList}
|
||||
availableSupplier={product.availableSupplier}
|
||||
komponenten={product.komponenten}
|
||||
rebate={product.rebate}
|
||||
categoryId={product.kategorien ? product.kategorien.split(',')[0] : undefined}
|
||||
priority={index < 6 ? 'high' : 'auto'}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Virtual Scrollbar */}
|
||||
{this.renderVirtualScrollbar()}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Shuffle array using Fisher-Yates algorithm
|
||||
shuffleArray(array) {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(withLanguage(ProductCarousel));
|
||||
|
||||
4
src/components/ProductDetail.js
Normal file
@@ -0,0 +1,4 @@
|
||||
// This file re-exports ProductDetailWithSocket to maintain compatibility with App.js imports
|
||||
import ProductDetailWithSocket from './ProductDetailWithSocket.js';
|
||||
|
||||
export default ProductDetailWithSocket;
|
||||
@@ -1,18 +1,21 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import SocketContext from '../contexts/SocketContext.js';
|
||||
import ProductDetailPage from './ProductDetailPage.js';
|
||||
import { useProduct } from '../context/ProductContext.js';
|
||||
|
||||
// Wrapper component for individual product detail page with socket
|
||||
const ProductDetailWithSocket = () => {
|
||||
const { seoName } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { setCurrentProduct } = useProduct();
|
||||
|
||||
return (
|
||||
<SocketContext.Consumer>
|
||||
{({socket,socketB}) => <ProductDetailPage seoName={seoName} navigate={navigate} location={location} socket={socket} socketB={socketB} />}
|
||||
</SocketContext.Consumer>
|
||||
<ProductDetailPage
|
||||
seoName={seoName}
|
||||
navigate={navigate}
|
||||
location={location}
|
||||
setCurrentProduct={setCurrentProduct}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -47,6 +47,41 @@ class ProductFilters extends Component {
|
||||
window.addEventListener('resize', this.adjustPaperHeight);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Regenerate values when products, attributes, or language changes
|
||||
const productsChanged = this.props.products !== prevProps.products;
|
||||
const attributesChanged = this.props.attributes !== prevProps.attributes;
|
||||
const languageChanged = this.props.i18n && prevProps.i18n && this.props.i18n.language !== prevProps.i18n.language;
|
||||
const tFunctionChanged = this.props.t !== prevProps.t;
|
||||
|
||||
if(languageChanged) {
|
||||
console.log('ProductFilters: Language changed, will update when new data arrives');
|
||||
}
|
||||
|
||||
if(productsChanged || languageChanged || tFunctionChanged) {
|
||||
console.log('ProductFilters: Updating manufacturers and availability', {
|
||||
productsChanged,
|
||||
languageChanged,
|
||||
tFunctionChanged,
|
||||
productCount: this.props.products?.length
|
||||
});
|
||||
const uniqueManufacturerArray = this._getUniqueManufacturers(this.props.products);
|
||||
const availabilityValues = this._getAvailabilityValues(this.props.products);
|
||||
this.setState({uniqueManufacturerArray, availabilityValues});
|
||||
}
|
||||
|
||||
if(attributesChanged || (languageChanged && this.props.attributes)) {
|
||||
console.log('ProductFilters: Updating attributes', {
|
||||
attributesChanged,
|
||||
languageChanged,
|
||||
attributeCount: this.props.attributes?.length,
|
||||
firstAttribute: this.props.attributes?.[0]
|
||||
});
|
||||
const attributeGroups = this._getAttributeGroups(this.props.attributes);
|
||||
this.setState({attributeGroups});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Remove event listener when component unmounts
|
||||
window.removeEventListener('resize', this.adjustPaperHeight);
|
||||
@@ -116,19 +151,6 @@ class ProductFilters extends Component {
|
||||
return attributeGroups;
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
if(nextProps.products !== this.props.products) {
|
||||
const uniqueManufacturerArray = this._getUniqueManufacturers(nextProps.products);
|
||||
const availabilityValues = this._getAvailabilityValues(nextProps.products);
|
||||
this.setState({uniqueManufacturerArray, availabilityValues});
|
||||
}
|
||||
if(nextProps.attributes !== this.props.attributes) {
|
||||
const attributeGroups = this._getAttributeGroups(nextProps.attributes);
|
||||
this.setState({attributeGroups});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
generateAttributeFilters = () => {
|
||||
const filters = [];
|
||||
const sortedAttributeGroups = Object.values(this.state.attributeGroups)
|
||||
@@ -187,7 +209,7 @@ class ProductFilters extends Component {
|
||||
color: 'primary.main'
|
||||
}}
|
||||
>
|
||||
{this.props.dataParam}
|
||||
{this.props.categoryName}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
|
||||
56
src/components/ProductImage.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Images from './Images.js';
|
||||
|
||||
const ProductImage = ({
|
||||
product,
|
||||
fullscreenOpen,
|
||||
onOpenFullscreen,
|
||||
onCloseFullscreen
|
||||
}) => {
|
||||
// Container styling - unified for all versions
|
||||
const containerSx = {
|
||||
width: { xs: "100%", sm: "555px" },
|
||||
maxWidth: "100%",
|
||||
minHeight: "400px",
|
||||
background: "#f8f8f8",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={containerSx}>
|
||||
{!product.pictureList && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="400"
|
||||
image="/assets/images/nopicture.jpg"
|
||||
alt={product.name}
|
||||
fetchPriority="high"
|
||||
loading="eager"
|
||||
onError={(e) => {
|
||||
// Ensure alt text is always present even on error
|
||||
if (!e.target.alt) {
|
||||
e.target.alt = product.name || 'Produktbild';
|
||||
}
|
||||
}}
|
||||
sx={{ objectFit: "cover" }}
|
||||
/>
|
||||
)}
|
||||
{product.pictureList && (
|
||||
<Images
|
||||
pictureList={product.pictureList}
|
||||
productName={product.name}
|
||||
fullscreenOpen={fullscreenOpen}
|
||||
onOpenFullscreen={onOpenFullscreen}
|
||||
onCloseFullscreen={onCloseFullscreen}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductImage;
|
||||
@@ -399,7 +399,7 @@ class ProductList extends Component {
|
||||
>
|
||||
<MenuItem value={20}>20</MenuItem>
|
||||
<MenuItem value={50}>50</MenuItem>
|
||||
<MenuItem value="all">Alle</MenuItem>
|
||||
<MenuItem value="all">{this.props.t ? this.props.t('filters.all') : 'Alle'}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
@@ -430,7 +430,7 @@ class ProductList extends Component {
|
||||
<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}"</>)}
|
||||
{this.props.dataType == 'search' && (this.props.t ? this.props.t('search.searchResultsFor', { query: this.props.dataParam }) : `Suchergebnisse für: "${this.props.dataParam}"`)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{this.getProductCountText()}
|
||||
@@ -471,11 +471,12 @@ class ProductList extends Component {
|
||||
floweringWeeks={product.floweringWeeks}
|
||||
versandklasse={product.versandklasse}
|
||||
weight={product.weight}
|
||||
socket={this.props.socket}
|
||||
socketB={this.props.socketB}
|
||||
pictureList={product.pictureList}
|
||||
availableSupplier={product.availableSupplier}
|
||||
komponenten={product.komponenten}
|
||||
rebate={product.rebate}
|
||||
categoryId={product.kategorien ? product.kategorien.split(',')[0] : undefined}
|
||||
priority={index < 6 ? 'high' : 'auto'}
|
||||
t={this.props.t}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -1,162 +1,309 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import ChevronLeft from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRight from "@mui/icons-material/ChevronRight";
|
||||
import CategoryBox from "./CategoryBox.js";
|
||||
import SocketContext from "../contexts/SocketContext.js";
|
||||
import { useCarousel } from "../contexts/CarouselContext.js";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ProductCarousel from "./ProductCarousel.js";
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import { withLanguage } from '../i18n/withTranslation.js';
|
||||
|
||||
// Helper to process and set categories
|
||||
const processCategoryTree = (categoryTree) => {
|
||||
if (
|
||||
categoryTree &&
|
||||
categoryTree.id === 209 &&
|
||||
Array.isArray(categoryTree.children)
|
||||
) {
|
||||
return categoryTree.children;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const ITEM_WIDTH = 130 + 16; // 130px width + 16px gap
|
||||
const AUTO_SCROLL_SPEED = 0.5; // px per frame (~60fps, so ~30px/sec)
|
||||
const AUTOSCROLL_RESTART_DELAY = 5000; // 5 seconds of inactivity before restarting autoscroll
|
||||
const SCROLLBAR_FLASH_DURATION = 3000; // 3 seconds to show the virtual scrollbar
|
||||
|
||||
// Check for cached data
|
||||
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;
|
||||
};
|
||||
class SharedCarousel extends React.Component {
|
||||
_isMounted = false;
|
||||
categories = [];
|
||||
originalCategories = [];
|
||||
animationFrame = null;
|
||||
autoScrollActive = true;
|
||||
translateX = 0;
|
||||
inactivityTimer = null;
|
||||
scrollbarTimer = null;
|
||||
|
||||
// Initialize categories
|
||||
const initializeCategories = (language = 'en') => {
|
||||
const productCache = getProductCache();
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { i18n } = props;
|
||||
|
||||
if (productCache && productCache[`categoryTree_209_${language}`]) {
|
||||
const cached = productCache[`categoryTree_209_${language}`];
|
||||
if (cached.categoryTree) {
|
||||
return processCategoryTree(cached.categoryTree);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to old cache format if language-specific cache doesn't exist
|
||||
if (productCache && productCache["categoryTree_209"]) {
|
||||
const cached = productCache["categoryTree_209"];
|
||||
if (cached.categoryTree) {
|
||||
return processCategoryTree(cached.categoryTree);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const SharedCarousel = () => {
|
||||
const { carouselRef, filteredCategories, setFilteredCategories, moveCarousel } = useCarousel();
|
||||
const context = useContext(SocketContext);
|
||||
const { t, i18n } = useTranslation();
|
||||
const [rootCategories, setRootCategories] = useState([]);
|
||||
const [currentLanguage, setCurrentLanguage] = useState(i18n.language);
|
||||
|
||||
useEffect(() => {
|
||||
const initialCategories = initializeCategories(currentLanguage);
|
||||
setRootCategories(initialCategories);
|
||||
}, [currentLanguage]);
|
||||
|
||||
// Listen for language changes
|
||||
useEffect(() => {
|
||||
const handleLanguageChange = (lng) => {
|
||||
setCurrentLanguage(lng);
|
||||
// Clear categories to force refetch
|
||||
setRootCategories([]);
|
||||
// Don't load categories in constructor - will be loaded in componentDidMount with correct language
|
||||
this.state = {
|
||||
categories: [],
|
||||
currentLanguage: (i18n && i18n.language) || 'de',
|
||||
showScrollbar: false,
|
||||
};
|
||||
|
||||
i18n.on('languageChanged', handleLanguageChange);
|
||||
return () => {
|
||||
i18n.off('languageChanged', handleLanguageChange);
|
||||
};
|
||||
}, [i18n]);
|
||||
this.carouselTrackRef = React.createRef();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Only fetch from socket if we don't already have categories
|
||||
if (
|
||||
rootCategories.length === 0 &&
|
||||
context && context.socket && context.socket.connected &&
|
||||
typeof window !== "undefined"
|
||||
) {
|
||||
context.socket.emit("categoryList", { categoryId: 209, language: currentLanguage, requestTranslation: true }, (response) => {
|
||||
if (response && response.success) {
|
||||
// Use translated data if available, otherwise fall back to original
|
||||
const categoryTreeToUse = response.translation || response.categoryTree;
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
|
||||
|
||||
if (categoryTreeToUse) {
|
||||
// Store in cache with language-specific key
|
||||
try {
|
||||
if (!window.productCache) window.productCache = {};
|
||||
window.productCache[`categoryTree_209_${currentLanguage}`] = {
|
||||
categoryTree: categoryTreeToUse,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
setRootCategories(categoryTreeToUse.children || []);
|
||||
}
|
||||
} else if (response && response.categoryTree) {
|
||||
// Fallback for old response format
|
||||
// Store in cache with language-specific key
|
||||
try {
|
||||
if (!window.productCache) window.productCache = {};
|
||||
window.productCache[`categoryTree_209_${currentLanguage}`] = {
|
||||
categoryTree: response.categoryTree,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
setRootCategories(response.categoryTree.children || []);
|
||||
// ALWAYS reload categories to ensure correct language
|
||||
console.log("SharedCarousel componentDidMount: ALWAYS RELOADING categories for language", currentLanguage);
|
||||
window.categoryService.get(209, currentLanguage).then((response) => {
|
||||
console.log("SharedCarousel categoryService.get response for language '" + currentLanguage + "':", response);
|
||||
if (this._isMounted && response.children && response.children.length > 0) {
|
||||
console.log("SharedCarousel: Setting categories with", response.children.length, "items");
|
||||
console.log("SharedCarousel: First category name:", response.children[0]?.name);
|
||||
this.originalCategories = response.children;
|
||||
// Duplicate for seamless looping
|
||||
this.categories = [...response.children, ...response.children];
|
||||
this.setState({ categories: this.categories });
|
||||
this.startAutoScroll();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [context, context?.socket?.connected, rootCategories.length, currentLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = rootCategories.filter(
|
||||
(cat) => cat.id !== 689 && cat.id !== 706
|
||||
componentDidUpdate(prevProps) {
|
||||
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
|
||||
if (prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
|
||||
this.setState({ categories: [] }, () => {
|
||||
window.categoryService.get(209, this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
|
||||
console.log("response", response);
|
||||
if (response.children && response.children.length > 0) {
|
||||
this.originalCategories = response.children;
|
||||
this.categories = [...response.children, ...response.children];
|
||||
this.setState({ categories: this.categories });
|
||||
this.startAutoScroll();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
this.stopAutoScroll();
|
||||
this.clearInactivityTimer();
|
||||
this.clearScrollbarTimer();
|
||||
}
|
||||
|
||||
startAutoScroll = () => {
|
||||
this.autoScrollActive = true;
|
||||
if (!this.animationFrame) {
|
||||
this.animationFrame = requestAnimationFrame(this.handleAutoScroll);
|
||||
}
|
||||
};
|
||||
|
||||
stopAutoScroll = () => {
|
||||
this.autoScrollActive = false;
|
||||
if (this.animationFrame) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
this.animationFrame = null;
|
||||
}
|
||||
};
|
||||
|
||||
clearInactivityTimer = () => {
|
||||
if (this.inactivityTimer) {
|
||||
clearTimeout(this.inactivityTimer);
|
||||
this.inactivityTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
clearScrollbarTimer = () => {
|
||||
if (this.scrollbarTimer) {
|
||||
clearTimeout(this.scrollbarTimer);
|
||||
this.scrollbarTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
startInactivityTimer = () => {
|
||||
this.clearInactivityTimer();
|
||||
this.inactivityTimer = setTimeout(() => {
|
||||
if (this._isMounted) {
|
||||
this.startAutoScroll();
|
||||
}
|
||||
}, AUTOSCROLL_RESTART_DELAY);
|
||||
};
|
||||
|
||||
showScrollbarFlash = () => {
|
||||
this.clearScrollbarTimer();
|
||||
this.setState({ showScrollbar: true });
|
||||
|
||||
this.scrollbarTimer = setTimeout(() => {
|
||||
if (this._isMounted) {
|
||||
this.setState({ showScrollbar: false });
|
||||
}
|
||||
}, SCROLLBAR_FLASH_DURATION);
|
||||
};
|
||||
|
||||
handleAutoScroll = () => {
|
||||
if (!this.autoScrollActive || this.originalCategories.length === 0) return;
|
||||
|
||||
this.translateX -= AUTO_SCROLL_SPEED;
|
||||
this.updateTrackTransform();
|
||||
|
||||
const originalItemCount = this.originalCategories.length;
|
||||
const maxScroll = ITEM_WIDTH * originalItemCount;
|
||||
|
||||
// Check if we've scrolled past the first set of items
|
||||
if (Math.abs(this.translateX) >= maxScroll) {
|
||||
// Reset to beginning seamlessly
|
||||
this.translateX = 0;
|
||||
this.updateTrackTransform();
|
||||
}
|
||||
|
||||
this.animationFrame = requestAnimationFrame(this.handleAutoScroll);
|
||||
};
|
||||
|
||||
updateTrackTransform = () => {
|
||||
if (this.carouselTrackRef.current) {
|
||||
this.carouselTrackRef.current.style.transform = `translateX(${this.translateX}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
handleLeftClick = () => {
|
||||
this.stopAutoScroll();
|
||||
this.scrollBy(1);
|
||||
this.showScrollbarFlash();
|
||||
this.startInactivityTimer();
|
||||
};
|
||||
|
||||
handleRightClick = () => {
|
||||
this.stopAutoScroll();
|
||||
this.scrollBy(-1);
|
||||
this.showScrollbarFlash();
|
||||
this.startInactivityTimer();
|
||||
};
|
||||
|
||||
scrollBy = (direction) => {
|
||||
if (this.originalCategories.length === 0) return;
|
||||
|
||||
// direction: 1 = left (scroll content right), -1 = right (scroll content left)
|
||||
const originalItemCount = this.originalCategories.length;
|
||||
const maxScroll = ITEM_WIDTH * originalItemCount;
|
||||
|
||||
this.translateX += direction * ITEM_WIDTH;
|
||||
|
||||
// Handle wrap-around when scrolling left (positive translateX)
|
||||
if (this.translateX > 0) {
|
||||
this.translateX = -(maxScroll - ITEM_WIDTH);
|
||||
}
|
||||
// Handle wrap-around when scrolling right (negative translateX beyond limit)
|
||||
else if (Math.abs(this.translateX) >= maxScroll) {
|
||||
this.translateX = 0;
|
||||
}
|
||||
|
||||
this.updateTrackTransform();
|
||||
|
||||
// Force scrollbar to update immediately after wrap-around
|
||||
if (this.state.showScrollbar) {
|
||||
this.forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
renderVirtualScrollbar = () => {
|
||||
if (!this.state.showScrollbar || this.originalCategories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const originalItemCount = this.originalCategories.length;
|
||||
const viewportWidth = 1080; // carousel container max-width
|
||||
const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH);
|
||||
|
||||
// Calculate which item is currently at the left edge (first visible)
|
||||
// Map translateX directly to item index using the same logic as scrollBy
|
||||
let currentItemIndex;
|
||||
|
||||
if (this.translateX === 0) {
|
||||
// At the beginning - item 0 is visible
|
||||
currentItemIndex = 0;
|
||||
} else if (this.translateX > 0) {
|
||||
// Wrapped to show end items (this happens when scrolling left past beginning)
|
||||
const maxScroll = ITEM_WIDTH * originalItemCount;
|
||||
const effectivePosition = maxScroll + this.translateX;
|
||||
currentItemIndex = Math.floor(effectivePosition / ITEM_WIDTH);
|
||||
} else {
|
||||
// Normal negative scrolling - calculate which item is at left edge
|
||||
currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH);
|
||||
}
|
||||
|
||||
// Ensure we stay within bounds
|
||||
currentItemIndex = Math.max(0, Math.min(currentItemIndex, originalItemCount - 1));
|
||||
|
||||
// Calculate scrollbar position: 0% when item 0 is first visible, 100% when last item is first visible
|
||||
const lastPossibleFirstItem = Math.max(0, originalItemCount - itemsInView);
|
||||
const thumbPosition = lastPossibleFirstItem > 0 ? Math.min((currentItemIndex / lastPossibleFirstItem) * 100, 100) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="virtual-scrollbar"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '5px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '200px',
|
||||
height: '4px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: '2px',
|
||||
zIndex: 1000,
|
||||
opacity: this.state.showScrollbar ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease-in-out'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="scrollbar-thumb"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: `${thumbPosition}%`,
|
||||
width: '20px',
|
||||
height: '4px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: '2px',
|
||||
transform: 'translateX(-50%)',
|
||||
transition: 'left 0.2s ease-out'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
setFilteredCategories(filtered);
|
||||
}, [rootCategories, setFilteredCategories]);
|
||||
};
|
||||
|
||||
// Create duplicated array for seamless scrolling
|
||||
const displayCategories = [...filteredCategories, ...filteredCategories];
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { categories } = this.state;
|
||||
|
||||
if (filteredCategories.length === 0) {
|
||||
if (!categories || categories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Box
|
||||
component={Link}
|
||||
to="/Kategorien"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textDecoration: "none",
|
||||
color: "primary.main",
|
||||
mb: 2,
|
||||
transition: "all 0.3s ease",
|
||||
"&:hover": {
|
||||
transform: "translateX(5px)",
|
||||
color: "primary.dark"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h1"
|
||||
component="span"
|
||||
sx={{
|
||||
mb: 2,
|
||||
fontFamily: "SwashingtonCP",
|
||||
color: "primary.main",
|
||||
textAlign: "center",
|
||||
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
|
||||
}}
|
||||
>
|
||||
{t('navigation.categories')}
|
||||
</Typography>
|
||||
<ChevronRight sx={{ fontSize: "2.5rem", ml: 1 }} />
|
||||
</Box>
|
||||
|
||||
<div
|
||||
className="carousel-wrapper"
|
||||
@@ -172,8 +319,8 @@ const SharedCarousel = () => {
|
||||
>
|
||||
{/* Left Arrow */}
|
||||
<IconButton
|
||||
onClick={() => moveCarousel("left")}
|
||||
aria-label="Vorherige Kategorien anzeigen"
|
||||
onClick={this.handleLeftClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
@@ -192,8 +339,8 @@ const SharedCarousel = () => {
|
||||
|
||||
{/* Right Arrow */}
|
||||
<IconButton
|
||||
onClick={() => moveCarousel("right")}
|
||||
aria-label="Nächste Kategorien anzeigen"
|
||||
onClick={this.handleRightClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
@@ -225,7 +372,7 @@ const SharedCarousel = () => {
|
||||
>
|
||||
<div
|
||||
className="home-carousel-track"
|
||||
ref={carouselRef}
|
||||
ref={this.carouselTrackRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
@@ -238,7 +385,7 @@ const SharedCarousel = () => {
|
||||
margin: '0 auto'
|
||||
}}
|
||||
>
|
||||
{displayCategories.map((category, index) => (
|
||||
{categories.map((category, index) => (
|
||||
<div
|
||||
key={`${category.id}-${index}`}
|
||||
className="carousel-item"
|
||||
@@ -264,10 +411,17 @@ const SharedCarousel = () => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Virtual Scrollbar */}
|
||||
{this.renderVirtualScrollbar()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Carousel for "neu" category */}
|
||||
<ProductCarousel categoryId="neu" />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default SharedCarousel;
|
||||
export default withTranslation()(withLanguage(SharedCarousel));
|
||||
|
||||
53
src/components/TitleUpdater.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { Component } from 'react';
|
||||
import { withProduct } from '../context/ProductContext.js';
|
||||
import { withCategory } from '../context/CategoryContext.js';
|
||||
|
||||
// Utility function to clean product names (duplicated from ProductDetailPage to ensure consistency)
|
||||
const cleanProductName = (name) => {
|
||||
if (!name) return "";
|
||||
// Remove patterns like " (1)", " (3)", " (10)" at the end of the string
|
||||
return name.replace(/\s*\(\d+\)\s*$/, "").trim();
|
||||
};
|
||||
|
||||
class TitleUpdater extends Component {
|
||||
componentDidMount() {
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
console.log('TitleUpdater: Update triggered', {
|
||||
prevProduct: prevProps.productContext.currentProduct,
|
||||
currProduct: this.props.productContext.currentProduct,
|
||||
prevCategory: prevProps.categoryContext.currentCategory,
|
||||
currCategory: this.props.categoryContext.currentCategory
|
||||
});
|
||||
if (
|
||||
prevProps.productContext.currentProduct !== this.props.productContext.currentProduct ||
|
||||
prevProps.categoryContext.currentCategory !== this.props.categoryContext.currentCategory
|
||||
) {
|
||||
this.updateTitle();
|
||||
}
|
||||
}
|
||||
|
||||
updateTitle() {
|
||||
const { currentProduct } = this.props.productContext;
|
||||
const { currentCategory } = this.props.categoryContext;
|
||||
|
||||
console.log('TitleUpdater: Updating title with', { currentProduct, currentCategory });
|
||||
|
||||
if (currentProduct && currentProduct.name) {
|
||||
document.title = `GrowHeads.de - ${cleanProductName(currentProduct.name)}`;
|
||||
} else if (currentCategory && currentCategory.name) {
|
||||
document.title = `GrowHeads.de - ${currentCategory.name}`;
|
||||
} else {
|
||||
document.title = 'GrowHeads.de';
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default withCategory(withProduct(TitleUpdater));
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { Component } from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { Link } from 'react-router-dom';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
|
||||
class ExtrasSelector extends Component {
|
||||
formatPrice(price) {
|
||||
@@ -16,127 +17,178 @@ class ExtrasSelector extends Component {
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
// Render product image using working code from GrowTentKonfigurator
|
||||
renderProductImage(product) {
|
||||
if (!window.smallPicCache) {
|
||||
window.smallPicCache = {};
|
||||
}
|
||||
|
||||
const pictureList = product.pictureList;
|
||||
|
||||
if (!pictureList || pictureList.length === 0 || !pictureList.split(',').length) {
|
||||
return (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="160"
|
||||
image="/assets/images/nopicture.jpg"
|
||||
alt={product.name || 'Produktbild'}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
width: '100%'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const bildId = pictureList.split(',')[0];
|
||||
|
||||
if (window.smallPicCache[bildId]) {
|
||||
return (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="160"
|
||||
image={window.smallPicCache[bildId]}
|
||||
alt={product.name || 'Produktbild'}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
width: '100%'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Load image if not cached
|
||||
if (!this.loadingImages) this.loadingImages = new Set();
|
||||
if (!this.loadingImages.has(bildId)) {
|
||||
this.loadingImages.add(bildId);
|
||||
window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => {
|
||||
if (res.success) {
|
||||
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
|
||||
this.forceUpdate();
|
||||
}
|
||||
this.loadingImages.delete(bildId);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '160px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CircularProgress sx={{ color: '#90ffc0' }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
renderExtraCard(extra) {
|
||||
const { selectedExtras, onExtraToggle, showImage = true } = this.props;
|
||||
const isSelected = selectedExtras.includes(extra.id);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={extra.id}
|
||||
sx={{
|
||||
<Box sx={{
|
||||
width: { xs: '100%', sm: '250px' },
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
border: '2px solid',
|
||||
borderColor: isSelected ? '#2e7d32' : '#e0e0e0',
|
||||
backgroundColor: isSelected ? '#f1f8e9' : '#ffffff',
|
||||
'&:hover': {
|
||||
boxShadow: 5,
|
||||
boxShadow: 6,
|
||||
borderColor: isSelected ? '#2e7d32' : '#90caf9'
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer'
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
onClick={() => onExtraToggle(extra.id)}
|
||||
>
|
||||
onClick={() => onExtraToggle(extra.id)}>
|
||||
{/* Image */}
|
||||
{showImage && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="160"
|
||||
image={extra.image}
|
||||
alt={extra.name}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onExtraToggle(extra.id);
|
||||
}}
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
'&.Mui-checked': { color: '#2e7d32' },
|
||||
padding: 0
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label=""
|
||||
sx={{ margin: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{this.formatPrice(extra.price)}
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
height: { xs: '240px', sm: '180px' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#ffffff'
|
||||
}}>
|
||||
{this.renderProductImage(extra)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Name */}
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{extra.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{extra.description}
|
||||
|
||||
<Typography gutterBottom>
|
||||
{extra.kurzBeschreibung}
|
||||
</Typography>
|
||||
|
||||
{isSelected && (
|
||||
<Box sx={{ mt: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body2" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
✓ Hinzugefügt
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Price with VAT - Same as other sections */}
|
||||
<Typography variant="h6" sx={{
|
||||
color: '#2e7d32',
|
||||
fontWeight: 'bold',
|
||||
mt: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<span>{extra.price ? this.formatPrice(extra.price) : 'Kein Preis'}</span>
|
||||
{extra.vat && (
|
||||
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>
|
||||
(incl. {extra.vat}% MwSt.,*)
|
||||
</small>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Typography>
|
||||
|
||||
{/* Selection Indicator - Separate line */}
|
||||
{isSelected && (
|
||||
<Typography variant="body2" sx={{
|
||||
color: '#2e7d32',
|
||||
fontWeight: 'bold',
|
||||
mt: 1,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
✓ Ausgewählt
|
||||
</Typography>
|
||||
)}
|
||||
<Stack direction="row" spacing={1} justifyContent="center">
|
||||
<IconButton
|
||||
component={Link}
|
||||
to={`/Artikel/${extra.seoName}`}
|
||||
size="small"
|
||||
aria-label="Produktdetails anzeigen"
|
||||
sx={{ mr: 1, color: 'text.secondary' }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ZoomInIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { extras, title, subtitle, groupByCategory = true, gridSize = { xs: 12, sm: 6, md: 4 } } = this.props;
|
||||
|
||||
if (groupByCategory) {
|
||||
// Group extras by category
|
||||
const groupedExtras = extras.reduce((acc, extra) => {
|
||||
if (!acc[extra.category]) {
|
||||
acc[extra.category] = [];
|
||||
}
|
||||
acc[extra.category].push(extra);
|
||||
return acc;
|
||||
}, {});
|
||||
const { extras, title, subtitle, gridSize = { xs: 12, sm: 6, md: 4 } } = this.props;
|
||||
|
||||
if (!extras || !Array.isArray(extras)) {
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{subtitle}
|
||||
Keine Extras verfügbar
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{Object.entries(groupedExtras).map(([category, categoryExtras]) => (
|
||||
<Box key={category} sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ color: '#1976d2' }}>
|
||||
{category}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{categoryExtras.map(extra => (
|
||||
<Grid item {...gridSize} key={extra.id}>
|
||||
{this.renderExtraCard(extra)}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Render without category grouping
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
|
||||
@@ -6,6 +6,10 @@ import CardMedia from '@mui/material/CardMedia';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { Link } from 'react-router-dom';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
|
||||
class ProductSelector extends Component {
|
||||
formatPrice(price) {
|
||||
@@ -65,6 +69,19 @@ class ProductSelector extends Component {
|
||||
✓ Ausgewählt
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={1} justifyContent="center">
|
||||
<IconButton
|
||||
component={Link}
|
||||
to={`/Artikel/${product.seoName}`}
|
||||
size="small"
|
||||
aria-label="Produktdetails anzeigen"
|
||||
sx={{ mr: 1, color: 'text.secondary' }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ZoomInIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -147,7 +164,7 @@ class ProductSelector extends Component {
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import CardContent from '@mui/material/CardContent';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import { withI18n } from '../../i18n/withTranslation.js';
|
||||
|
||||
class TentShapeSelector extends Component {
|
||||
// Generate plant layout based on tent shape
|
||||
@@ -90,7 +91,7 @@ class TentShapeSelector extends Component {
|
||||
onClick={() => onShapeSelect(shape.id)}
|
||||
>
|
||||
<CardContent sx={{ textAlign: 'center', p: 3 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="h4" component="h4" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{shape.name}
|
||||
</Typography>
|
||||
|
||||
@@ -180,12 +181,20 @@ class TentShapeSelector extends Component {
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{shape.description}
|
||||
{this.props.t && shape.descriptionKey ? this.props.t(shape.descriptionKey) : shape.description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Chip
|
||||
label={`${shape.minPlants}-${shape.maxPlants} Pflanzen`}
|
||||
label={this.props.t
|
||||
? (
|
||||
shape.minPlants === 1 && shape.maxPlants === 2 ? this.props.t("kitConfig.plants1to2") :
|
||||
shape.minPlants === 2 && shape.maxPlants === 4 ? this.props.t("kitConfig.plants2to4") :
|
||||
shape.minPlants === 4 && shape.maxPlants === 6 ? this.props.t("kitConfig.plants4to6") :
|
||||
shape.minPlants === 3 && shape.maxPlants === 6 ? this.props.t("kitConfig.plants3to6") :
|
||||
`${shape.minPlants}-${shape.maxPlants} Pflanzen`
|
||||
)
|
||||
: `${shape.minPlants}-${shape.maxPlants} Pflanzen`}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: isSelected ? '#2e7d32' : '#f0f0f0',
|
||||
@@ -205,7 +214,7 @@ class TentShapeSelector extends Component {
|
||||
transition: 'opacity 0.3s ease'
|
||||
}}
|
||||
>
|
||||
✓ Ausgewählt
|
||||
{this.props.t ? this.props.t("kitConfig.selected") : "✓ Ausgewählt"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
@@ -218,7 +227,7 @@ class TentShapeSelector extends Component {
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
@@ -238,4 +247,4 @@ class TentShapeSelector extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default TentShapeSelector;
|
||||
export default withI18n()(TentShapeSelector);
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Component } from 'react';
|
||||
import React, { Component, lazy } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
@@ -8,7 +8,14 @@ import Typography from '@mui/material/Typography';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import LoginComponent from '../LoginComponent.js';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { Suspense } from 'react';
|
||||
//import LoginComponent from '../LoginComponent.js';
|
||||
|
||||
|
||||
const LoginComponent = lazy(() => import(/* webpackChunkName: "login" */ "../LoginComponent.js"));
|
||||
|
||||
|
||||
import CartDropdown from '../CartDropdown.js';
|
||||
import LanguageSwitcher from '../LanguageSwitcher.js';
|
||||
import { isUserLoggedIn } from '../LoginComponent.js';
|
||||
@@ -34,9 +41,8 @@ class ButtonGroup extends Component {
|
||||
|
||||
componentDidMount() {
|
||||
this.cart = () => {
|
||||
// @note Only emit if socket exists, is connected, AND the update didn't come from socket
|
||||
if (this.props.socket && this.props.socket.connected && !this.isUpdatingFromSocket) {
|
||||
this.props.socket.emit('updateCart', window.cart);
|
||||
if (!this.isUpdatingFromSocket) {
|
||||
window.socketManager.emit('updateCart', window.cart);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
@@ -53,19 +59,6 @@ class ButtonGroup extends Component {
|
||||
this.addSocketListeners();
|
||||
}
|
||||
|
||||
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) {
|
||||
// Socket just connected, add listeners
|
||||
this.addSocketListeners();
|
||||
} else if (wasConnected && !isNowConnected) {
|
||||
// Socket just disconnected, remove listeners
|
||||
this.removeSocketListeners();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('cart', this.cart);
|
||||
@@ -74,16 +67,17 @@ class ButtonGroup extends Component {
|
||||
}
|
||||
|
||||
addSocketListeners = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
// Remove existing listeners first to avoid duplicates
|
||||
this.removeSocketListeners();
|
||||
this.props.socket.on('cartUpdated', this.handleCartUpdated);
|
||||
|
||||
if (window.socketManager) {
|
||||
window.socketManager.on('cartUpdated', this.handleCartUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
removeSocketListeners = () => {
|
||||
if (this.props.socket) {
|
||||
this.props.socket.off('cartUpdated', this.handleCartUpdated);
|
||||
if (window.socketManager) {
|
||||
window.socketManager.off('cartUpdated', this.handleCartUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +112,7 @@ class ButtonGroup extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { socket, navigate, t } = this.props;
|
||||
const { navigate, t } = this.props;
|
||||
const { isCartOpen } = this.state;
|
||||
const cartItems = Array.isArray(window.cart) ? window.cart : [];
|
||||
|
||||
@@ -126,11 +120,14 @@ class ButtonGroup extends Component {
|
||||
<Box sx={{ display: 'flex', gap: { xs: 0.5, sm: 1 } }}>
|
||||
|
||||
<LanguageSwitcher />
|
||||
<LoginComponent socket={socket} />
|
||||
<Suspense fallback={<CircularProgress size={20} />}>
|
||||
<LoginComponent/>
|
||||
</Suspense>
|
||||
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.toggleCart}
|
||||
aria-label="Warenkorb öffnen"
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<Badge badgeContent={this.state.badgeNumber} color="error">
|
||||
@@ -156,6 +153,7 @@ class ButtonGroup extends Component {
|
||||
<IconButton
|
||||
onClick={this.toggleCart}
|
||||
size="small"
|
||||
aria-label="Warenkorb schließen"
|
||||
sx={{
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
@@ -170,7 +168,7 @@ class ButtonGroup extends Component {
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<CartDropdown cartItems={cartItems} socket={socket} onClose={this.toggleCart} onCheckout={()=>{
|
||||
<CartDropdown cartItems={cartItems} onClose={this.toggleCart} onCheckout={()=>{
|
||||
/*open the Drawer inside <LoginComponent */
|
||||
|
||||
if (isUserLoggedIn().isLoggedIn) {
|
||||
|
||||
@@ -6,372 +6,137 @@ 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 FiberNewIcon from '@mui/icons-material/FiberNew';
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { withI18n } from "../../i18n/withTranslation.js";
|
||||
|
||||
class CategoryList extends Component {
|
||||
findCategoryById = (category, targetId) => {
|
||||
if (!category) return null;
|
||||
|
||||
if (category.seoName === targetId) {
|
||||
return category;
|
||||
}
|
||||
|
||||
if (category.children) {
|
||||
for (let child of category.children) {
|
||||
const found = this.findCategoryById(child, targetId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
getPathToCategory = (category, targetId, currentPath = []) => {
|
||||
if (!category) return null;
|
||||
|
||||
const newPath = [...currentPath, category];
|
||||
|
||||
if (category.seoName === targetId) {
|
||||
return newPath;
|
||||
}
|
||||
|
||||
if (category.children) {
|
||||
for (let child of category.children) {
|
||||
const found = this.getPathToCategory(child, targetId, newPath);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
//const { i18n } = props;
|
||||
const categories = window.categoryService.getSync(209);
|
||||
|
||||
// Get current language from props (provided by withI18n HOC)
|
||||
const currentLanguage = props.languageContext?.currentLanguage || 'de';
|
||||
|
||||
// Check for cached data during SSR/initial render
|
||||
let initialState = {
|
||||
categoryTree: null,
|
||||
level1Categories: [], // Children of category 209 (Home) - always shown
|
||||
level2Categories: [], // Children of active level 1 category
|
||||
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
|
||||
currentLanguage: currentLanguage,
|
||||
this.state = {
|
||||
categories: categories && categories.children && categories.children.length > 0 ? categories.children : [],
|
||||
mobileMenuOpen: false,
|
||||
activeCategoryId: null // Will be set properly after categories are loaded
|
||||
};
|
||||
|
||||
// Try to get cached data for SSR
|
||||
try {
|
||||
// @note Check both global.window (SSR) and window (browser) for cache
|
||||
const productCache = (typeof global !== "undefined" && global.window && global.window.productCache) ||
|
||||
(typeof window !== "undefined" && window.productCache);
|
||||
|
||||
if (productCache) {
|
||||
const cacheKey = `categoryTree_209_${currentLanguage}`;
|
||||
const cachedData = productCache[cacheKey];
|
||||
if (cachedData && cachedData.categoryTree) {
|
||||
const { categoryTree, timestamp } = cachedData;
|
||||
const cacheAge = Date.now() - timestamp;
|
||||
const tenMinutes = 10 * 60 * 1000;
|
||||
|
||||
// Use cached data if it's fresh
|
||||
if (cacheAge < tenMinutes) {
|
||||
initialState.categoryTree = categoryTree;
|
||||
initialState.fetchedCategories = true;
|
||||
|
||||
// Process category tree to set up navigation
|
||||
const level1Categories =
|
||||
categoryTree && categoryTree.id === 209
|
||||
? categoryTree.children || []
|
||||
: [];
|
||||
initialState.level1Categories = level1Categories;
|
||||
|
||||
// Process active category path if needed
|
||||
if (props.activeCategoryId) {
|
||||
const activeCategory = this.findCategoryById(
|
||||
categoryTree,
|
||||
props.activeCategoryId
|
||||
);
|
||||
if (activeCategory) {
|
||||
const pathToActive = this.getPathToCategory(
|
||||
categoryTree,
|
||||
props.activeCategoryId
|
||||
);
|
||||
initialState.activePath = pathToActive
|
||||
? pathToActive.slice(1)
|
||||
: [];
|
||||
|
||||
if (initialState.activePath.length >= 1) {
|
||||
const level1Category = initialState.activePath[0];
|
||||
initialState.level2Categories = level1Category.children || [];
|
||||
}
|
||||
|
||||
if (initialState.activePath.length >= 2) {
|
||||
const level2Category = initialState.activePath[1];
|
||||
initialState.level3Categories = level2Category.children || [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error reading cache in constructor:", err);
|
||||
}
|
||||
|
||||
this.state = initialState;
|
||||
this.productCategoryCheckInterval = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchCategories();
|
||||
console.log("CategoryList componentDidMount - Debug info:");
|
||||
console.log(" languageContext:", this.props.languageContext);
|
||||
console.log(" i18n.language:", this.props.i18n?.language);
|
||||
console.log(" sessionStorage i18nextLng:", typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('i18nextLng') : 'N/A');
|
||||
console.log(" localStorage i18nextLng:", typeof localStorage !== 'undefined' ? localStorage.getItem('i18nextLng') : 'N/A');
|
||||
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
|
||||
|
||||
// ALWAYS reload categories to ensure correct language
|
||||
console.log("CategoryList componentDidMount: ALWAYS RELOADING categories for language", currentLanguage);
|
||||
this.setState({ categories: [] }); // Clear any cached categories
|
||||
window.categoryService.get(209, currentLanguage).then((response) => {
|
||||
console.log("categoryService.get response for language '" + currentLanguage + "':", response);
|
||||
if (response.children && response.children.length > 0) {
|
||||
console.log("Setting categories with", response.children.length, "items");
|
||||
console.log("First category name:", response.children[0]?.name);
|
||||
this.setState({
|
||||
categories: response.children,
|
||||
activeCategoryId: this.setLevel1CategoryId(this.props.activeCategoryId)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Handle language changes
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || 'de';
|
||||
const prevLanguage = prevProps.languageContext?.currentLanguage || 'de';
|
||||
|
||||
if (currentLanguage !== prevLanguage) {
|
||||
// Language changed, need to refetch categories
|
||||
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
|
||||
if (prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
|
||||
this.setState({
|
||||
currentLanguage: currentLanguage,
|
||||
fetchedCategories: false,
|
||||
categoryTree: null,
|
||||
level1Categories: [],
|
||||
level2Categories: [],
|
||||
level3Categories: [],
|
||||
activePath: [],
|
||||
categories: [],
|
||||
activeCategoryId: null
|
||||
}, () => {
|
||||
this.fetchCategories();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
() => {
|
||||
this.fetchCategories();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// If activeCategoryId changes, update subcategories
|
||||
if (
|
||||
prevProps.activeCategoryId !== this.props.activeCategoryId &&
|
||||
this.state.categoryTree
|
||||
) {
|
||||
this.processCategoryTree(this.state.categoryTree);
|
||||
}
|
||||
}
|
||||
|
||||
fetchCategories = () => {
|
||||
const { socket } = this.props;
|
||||
if (!socket || !socket.connected) {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
// The componentDidUpdate will retry when socket connects
|
||||
console.log("Socket not connected yet, waiting for connection to fetch categories");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.fetchedCategories) {
|
||||
console.log('Categories already fetched, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current language from state
|
||||
const currentLanguage = this.state.currentLanguage || 'de';
|
||||
|
||||
// Initialize global cache object if it doesn't exist
|
||||
// @note Handle both SSR (global.window) and browser (window) environments
|
||||
const windowObj = (typeof global !== "undefined" && global.window) ||
|
||||
(typeof window !== "undefined" && window);
|
||||
|
||||
if (windowObj && !windowObj.productCache) {
|
||||
windowObj.productCache = {};
|
||||
}
|
||||
|
||||
// Check if we have a valid cache in the global object
|
||||
try {
|
||||
const cacheKey = `categoryTree_209_${currentLanguage}`;
|
||||
const cachedData = windowObj && windowObj.productCache ? windowObj.productCache[cacheKey] : null;
|
||||
if (cachedData) {
|
||||
const { categoryTree, fetching } = cachedData;
|
||||
//const cacheAge = Date.now() - timestamp;
|
||||
//const tenMinutes = 10 * 60 * 1000; // 10 minutes in milliseconds
|
||||
|
||||
// If data is currently being fetched, wait for it
|
||||
if (fetching) {
|
||||
//console.log('CategoryList: Data is being fetched, waiting...');
|
||||
const checkInterval = setInterval(() => {
|
||||
const currentCache = windowObj && windowObj.productCache ? windowObj.productCache[cacheKey] : null;
|
||||
if (currentCache && !currentCache.fetching) {
|
||||
clearInterval(checkInterval);
|
||||
if (currentCache.categoryTree) {
|
||||
this.processCategoryTree(currentCache.categoryTree);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// If cache is less than 10 minutes old, use it
|
||||
if (/*cacheAge < tenMinutes &&*/ categoryTree) {
|
||||
//console.log('Using cached category tree, age:', Math.round(cacheAge/1000), 'seconds');
|
||||
// Defer processing to next tick to avoid blocking
|
||||
//setTimeout(() => {
|
||||
this.processCategoryTree(categoryTree);
|
||||
//}, 0);
|
||||
//return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error reading from cache:", err);
|
||||
}
|
||||
|
||||
// Mark as being fetched to prevent concurrent calls
|
||||
const cacheKey = `categoryTree_209_${currentLanguage}`;
|
||||
if (windowObj && windowObj.productCache) {
|
||||
windowObj.productCache[cacheKey] = {
|
||||
fetching: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
this.setState({ fetchedCategories: true });
|
||||
|
||||
//console.log('CategoryList: Fetching categories from socket');
|
||||
socket.emit("categoryList", { categoryId: 209, language: currentLanguage, requestTranslation: true }, (response) => {
|
||||
if (response && response.success) {
|
||||
// Use translated data if available, otherwise fall back to original
|
||||
const categoryTreeToUse = response.translation || response.categoryTree;
|
||||
|
||||
if (categoryTreeToUse) {
|
||||
// Store in global cache with timestamp
|
||||
try {
|
||||
const cacheKey = `categoryTree_209_${currentLanguage}`;
|
||||
if (windowObj && windowObj.productCache) {
|
||||
windowObj.productCache[cacheKey] = {
|
||||
categoryTree: categoryTreeToUse,
|
||||
timestamp: Date.now(),
|
||||
fetching: false,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error writing to cache:", err);
|
||||
}
|
||||
this.processCategoryTree(categoryTreeToUse);
|
||||
} else {
|
||||
console.error('No category tree found in response');
|
||||
// Clear cache on error
|
||||
try {
|
||||
const cacheKey = `categoryTree_209_${currentLanguage}`;
|
||||
if (windowObj && windowObj.productCache) {
|
||||
windowObj.productCache[cacheKey] = {
|
||||
categoryTree: null,
|
||||
timestamp: Date.now(),
|
||||
fetching: false,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error writing to cache:", err);
|
||||
}
|
||||
|
||||
window.categoryService.get(209, this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
|
||||
console.log("response", response);
|
||||
if (response.children && response.children.length > 0) {
|
||||
this.setState({
|
||||
categoryTree: null,
|
||||
level1Categories: [],
|
||||
level2Categories: [],
|
||||
level3Categories: [],
|
||||
activePath: [],
|
||||
categories: response.children,
|
||||
activeCategoryId: this.setLevel1CategoryId(this.props.activeCategoryId)
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if (prevProps.activeCategoryId !== this.props.activeCategoryId) {
|
||||
this.setLevel1CategoryId(this.props.activeCategoryId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setLevel1CategoryId = (input) => {
|
||||
if (input) {
|
||||
const language = this.props.languageContext?.currentLanguage || this.props.i18n.language;
|
||||
const categoryTreeCache = window.categoryService.getSync(209, language);
|
||||
|
||||
if (categoryTreeCache && categoryTreeCache.children) {
|
||||
let level1CategoryId = null;
|
||||
|
||||
// Check if input is already a numeric level 1 category ID
|
||||
const inputAsNumber = parseInt(input);
|
||||
if (!isNaN(inputAsNumber)) {
|
||||
// Check if this is already a level 1 category ID
|
||||
const level1Category = categoryTreeCache.children.find(cat => cat.id === inputAsNumber);
|
||||
if (level1Category) {
|
||||
console.log("Input is already a level 1 category ID:", inputAsNumber);
|
||||
level1CategoryId = inputAsNumber;
|
||||
} else {
|
||||
// It's a category ID, find its level 1 parent
|
||||
const findLevel1FromId = (categories, targetId) => {
|
||||
for (const category of categories) {
|
||||
if (category.id === targetId) {
|
||||
return category.parentId === 209 ? category.id : findLevel1FromId(categoryTreeCache.children, category.parentId);
|
||||
}
|
||||
if (category.children && category.children.length > 0) {
|
||||
const result = findLevel1FromId(category.children, targetId);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
level1CategoryId = findLevel1FromId(categoryTreeCache.children, inputAsNumber);
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch categories:', response);
|
||||
try {
|
||||
const cacheKey = `categoryTree_209_${currentLanguage}`;
|
||||
if (windowObj && windowObj.productCache) {
|
||||
windowObj.productCache[cacheKey] = {
|
||||
categoryTree: null,
|
||||
timestamp: Date.now(),
|
||||
fetching: false,
|
||||
};
|
||||
// It's an SEO name, find the level 1 category
|
||||
const findLevel1FromSeoName = (categories, targetSeoName, level1Id = null) => {
|
||||
for (const category of categories) {
|
||||
const currentLevel1Id = level1Id || category.id;
|
||||
|
||||
if (category.seoName === targetSeoName) {
|
||||
return currentLevel1Id;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error writing to cache:", err);
|
||||
|
||||
if (category.children && category.children.length > 0) {
|
||||
const result = findLevel1FromSeoName(category.children, targetSeoName, currentLevel1Id);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
level1CategoryId = findLevel1FromSeoName(categoryTreeCache.children, input);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
categoryTree: null,
|
||||
level1Categories: [],
|
||||
level2Categories: [],
|
||||
level3Categories: [],
|
||||
activePath: [],
|
||||
activeCategoryId: level1CategoryId
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
processCategoryTree = (categoryTree) => {
|
||||
// Level 1 categories are always the children of category 209 (Home)
|
||||
const level1Categories =
|
||||
categoryTree && categoryTree.id === 209
|
||||
? categoryTree.children || []
|
||||
: [];
|
||||
|
||||
// Build the navigation path and determine what to show at each level
|
||||
let level2Categories = [];
|
||||
let level3Categories = [];
|
||||
let activePath = [];
|
||||
|
||||
if (this.props.activeCategoryId) {
|
||||
const activeCategory = this.findCategoryById(
|
||||
categoryTree,
|
||||
this.props.activeCategoryId
|
||||
);
|
||||
if (activeCategory) {
|
||||
// Build the path from root to active category
|
||||
const pathToActive = this.getPathToCategory(
|
||||
categoryTree,
|
||||
this.props.activeCategoryId
|
||||
);
|
||||
activePath = pathToActive.slice(1); // Remove root (209) from path
|
||||
|
||||
// Determine what to show at each level based on the path depth
|
||||
if (activePath.length >= 1) {
|
||||
// Show children of the level 1 category
|
||||
const level1Category = activePath[0];
|
||||
level2Categories = level1Category.children || [];
|
||||
}
|
||||
|
||||
if (activePath.length >= 2) {
|
||||
// Show children of the level 2 category
|
||||
const level2Category = activePath[1];
|
||||
level3Categories = level2Category.children || [];
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.setState({ activeCategoryId: null });
|
||||
}
|
||||
|
||||
this.setState({
|
||||
categoryTree,
|
||||
level1Categories,
|
||||
level2Categories,
|
||||
level3Categories,
|
||||
activePath,
|
||||
fetchedCategories: true,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
handleMobileMenuToggle = () => {
|
||||
this.setState(prevState => ({
|
||||
@@ -386,28 +151,28 @@ class CategoryList extends Component {
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { level1Categories, activePath, mobileMenuOpen } =
|
||||
this.state;
|
||||
componentWillUnmount() {
|
||||
if (this.productCategoryCheckInterval) {
|
||||
clearInterval(this.productCategoryCheckInterval);
|
||||
this.productCategoryCheckInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
const renderCategoryRow = (categories, level = 1, isMobile = false) => (
|
||||
render() {
|
||||
const { categories, mobileMenuOpen, activeCategoryId } = this.state;
|
||||
|
||||
const renderCategoryRow = (categories, isMobile = false) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
flexWrap: isMobile ? "wrap" : "nowrap",
|
||||
overflowX: isMobile ? "visible" : "auto",
|
||||
flexWrap: "wrap",
|
||||
overflowX: "visible",
|
||||
flexDirection: isMobile ? "column" : "row",
|
||||
py: 0.5, // Add vertical padding to prevent border clipping
|
||||
"&::-webkit-scrollbar": {
|
||||
display: "none",
|
||||
},
|
||||
scrollbarWidth: "none",
|
||||
msOverflowStyle: "none",
|
||||
}}
|
||||
>
|
||||
{level === 1 && (
|
||||
<Button
|
||||
component={Link}
|
||||
to="/"
|
||||
@@ -428,7 +193,7 @@ class CategoryList extends Component {
|
||||
transition: "all 0.2s ease",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
|
||||
position: "relative",
|
||||
...(this.props.activeCategoryId === null && {
|
||||
...(activeCategoryId === null && {
|
||||
bgcolor: "#fff",
|
||||
textShadow: "none",
|
||||
opacity: 1,
|
||||
@@ -452,7 +217,7 @@ class CategoryList extends Component {
|
||||
<HomeIcon sx={{
|
||||
fontSize: "1rem",
|
||||
mr: isMobile ? 1 : 0,
|
||||
color: this.props.activeCategoryId === null ? "#2e7d32" : "inherit"
|
||||
color: activeCategoryId === null ? "#2e7d32" : "inherit"
|
||||
}} />
|
||||
{isMobile && (
|
||||
<Box sx={{ position: "relative", display: "inline-block" }}>
|
||||
@@ -461,7 +226,7 @@ class CategoryList extends Component {
|
||||
className="bold-text"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
color: this.props.activeCategoryId === null ? "#2e7d32" : "transparent",
|
||||
color: activeCategoryId === null ? "#2e7d32" : "transparent",
|
||||
position: "relative",
|
||||
zIndex: 2,
|
||||
}}
|
||||
@@ -473,7 +238,7 @@ class CategoryList extends Component {
|
||||
className="thin-text"
|
||||
sx={{
|
||||
fontWeight: "400",
|
||||
color: this.props.activeCategoryId === null ? "transparent" : "inherit",
|
||||
color: activeCategoryId === null ? "transparent" : "inherit",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
@@ -485,14 +250,69 @@ class CategoryList extends Component {
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
component={Link}
|
||||
to="/Kategorie/neu"
|
||||
color="inherit"
|
||||
size="small"
|
||||
aria-label="Neuheiten"
|
||||
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
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"
|
||||
}}
|
||||
>
|
||||
<FiberNewIcon sx={{
|
||||
fontSize: "1rem",
|
||||
mr: isMobile ? 1 : 0
|
||||
}} />
|
||||
{isMobile && (
|
||||
<Box sx={{ position: "relative", display: "inline-block" }}>
|
||||
{/* Bold text (always rendered to set width) */}
|
||||
<Box
|
||||
className="bold-text"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
color: "transparent",
|
||||
position: "relative",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('navigation.new') : 'Neuheiten'}
|
||||
</Box>
|
||||
{/* Thin text (positioned on top) */}
|
||||
<Box
|
||||
className="thin-text"
|
||||
sx={{
|
||||
fontWeight: "400",
|
||||
color: "inherit",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('navigation.new') : 'Neuheiten'}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{this.state.fetchedCategories && categories.length > 0 ? (
|
||||
</Button>
|
||||
|
||||
|
||||
{categories.length > 0 ? (
|
||||
<>
|
||||
{categories.map((category) => {
|
||||
// Determine if this category is active at this level
|
||||
const isActiveAtThisLevel =
|
||||
activePath[level - 1] &&
|
||||
activePath[level - 1].id === category.id;
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -515,7 +335,7 @@ class CategoryList extends Component {
|
||||
transition: "all 0.2s ease",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
|
||||
position: "relative",
|
||||
...(isActiveAtThisLevel && {
|
||||
...(activeCategoryId === category.id && {
|
||||
bgcolor: "#fff",
|
||||
textShadow: "none",
|
||||
opacity: 1,
|
||||
@@ -539,7 +359,7 @@ class CategoryList extends Component {
|
||||
className="bold-text"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
color: isActiveAtThisLevel ? "#2e7d32" : "transparent",
|
||||
color: activeCategoryId === category.id ? "#2e7d32" : "transparent",
|
||||
position: "relative",
|
||||
zIndex: 2,
|
||||
}}
|
||||
@@ -551,7 +371,7 @@ class CategoryList extends Component {
|
||||
className="thin-text"
|
||||
sx={{
|
||||
fontWeight: "400",
|
||||
color: isActiveAtThisLevel ? "transparent" : "inherit",
|
||||
color: activeCategoryId === category.id ? "transparent" : "inherit",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
@@ -565,15 +385,14 @@ class CategoryList extends Component {
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
level === 1 && !isMobile && (
|
||||
) : (!isMobile && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="inherit"
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
height: "30px", // Match small button height
|
||||
height: "33px", // Match small button height
|
||||
px: 1,
|
||||
fontSize: "0.75rem",
|
||||
opacity: 0.9,
|
||||
@@ -583,6 +402,84 @@ class CategoryList extends Component {
|
||||
</Typography>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
component={Link}
|
||||
to="/Konfigurator"
|
||||
color="inherit"
|
||||
size="small"
|
||||
aria-label="Zur Startseite"
|
||||
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
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",
|
||||
...(activeCategoryId === null && {
|
||||
bgcolor: "#fff",
|
||||
textShadow: "none",
|
||||
opacity: 1,
|
||||
}),
|
||||
"&:hover": {
|
||||
opacity: 1,
|
||||
bgcolor: "#fff",
|
||||
textShadow: "none",
|
||||
"& .MuiSvgIcon-root": {
|
||||
color: "#2e7d32 !important",
|
||||
},
|
||||
"& .bold-text": {
|
||||
color: "#2e7d32 !important",
|
||||
},
|
||||
"& .thin-text": {
|
||||
color: "transparent !important",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SettingsIcon sx={{
|
||||
fontSize: "1rem",
|
||||
mr: isMobile ? 1 : 0,
|
||||
color: 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: activeCategoryId === null ? "#2e7d32" : "transparent",
|
||||
position: "relative",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
|
||||
</Box>
|
||||
{/* Thin text (positioned on top) */}
|
||||
<Box
|
||||
className="thin-text"
|
||||
sx={{
|
||||
fontWeight: "400",
|
||||
color: activeCategoryId === null ? "transparent" : "inherit",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -605,25 +502,7 @@ class CategoryList extends Component {
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg" sx={{ px: 2 }}>
|
||||
{/* Level 1 Categories Row - Always shown */}
|
||||
{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, 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, false)}
|
||||
</Box>
|
||||
)}
|
||||
*/}
|
||||
{renderCategoryRow(categories, false)}
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
@@ -666,7 +545,7 @@ class CategoryList extends Component {
|
||||
fontWeight: "bold",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.3)"
|
||||
}}>
|
||||
{this.props.t ? this.props.t('navigation.categories') : 'Kategorien'}
|
||||
{this.props.t ? this.props.t('navigation.categories') : 'Kategorien'}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
|
||||
@@ -677,7 +556,7 @@ class CategoryList extends Component {
|
||||
<Collapse in={mobileMenuOpen}>
|
||||
<Box sx={{ pb: 2 }}>
|
||||
{/* Level 1 Categories - Only level shown in mobile menu */}
|
||||
{renderCategoryRow(level1Categories, 1, true)}
|
||||
{renderCategoryRow(categories, true)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Container>
|
||||
|
||||
@@ -16,9 +16,11 @@ const Logo = () => {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/assets/images/sh.png"
|
||||
src="/assets/images/sh.avif"
|
||||
alt="SH Logo"
|
||||
style={{ height: "45px" }}
|
||||
width="108px"
|
||||
height="45px"
|
||||
style={{ width: "108px", height: "45px" }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -7,18 +7,19 @@ import List from "@mui/material/List";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import KeyboardReturnIcon from "@mui/icons-material/KeyboardReturn";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import SocketContext from "../../contexts/SocketContext.js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LanguageContext } from "../../i18n/withTranslation.js";
|
||||
|
||||
const SearchBar = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const context = React.useContext(SocketContext);
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const { t, i18n } = useTranslation();
|
||||
const languageContext = React.useContext(LanguageContext);
|
||||
|
||||
// State management
|
||||
const [searchQuery, setSearchQuery] = React.useState(
|
||||
@@ -27,7 +28,6 @@ const SearchBar = () => {
|
||||
const [suggestions, setSuggestions] = React.useState([]);
|
||||
const [showSuggestions, setShowSuggestions] = React.useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(-1);
|
||||
const [loadingSuggestions, setLoadingSuggestions] = React.useState(false);
|
||||
|
||||
// Refs for debouncing and timers
|
||||
const debounceTimerRef = React.useRef(null);
|
||||
@@ -60,27 +60,26 @@ const SearchBar = () => {
|
||||
// @note Autocomplete function using getSearchProducts Socket.io API - returns objects with name and seoName
|
||||
const fetchAutocomplete = React.useCallback(
|
||||
(query) => {
|
||||
if (!context || !context.socket || !context.socket.connected || !query || query.length < 2) {
|
||||
if (!query || query.length < 2) {
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
setLoadingSuggestions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingSuggestions(true);
|
||||
const currentLanguage = languageContext?.currentLanguage || i18n?.language || 'de';
|
||||
|
||||
context.socket.emit(
|
||||
window.socketManager.emit(
|
||||
"getSearchProducts",
|
||||
{
|
||||
query: query.trim(),
|
||||
limit: 8,
|
||||
language: currentLanguage,
|
||||
requestTranslation: currentLanguage === 'de' ? false : true,
|
||||
},
|
||||
(response) => {
|
||||
setLoadingSuggestions(false);
|
||||
|
||||
if (response && response.products) {
|
||||
// getSearchProducts returns response.products array
|
||||
const suggestions = response.products.slice(0, 8); // Limit to 8 suggestions
|
||||
const suggestions = response.products.map(p => p.translatedProduct || p).slice(0, 8); // Limit to 8 suggestions
|
||||
setSuggestions(suggestions);
|
||||
setShowSuggestions(suggestions.length > 0);
|
||||
setSelectedIndex(-1); // Reset selection
|
||||
@@ -92,7 +91,7 @@ const SearchBar = () => {
|
||||
}
|
||||
);
|
||||
},
|
||||
[context]
|
||||
[languageContext, i18n]
|
||||
);
|
||||
|
||||
const handleSearchChange = (e) => {
|
||||
@@ -186,6 +185,15 @@ const SearchBar = () => {
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// Get delivery days based on availability
|
||||
const getDeliveryDays = (product) => {
|
||||
if (product.available === 1) {
|
||||
return t('delivery.times.standard2to3Days');
|
||||
} else if (product.incoming === 1 || product.availableSupplier === 1) {
|
||||
return t('delivery.times.supplier7to9Days');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle enter icon click
|
||||
const handleEnterClick = () => {
|
||||
delete window.currentSearchQuery;
|
||||
@@ -236,7 +244,7 @@ const SearchBar = () => {
|
||||
>
|
||||
<TextField
|
||||
ref={inputRef}
|
||||
placeholder="Produkte suchen..."
|
||||
placeholder={t('search.searchProducts')}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
@@ -257,12 +265,11 @@ const SearchBar = () => {
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
{loadingSuggestions && <CircularProgress size={16} />}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleEnterClick}
|
||||
aria-label="Suche starten"
|
||||
sx={{
|
||||
ml: loadingSuggestions ? 0.5 : 0,
|
||||
p: 0.5,
|
||||
color: "text.secondary",
|
||||
"&:hover": {
|
||||
@@ -289,8 +296,6 @@ const SearchBar = () => {
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1300,
|
||||
maxHeight: "300px",
|
||||
overflow: "auto",
|
||||
mt: 0.5,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
@@ -298,12 +303,19 @@ const SearchBar = () => {
|
||||
<List disablePadding>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<ListItem
|
||||
key={suggestion.seoName || index}
|
||||
button
|
||||
key={`${suggestion.seoName || 'suggestion'}-${index}`}
|
||||
component="button"
|
||||
selected={index === selectedIndex}
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
background: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
px: 2, // Add horizontal padding back
|
||||
"&:hover": {
|
||||
backgroundColor: "action.hover",
|
||||
},
|
||||
@@ -318,14 +330,48 @@ const SearchBar = () => {
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="body2" noWrap>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2 }}>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2" noWrap sx={{ mb: 0.5 }}>
|
||||
{suggestion.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||
{getDeliveryDays(suggestion)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'right', flexShrink: 0, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body1" color="primary" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(suggestion.price)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem' }}>
|
||||
{t('product.inclVat', { vat: suggestion.vat })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Box sx={{ p: 1, borderTop: 1, borderColor: 'divider' }}>
|
||||
<IconButton
|
||||
fullWidth
|
||||
onClick={handleEnterClick}
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
py: 1,
|
||||
color: 'primary.main',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ mr: 1 }}>
|
||||
{t('common.more')}
|
||||
</Typography>
|
||||
<KeyboardReturnIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -5,7 +5,6 @@ import CheckoutForm from "./CheckoutForm.js";
|
||||
import PaymentConfirmationDialog from "./PaymentConfirmationDialog.js";
|
||||
import OrderProcessingService from "./OrderProcessingService.js";
|
||||
import CheckoutValidation from "./CheckoutValidation.js";
|
||||
import SocketContext from "../../contexts/SocketContext.js";
|
||||
import { withI18n } from "../../i18n/index.js";
|
||||
|
||||
class CartTab extends Component {
|
||||
@@ -68,8 +67,7 @@ class CartTab extends Component {
|
||||
|
||||
// @note Add method to fetch and apply order template prefill data
|
||||
fetchOrderTemplate = () => {
|
||||
if (this.context && this.context.socket && this.context.socket.connected) {
|
||||
this.context.socket.emit('getOrderTemplate', (response) => {
|
||||
window.socketManager.emit('getOrderTemplate', (response) => {
|
||||
if (response.success && response.orderTemplate) {
|
||||
const template = response.orderTemplate;
|
||||
|
||||
@@ -147,7 +145,6 @@ class CartTab extends Component {
|
||||
console.log("No order template available or failed to fetch");
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
@@ -468,7 +465,6 @@ class CartTab extends Component {
|
||||
{!showPaymentConfirmation && (
|
||||
<CartDropdown
|
||||
cartItems={cartItems}
|
||||
socket={this.context.socket}
|
||||
showDetailedSummary={showStripePayment}
|
||||
deliveryMethod={deliveryMethod}
|
||||
deliveryCost={deliveryCost}
|
||||
@@ -539,7 +535,4 @@ class CartTab extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
// Set static contextType to access the socket
|
||||
CartTab.contextType = SocketContext;
|
||||
|
||||
export default withI18n()(CartTab);
|
||||
|
||||
@@ -17,7 +17,7 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartIt
|
||||
name: 'DHL',
|
||||
description: isPickupOnly ? (t ? t('delivery.descriptions.notAvailable') : "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können") :
|
||||
isFreeShipping ? (t ? t('delivery.descriptions.standardFree') : 'Standardversand - KOSTENLOS ab 100€ Warenwert!') : (t ? t('delivery.descriptions.standard') : 'Standardversand'),
|
||||
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dhl') : '6,99 €'),
|
||||
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dhl') : '5,90 €'),
|
||||
disabled: isPickupOnly
|
||||
},
|
||||
{
|
||||
|
||||
@@ -145,26 +145,24 @@ class OrderProcessingService {
|
||||
}
|
||||
|
||||
// If socket is ready, process immediately
|
||||
const context = this.getContext();
|
||||
if (context && context.socket && context.socket.connected) {
|
||||
|
||||
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
|
||||
if (isAuthenticated) {
|
||||
this.sendStripeOrder();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Wait for socket to be ready
|
||||
this.socketHandler = () => {
|
||||
const context = this.getContext();
|
||||
if (context && context.socket && context.socket.connected) {
|
||||
|
||||
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
|
||||
const state = this.getState();
|
||||
|
||||
if (isAuthenticated && state.showPaymentConfirmation && !state.isCompletingOrder) {
|
||||
this.sendStripeOrder();
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
if (this.socketHandler) {
|
||||
window.removeEventListener('cart', this.socketHandler);
|
||||
@@ -213,9 +211,8 @@ class OrderProcessingService {
|
||||
saveAddressForFuture,
|
||||
};
|
||||
|
||||
// Emit stripe order to backend via socket.io
|
||||
const context = this.getContext();
|
||||
context.socket.emit("issueStripeOrder", orderData, (response) => {
|
||||
|
||||
window.socketManager.emit("issueStripeOrder", orderData, (response) => {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
@@ -247,9 +244,8 @@ class OrderProcessingService {
|
||||
|
||||
// Process regular (non-Stripe) orders
|
||||
processRegularOrder(orderData) {
|
||||
const context = this.getContext();
|
||||
if (context && context.socket && context.socket.connected) {
|
||||
context.socket.emit("issueOrder", orderData, (response) => {
|
||||
|
||||
window.socketManager.emit("issueOrder", orderData, (response) => {
|
||||
if (response.success) {
|
||||
// Clear the cart
|
||||
window.cart = [];
|
||||
@@ -274,20 +270,12 @@ class OrderProcessingService {
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error("Socket context not available");
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: "Cannot connect to server. Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create Stripe payment intent
|
||||
createStripeIntent(totalAmount, loadStripeComponent) {
|
||||
const context = this.getContext();
|
||||
if (context && context.socket && context.socket.connected) {
|
||||
context.socket.emit(
|
||||
|
||||
window.socketManager.emit(
|
||||
"createStripeIntent",
|
||||
{ amount: totalAmount },
|
||||
(response) => {
|
||||
@@ -302,20 +290,13 @@ class OrderProcessingService {
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.error("Socket context not available");
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: "Cannot connect to server. Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Create Mollie payment intent
|
||||
createMollieIntent(mollieOrderData) {
|
||||
const context = this.getContext();
|
||||
if (context && context.socket && context.socket.connected) {
|
||||
context.socket.emit(
|
||||
window.socketManager.emit(
|
||||
"createMollieIntent",
|
||||
mollieOrderData,
|
||||
(response) => {
|
||||
@@ -336,13 +317,6 @@ class OrderProcessingService {
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.error("Socket context not available");
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: "Cannot connect to server. Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate delivery cost
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useContext, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { withI18n } from "../../i18n/withTranslation.js";
|
||||
import {
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from "@mui/material";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import CancelIcon from "@mui/icons-material/Cancel";
|
||||
import SocketContext from "../../contexts/SocketContext.js";
|
||||
import OrderDetailsDialog from "./OrderDetailsDialog.js";
|
||||
|
||||
// Constants
|
||||
@@ -32,6 +31,7 @@ const getStatusTranslation = (status, t) => {
|
||||
new: t ? t('orders.status.new') : "in Bearbeitung",
|
||||
pending: t ? t('orders.status.pending') : "Neu",
|
||||
processing: t ? t('orders.status.processing') : "in Bearbeitung",
|
||||
paid: t ? t('orders.status.paid') : "Bezahlt",
|
||||
cancelled: t ? t('orders.status.cancelled') : "Storniert",
|
||||
shipped: t ? t('orders.status.shipped') : "Verschickt",
|
||||
delivered: t ? t('orders.status.delivered') : "Geliefert",
|
||||
@@ -40,29 +40,23 @@ const getStatusTranslation = (status, t) => {
|
||||
};
|
||||
|
||||
const statusEmojis = {
|
||||
"in Bearbeitung": "⚙️",
|
||||
new: "⚙️",
|
||||
pending: "⏳",
|
||||
processing: "🔄",
|
||||
paid: "🏦",
|
||||
cancelled: "❌",
|
||||
Verschickt: "🚚",
|
||||
Geliefert: "✅",
|
||||
Storniert: "❌",
|
||||
Retoure: "↩️",
|
||||
"Teil Retoure": "↪️",
|
||||
"Teil geliefert": "⚡",
|
||||
shipped: "🚚",
|
||||
delivered: "✅",
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
"in Bearbeitung": "#ed6c02", // orange
|
||||
new: "#ed6c02", // orange
|
||||
pending: "#ff9800", // orange for pending
|
||||
processing: "#2196f3", // blue for processing
|
||||
paid: "#2e7d32", // green
|
||||
cancelled: "#d32f2f", // red for cancelled
|
||||
Verschickt: "#2e7d32", // green
|
||||
Geliefert: "#2e7d32", // green
|
||||
Storniert: "#d32f2f", // red
|
||||
Retoure: "#9c27b0", // purple
|
||||
"Teil Retoure": "#9c27b0", // purple
|
||||
"Teil geliefert": "#009688", // teal
|
||||
shipped: "#2e7d32", // green
|
||||
delivered: "#2e7d32", // green
|
||||
};
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
||||
@@ -81,7 +75,6 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
|
||||
const [orderToCancel, setOrderToCancel] = useState(null);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
|
||||
const {socket} = useContext(SocketContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleViewDetails = useCallback(
|
||||
@@ -98,10 +91,10 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
|
||||
);
|
||||
|
||||
const fetchOrders = useCallback(() => {
|
||||
if (socket && socket.connected) {
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
socket.emit("getOrders", (response) => {
|
||||
window.socketManager.emit("getOrders", (response) => {
|
||||
if (response.success) {
|
||||
setOrders(response.orders);
|
||||
} else {
|
||||
@@ -109,25 +102,13 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
console.log("Socket not connected yet, waiting for connection to fetch orders");
|
||||
setLoading(false); // Stop loading when socket is not connected
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
|
||||
// Monitor socket connection changes
|
||||
useEffect(() => {
|
||||
if (socket && socket.connected && orders.length === 0) {
|
||||
// Socket just connected and we don't have orders yet, fetch them
|
||||
fetchOrders();
|
||||
}
|
||||
}, [socket, socket?.connected, orders.length, fetchOrders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (orderIdFromHash && orders.length > 0) {
|
||||
handleViewDetails(orderIdFromHash);
|
||||
@@ -166,10 +147,10 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
|
||||
|
||||
// Handle cancel confirmation
|
||||
const handleConfirmCancel = () => {
|
||||
if (!orderToCancel || !socket) return;
|
||||
if (!orderToCancel) return;
|
||||
|
||||
setIsCancelling(true);
|
||||
socket.emit('cancelOrder', { orderId: orderToCancel.orderId }, (response) => {
|
||||
window.socketManager.emit('cancelOrder', { orderId: orderToCancel.orderId }, (response) => {
|
||||
setIsCancelling(false);
|
||||
setCancelConfirmOpen(false);
|
||||
|
||||
@@ -243,11 +224,11 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
color: getStatusColor(displayStatus),
|
||||
color: getStatusColor(order.status),
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "1.2rem" }}>
|
||||
{getStatusEmoji(displayStatus)}
|
||||
{getStatusEmoji(order.status)}
|
||||
</span>
|
||||
<Typography
|
||||
variant="body2"
|
||||
@@ -257,6 +238,18 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
|
||||
{displayStatus}
|
||||
</Typography>
|
||||
</Box>
|
||||
{order.delivery_method === 'DHL' && order.trackingCode && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<a
|
||||
href={`https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${order.trackingCode}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: '0.85rem', color: '#d40511' }}
|
||||
>
|
||||
📦 {t ? t('orders.trackShipment') : 'Sendung verfolgen'}
|
||||
</a>
|
||||
</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{order.items
|
||||
@@ -283,6 +276,7 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => handleViewDetails(order.orderId)}
|
||||
aria-label={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}
|
||||
>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
@@ -293,6 +287,7 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleCancelClick(order)}
|
||||
aria-label={t ? t('orders.tooltips.cancelOrder') : 'Bestellung stornieren'}
|
||||
>
|
||||
<CancelIcon />
|
||||
</IconButton>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
IconButton,
|
||||
Snackbar
|
||||
} from '@mui/material';
|
||||
import { ContentCopy } from '@mui/icons-material';
|
||||
import ContentCopy from '@mui/icons-material/ContentCopy';
|
||||
import { withI18n } from '../../i18n/withTranslation.js';
|
||||
|
||||
class SettingsTab extends Component {
|
||||
@@ -48,7 +48,7 @@ class SettingsTab extends Component {
|
||||
this.setState({ newEmail: user.email || '' });
|
||||
|
||||
// Check if user has an API key
|
||||
this.props.socket.emit('isApiKey', (response) => {
|
||||
window.socketManager.emit('isApiKey', (response) => {
|
||||
if (response.success && response.hasApiKey) {
|
||||
this.setState({
|
||||
hasApiKey: true,
|
||||
@@ -90,7 +90,7 @@ class SettingsTab extends Component {
|
||||
this.setState({ loading: true });
|
||||
|
||||
// Call socket.io endpoint to update password
|
||||
this.props.socket.emit('updatePassword',
|
||||
window.socketManager.emit('updatePassword',
|
||||
{ oldPassword: this.state.currentPassword, newPassword: this.state.newPassword },
|
||||
(response) => {
|
||||
this.setState({ loading: false });
|
||||
@@ -134,7 +134,7 @@ class SettingsTab extends Component {
|
||||
this.setState({ loading: true });
|
||||
|
||||
// Call socket.io endpoint to update email
|
||||
this.props.socket.emit('updateEmail',
|
||||
window.socketManager.emit('updateEmail',
|
||||
{ password: this.state.password, email: this.state.newEmail },
|
||||
(response) => {
|
||||
this.setState({ loading: false });
|
||||
@@ -184,7 +184,7 @@ class SettingsTab extends Component {
|
||||
try {
|
||||
const user = JSON.parse(storedUser);
|
||||
|
||||
this.props.socket.emit('createApiKey', user.id, (response) => {
|
||||
window.socketManager.emit('createApiKey', user.id, (response) => {
|
||||
this.setState({ loadingApiKey: false });
|
||||
|
||||
if (response.success) {
|
||||
@@ -382,6 +382,7 @@ class SettingsTab extends Component {
|
||||
{this.state.apiKeyDisplay !== '************' && this.state.apiKey && (
|
||||
<IconButton
|
||||
onClick={this.handleCopyToClipboard}
|
||||
aria-label={this.props.t ? this.props.t('settings.copyToClipboard') : 'In Zwischenablage kopieren'}
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
'&:hover': { bgcolor: 'rgba(46, 125, 50, 0.1)' }
|
||||
|
||||
@@ -200,13 +200,13 @@ const config = {
|
||||
|
||||
// Shipping
|
||||
shipping: {
|
||||
defaultCost: "4.99 EUR",
|
||||
defaultCost: "5.90 EUR",
|
||||
defaultService: "Standard"
|
||||
},
|
||||
|
||||
// Images
|
||||
images: {
|
||||
logo: "/assets/images/sh.png",
|
||||
logo: "/assets/images/sh.avif",
|
||||
placeholder: "/assets/images/nopicture.jpg"
|
||||
},
|
||||
|
||||
|
||||
31
src/context/CategoryContext.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import React, { createContext, useState, useContext } from 'react';
|
||||
|
||||
const CategoryContext = createContext({
|
||||
currentCategory: null,
|
||||
setCurrentCategory: () => {}
|
||||
});
|
||||
|
||||
export const useCategory = () => useContext(CategoryContext);
|
||||
|
||||
export const withCategory = (Component) => {
|
||||
return (props) => {
|
||||
const categoryContext = useCategory();
|
||||
return <Component {...props} categoryContext={categoryContext} />;
|
||||
};
|
||||
};
|
||||
|
||||
export const CategoryContextProvider = ({ children }) => {
|
||||
const [currentCategory, setCurrentCategory] = useState(null);
|
||||
|
||||
const setCurrentCategoryWithLog = (category) => {
|
||||
console.log('CategoryContext: Setting current category to:', category);
|
||||
setCurrentCategory(category);
|
||||
};
|
||||
|
||||
return (
|
||||
<CategoryContext.Provider value={{ currentCategory, setCurrentCategory: setCurrentCategoryWithLog }}>
|
||||
{children}
|
||||
</CategoryContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
31
src/context/ProductContext.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import React, { createContext, useState, useContext } from 'react';
|
||||
|
||||
const ProductContext = createContext({
|
||||
currentProduct: null,
|
||||
setCurrentProduct: () => {}
|
||||
});
|
||||
|
||||
export const useProduct = () => useContext(ProductContext);
|
||||
|
||||
export const withProduct = (Component) => {
|
||||
return (props) => {
|
||||
const productContext = useProduct();
|
||||
return <Component {...props} productContext={productContext} />;
|
||||
};
|
||||
};
|
||||
|
||||
export const ProductContextProvider = ({ children }) => {
|
||||
const [currentProduct, setCurrentProduct] = useState(null);
|
||||
|
||||
const setCurrentProductWithLog = (product) => {
|
||||
console.log('ProductContext: Setting current product to:', product);
|
||||
setCurrentProduct(product);
|
||||
};
|
||||
|
||||
return (
|
||||
<ProductContext.Provider value={{ currentProduct, setCurrentProduct: setCurrentProductWithLog }}>
|
||||
{children}
|
||||
</ProductContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// Create a new context for Socket.IO
|
||||
const SocketContext = React.createContext(null);
|
||||
|
||||
export const SocketConsumer = SocketContext.Consumer;
|
||||
export default SocketContext;
|
||||
@@ -1,9 +1,10 @@
|
||||
// @note Dummy data for grow tent configurator - no backend calls
|
||||
// descriptions now keys for translation
|
||||
export const tentShapes = [
|
||||
{
|
||||
id: '60x60',
|
||||
name: '60x60cm',
|
||||
description: 'Kompakt - ideal für kleine Räume',
|
||||
descriptionKey: 'kitConfig.description60x60',
|
||||
footprint: '60x60',
|
||||
minPlants: 1,
|
||||
maxPlants: 2,
|
||||
@@ -13,7 +14,7 @@ export const tentShapes = [
|
||||
{
|
||||
id: '80x80',
|
||||
name: '80x80cm',
|
||||
description: 'Mittel - perfekte Balance',
|
||||
descriptionKey: 'kitConfig.description80x80',
|
||||
footprint: '80x80',
|
||||
minPlants: 2,
|
||||
maxPlants: 4,
|
||||
@@ -23,7 +24,7 @@ export const tentShapes = [
|
||||
{
|
||||
id: '100x100',
|
||||
name: '100x100cm',
|
||||
description: 'Groß - für erfahrene Grower',
|
||||
descriptionKey: 'kitConfig.description100x100',
|
||||
footprint: '100x100',
|
||||
minPlants: 4,
|
||||
maxPlants: 6,
|
||||
@@ -33,7 +34,7 @@ export const tentShapes = [
|
||||
{
|
||||
id: '120x60',
|
||||
name: '120x60cm',
|
||||
description: 'Rechteckig - maximale Raumnutzung',
|
||||
descriptionKey: 'kitConfig.description120x60',
|
||||
footprint: '120x60',
|
||||
minPlants: 3,
|
||||
maxPlants: 6,
|
||||
@@ -41,229 +42,3 @@ export const tentShapes = [
|
||||
visualDepth: 60
|
||||
}
|
||||
];
|
||||
|
||||
export const tentSizes = [
|
||||
// 60x60 tents
|
||||
{
|
||||
id: 'tent_60x60x140',
|
||||
name: 'Basic 140cm',
|
||||
description: 'Einsteigermodell',
|
||||
price: 89.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '60x60x140cm',
|
||||
coverage: '1-2 Pflanzen',
|
||||
shapeId: '60x60',
|
||||
height: 140
|
||||
},
|
||||
{
|
||||
id: 'tent_60x60x160',
|
||||
name: 'Premium 160cm',
|
||||
description: 'Mehr Höhe für größere Pflanzen',
|
||||
price: 109.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '60x60x160cm',
|
||||
coverage: '1-2 Pflanzen',
|
||||
shapeId: '60x60',
|
||||
height: 160
|
||||
},
|
||||
// 80x80 tents
|
||||
{
|
||||
id: 'tent_80x80x160',
|
||||
name: 'Standard 160cm',
|
||||
description: 'Beliebtes Mittelklasse-Modell',
|
||||
price: 129.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '80x80x160cm',
|
||||
coverage: '2-4 Pflanzen',
|
||||
shapeId: '80x80',
|
||||
height: 160
|
||||
},
|
||||
{
|
||||
id: 'tent_80x80x180',
|
||||
name: 'Pro 180cm',
|
||||
description: 'Extra Höhe für optimales Wachstum',
|
||||
price: 149.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '80x80x180cm',
|
||||
coverage: '2-4 Pflanzen',
|
||||
shapeId: '80x80',
|
||||
height: 180
|
||||
},
|
||||
// 100x100 tents
|
||||
{
|
||||
id: 'tent_100x100x180',
|
||||
name: 'Professional 180cm',
|
||||
description: 'Für anspruchsvolle Projekte',
|
||||
price: 189.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '100x100x180cm',
|
||||
coverage: '4-6 Pflanzen',
|
||||
shapeId: '100x100',
|
||||
height: 180
|
||||
},
|
||||
{
|
||||
id: 'tent_100x100x200',
|
||||
name: 'Expert 200cm',
|
||||
description: 'Maximum an Wuchshöhe',
|
||||
price: 219.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '100x100x200cm',
|
||||
coverage: '4-6 Pflanzen',
|
||||
shapeId: '100x100',
|
||||
height: 200
|
||||
},
|
||||
// 120x60 tents
|
||||
{
|
||||
id: 'tent_120x60x160',
|
||||
name: 'Rectangular 160cm',
|
||||
description: 'Platzsparend und effizient',
|
||||
price: 139.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '120x60x160cm',
|
||||
coverage: '3-6 Pflanzen',
|
||||
shapeId: '120x60',
|
||||
height: 160
|
||||
},
|
||||
{
|
||||
id: 'tent_120x60x180',
|
||||
name: 'Rectangular Pro 180cm',
|
||||
description: 'Optimale Raumausnutzung',
|
||||
price: 169.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '120x60x180cm',
|
||||
coverage: '3-6 Pflanzen',
|
||||
shapeId: '120x60',
|
||||
height: 180
|
||||
}
|
||||
];
|
||||
|
||||
export const lightTypes = [
|
||||
{
|
||||
id: 'led_quantum_board',
|
||||
name: 'LED Quantum Board',
|
||||
description: 'Energieeffizient, geringe Wärmeentwicklung',
|
||||
price: 159.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
wattage: '240W',
|
||||
coverage: 'Bis 100x100cm',
|
||||
spectrum: 'Vollspektrum',
|
||||
efficiency: 'Sehr hoch'
|
||||
},
|
||||
{
|
||||
id: 'led_cob',
|
||||
name: 'LED COB',
|
||||
description: 'Hochintensive COB-LEDs',
|
||||
price: 199.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
wattage: '300W',
|
||||
coverage: 'Bis 120x120cm',
|
||||
spectrum: 'Vollspektrum',
|
||||
efficiency: 'Hoch'
|
||||
},
|
||||
{
|
||||
id: 'hps_400w',
|
||||
name: 'HPS 400W',
|
||||
description: 'Bewährte Natriumdampflampe',
|
||||
price: 89.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
wattage: '400W',
|
||||
coverage: 'Bis 80x80cm',
|
||||
spectrum: 'Blüte-optimiert',
|
||||
efficiency: 'Mittel'
|
||||
},
|
||||
{
|
||||
id: 'cmh_315w',
|
||||
name: 'CMH 315W',
|
||||
description: 'Keramik-Metallhalogenid',
|
||||
price: 129.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
wattage: '315W',
|
||||
coverage: 'Bis 90x90cm',
|
||||
spectrum: 'Natürlich',
|
||||
efficiency: 'Hoch'
|
||||
}
|
||||
];
|
||||
|
||||
export const ventilationTypes = [
|
||||
{
|
||||
id: 'basic_exhaust',
|
||||
name: 'Basic Abluft-Set',
|
||||
description: 'Lüfter + Aktivkohlefilter',
|
||||
price: 79.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
airflow: '187 m³/h',
|
||||
noiseLevel: '35 dB',
|
||||
includes: ['Rohrventilator', 'Aktivkohlefilter', 'Aluflexrohr']
|
||||
},
|
||||
{
|
||||
id: 'premium_ventilation',
|
||||
name: 'Premium Klima-Set',
|
||||
description: 'Komplette Klimakontrolle',
|
||||
price: 159.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
airflow: '280 m³/h',
|
||||
noiseLevel: '28 dB',
|
||||
includes: ['EC-Lüfter', 'Aktivkohlefilter', 'Thermostat', 'Feuchtigkeitsmesser']
|
||||
},
|
||||
{
|
||||
id: 'pro_climate',
|
||||
name: 'Profi Klima-System',
|
||||
description: 'Automatisierte Klimasteuerung',
|
||||
price: 299.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
airflow: '420 m³/h',
|
||||
noiseLevel: '25 dB',
|
||||
includes: ['Digitaler Controller', 'EC-Lüfter', 'Aktivkohlefilter', 'Zu-/Abluft']
|
||||
}
|
||||
];
|
||||
|
||||
export const extras = [
|
||||
{
|
||||
id: 'ph_tester',
|
||||
name: 'pH-Messgerät',
|
||||
description: 'Digitales pH-Meter',
|
||||
price: 29.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
category: 'Messung'
|
||||
},
|
||||
{
|
||||
id: 'nutrients_starter',
|
||||
name: 'Dünger Starter-Set',
|
||||
description: 'Komplettes Nährstoff-Set',
|
||||
price: 39.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
category: 'Nährstoffe'
|
||||
},
|
||||
{
|
||||
id: 'grow_pots',
|
||||
name: 'Grow-Töpfe Set (5x)',
|
||||
description: '5x Stofftöpfe 11L',
|
||||
price: 24.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
category: 'Töpfe'
|
||||
},
|
||||
{
|
||||
id: 'timer_socket',
|
||||
name: 'Zeitschaltuhr',
|
||||
description: 'Digitale Zeitschaltuhr',
|
||||
price: 19.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
category: 'Steuerung'
|
||||
},
|
||||
{
|
||||
id: 'thermometer',
|
||||
name: 'Thermo-Hygrometer',
|
||||
description: 'Min/Max Temperatur & Luftfeuchtigkeit',
|
||||
price: 14.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
category: 'Messung'
|
||||
},
|
||||
{
|
||||
id: 'pruning_shears',
|
||||
name: 'Gartenschere',
|
||||
description: 'Präzisions-Gartenschere',
|
||||
price: 16.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
category: 'Werkzeug'
|
||||
}
|
||||
];
|
||||
@@ -1,365 +1,197 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
// Note: LanguageDetector not used - we have custom detector
|
||||
|
||||
// Import all translation files
|
||||
// Only import German translations by default
|
||||
import translationDE from './locales/de/index.js';
|
||||
import translationEN from './locales/en/index.js';
|
||||
import translationAR from './locales/ar/translation.js';
|
||||
import translationBG from './locales/bg/translation.js';
|
||||
import translationCS from './locales/cs/translation.js';
|
||||
import translationEL from './locales/el/translation.js';
|
||||
import translationES from './locales/es/translation.js';
|
||||
import translationFR from './locales/fr/translation.js';
|
||||
import translationHR from './locales/hr/translation.js';
|
||||
import translationHU from './locales/hu/translation.js';
|
||||
import translationIT from './locales/it/translation.js';
|
||||
import translationPL from './locales/pl/translation.js';
|
||||
import translationRO from './locales/ro/translation.js';
|
||||
import translationRU from './locales/ru/translation.js';
|
||||
import translationSK from './locales/sk/translation.js';
|
||||
import translationSL from './locales/sl/translation.js';
|
||||
import translationSR from './locales/sr/translation.js';
|
||||
import translationSV from './locales/sv/translation.js';
|
||||
import translationTR from './locales/tr/translation.js';
|
||||
import translationUK from './locales/uk/translation.js';
|
||||
import translationZH from './locales/zh/translation.js';
|
||||
|
||||
// Import legal translations for all languages
|
||||
// German
|
||||
import legalAgbDE from './locales/de/legal-agb.js';
|
||||
import legalDatenschutzDE from './locales/de/legal-datenschutz.js';
|
||||
import legalAgbDeliveryDE from './locales/de/legal-agb-delivery.js';
|
||||
import legalAgbPaymentDE from './locales/de/legal-agb-payment.js';
|
||||
import legalAgbConsumerDE from './locales/de/legal-agb-consumer.js';
|
||||
import legalDatenschutzBasicDE from './locales/de/legal-datenschutz-basic.js';
|
||||
import legalDatenschutzCustomerDE from './locales/de/legal-datenschutz-customer.js';
|
||||
import legalDatenschutzGoogleOrdersDE from './locales/de/legal-datenschutz-google-orders.js';
|
||||
import legalDatenschutzNewsletterDE from './locales/de/legal-datenschutz-newsletter.js';
|
||||
import legalDatenschutzChatbotDE from './locales/de/legal-datenschutz-chatbot.js';
|
||||
import legalDatenschutzCookiesPaymentDE from './locales/de/legal-datenschutz-cookies-payment.js';
|
||||
import legalDatenschutzRightsDE from './locales/de/legal-datenschutz-rights.js';
|
||||
import legalImpressumDE from './locales/de/legal-impressum.js';
|
||||
import legalWiderrufDE from './locales/de/legal-widerruf.js';
|
||||
import legalBatterieDE from './locales/de/legal-batterie.js';
|
||||
|
||||
// English
|
||||
import legalAgbEN from './locales/en/legal-agb.js';
|
||||
import legalDatenschutzEN from './locales/en/legal-datenschutz.js';
|
||||
import legalImpressumEN from './locales/en/legal-impressum.js';
|
||||
import legalWiderrufEN from './locales/en/legal-widerruf.js';
|
||||
import legalBatterieEN from './locales/en/legal-batterie.js';
|
||||
// Language loading cache to prevent duplicate loads
|
||||
const languageCache = new Set(['de']);
|
||||
const loadingPromises = new Map();
|
||||
|
||||
// Arabic
|
||||
import legalAgbAR from './locales/ar/legal-agb.js';
|
||||
import legalDatenschutzAR from './locales/ar/legal-datenschutz.js';
|
||||
import legalImpressumAR from './locales/ar/legal-impressum.js';
|
||||
import legalWiderrufAR from './locales/ar/legal-widerruf.js';
|
||||
import legalBatterieAR from './locales/ar/legal-batterie.js';
|
||||
// Lazy loading function for languages
|
||||
const loadLanguage = async (language) => {
|
||||
if (languageCache.has(language)) {
|
||||
return; // Already loaded
|
||||
}
|
||||
|
||||
// Bulgarian
|
||||
import legalAgbBG from './locales/bg/legal-agb.js';
|
||||
import legalDatenschutzBG from './locales/bg/legal-datenschutz.js';
|
||||
import legalImpressumBG from './locales/bg/legal-impressum.js';
|
||||
import legalWiderrufBG from './locales/bg/legal-widerruf.js';
|
||||
import legalBatterieBG from './locales/bg/legal-batterie.js';
|
||||
if (loadingPromises.has(language)) {
|
||||
return loadingPromises.get(language); // Already loading
|
||||
}
|
||||
|
||||
// Czech
|
||||
import legalAgbCS from './locales/cs/legal-agb.js';
|
||||
import legalDatenschutzCS from './locales/cs/legal-datenschutz.js';
|
||||
import legalImpressumCS from './locales/cs/legal-impressum.js';
|
||||
import legalWiderrufCS from './locales/cs/legal-widerruf.js';
|
||||
import legalBatterieCS from './locales/cs/legal-batterie.js';
|
||||
const loadingPromise = (async () => {
|
||||
try {
|
||||
console.log(`🌍 Lazy loading language: ${language}`);
|
||||
|
||||
// Greek
|
||||
import legalAgbEL from './locales/el/legal-agb.js';
|
||||
import legalDatenschutzEL from './locales/el/legal-datenschutz.js';
|
||||
import legalImpressumEL from './locales/el/legal-impressum.js';
|
||||
import legalWiderrufEL from './locales/el/legal-widerruf.js';
|
||||
import legalBatterieEL from './locales/el/legal-batterie.js';
|
||||
// Dynamic imports for lazy loading
|
||||
const [
|
||||
translation,
|
||||
legalAgbDelivery,
|
||||
legalAgbPayment,
|
||||
legalAgbConsumer,
|
||||
legalDatenschutzBasic,
|
||||
legalDatenschutzCustomer,
|
||||
legalDatenschutzGoogleOrders,
|
||||
legalDatenschutzNewsletter,
|
||||
legalDatenschutzChatbot,
|
||||
legalDatenschutzCookiesPayment,
|
||||
legalDatenschutzRights,
|
||||
legalImpressum,
|
||||
legalWiderruf,
|
||||
legalBatterie
|
||||
] = await Promise.all([
|
||||
import(`./locales/${language}/index.js`),
|
||||
import(`./locales/${language}/legal-agb-delivery.js`),
|
||||
import(`./locales/${language}/legal-agb-payment.js`),
|
||||
import(`./locales/${language}/legal-agb-consumer.js`),
|
||||
import(`./locales/${language}/legal-datenschutz-basic.js`),
|
||||
import(`./locales/${language}/legal-datenschutz-customer.js`),
|
||||
import(`./locales/${language}/legal-datenschutz-google-orders.js`),
|
||||
import(`./locales/${language}/legal-datenschutz-newsletter.js`),
|
||||
import(`./locales/${language}/legal-datenschutz-chatbot.js`),
|
||||
import(`./locales/${language}/legal-datenschutz-cookies-payment.js`),
|
||||
import(`./locales/${language}/legal-datenschutz-rights.js`),
|
||||
import(`./locales/${language}/legal-impressum.js`),
|
||||
import(`./locales/${language}/legal-widerruf.js`),
|
||||
import(`./locales/${language}/legal-batterie.js`)
|
||||
]);
|
||||
|
||||
// Spanish
|
||||
import legalAgbES from './locales/es/legal-agb.js';
|
||||
import legalDatenschutzES from './locales/es/legal-datenschutz.js';
|
||||
import legalImpressumES from './locales/es/legal-impressum.js';
|
||||
import legalWiderrufES from './locales/es/legal-widerruf.js';
|
||||
import legalBatterieES from './locales/es/legal-batterie.js';
|
||||
// Add the loaded resources to i18n
|
||||
i18n.addResourceBundle(language, 'translation', translation.default);
|
||||
i18n.addResourceBundle(language, 'legal-agb-delivery', legalAgbDelivery.default);
|
||||
i18n.addResourceBundle(language, 'legal-agb-payment', legalAgbPayment.default);
|
||||
i18n.addResourceBundle(language, 'legal-agb-consumer', legalAgbConsumer.default);
|
||||
i18n.addResourceBundle(language, 'legal-datenschutz-basic', legalDatenschutzBasic.default);
|
||||
i18n.addResourceBundle(language, 'legal-datenschutz-customer', legalDatenschutzCustomer.default);
|
||||
i18n.addResourceBundle(language, 'legal-datenschutz-google-orders', legalDatenschutzGoogleOrders.default);
|
||||
i18n.addResourceBundle(language, 'legal-datenschutz-newsletter', legalDatenschutzNewsletter.default);
|
||||
i18n.addResourceBundle(language, 'legal-datenschutz-chatbot', legalDatenschutzChatbot.default);
|
||||
i18n.addResourceBundle(language, 'legal-datenschutz-cookies-payment', legalDatenschutzCookiesPayment.default);
|
||||
i18n.addResourceBundle(language, 'legal-datenschutz-rights', legalDatenschutzRights.default);
|
||||
i18n.addResourceBundle(language, 'legal-impressum', legalImpressum.default);
|
||||
i18n.addResourceBundle(language, 'legal-widerruf', legalWiderruf.default);
|
||||
i18n.addResourceBundle(language, 'legal-batterie', legalBatterie.default);
|
||||
|
||||
// French
|
||||
import legalAgbFR from './locales/fr/legal-agb.js';
|
||||
import legalDatenschutzFR from './locales/fr/legal-datenschutz.js';
|
||||
import legalImpressumFR from './locales/fr/legal-impressum.js';
|
||||
import legalWiderrufFR from './locales/fr/legal-widerruf.js';
|
||||
import legalBatterieFR from './locales/fr/legal-batterie.js';
|
||||
languageCache.add(language);
|
||||
console.log(`✅ Language ${language} loaded successfully`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to load language ${language}:`, error);
|
||||
throw error;
|
||||
} finally {
|
||||
loadingPromises.delete(language);
|
||||
}
|
||||
})();
|
||||
|
||||
// Croatian
|
||||
import legalAgbHR from './locales/hr/legal-agb.js';
|
||||
import legalDatenschutzHR from './locales/hr/legal-datenschutz.js';
|
||||
import legalImpressumHR from './locales/hr/legal-impressum.js';
|
||||
import legalWiderrufHR from './locales/hr/legal-widerruf.js';
|
||||
import legalBatterieHR from './locales/hr/legal-batterie.js';
|
||||
loadingPromises.set(language, loadingPromise);
|
||||
return loadingPromise;
|
||||
};
|
||||
|
||||
// Hungarian
|
||||
import legalAgbHU from './locales/hu/legal-agb.js';
|
||||
import legalDatenschutzHU from './locales/hu/legal-datenschutz.js';
|
||||
import legalImpressumHU from './locales/hu/legal-impressum.js';
|
||||
import legalWiderrufHU from './locales/hu/legal-widerruf.js';
|
||||
import legalBatterieHU from './locales/hu/legal-batterie.js';
|
||||
// Custom language detector that prioritizes session storage and defaults to German
|
||||
const customDetector = {
|
||||
name: 'customDetector',
|
||||
lookup() {
|
||||
// Only try storage in browser environment
|
||||
if (typeof window === 'undefined') {
|
||||
return 'de';
|
||||
}
|
||||
|
||||
// Italian
|
||||
import legalAgbIT from './locales/it/legal-agb.js';
|
||||
import legalDatenschutzIT from './locales/it/legal-datenschutz.js';
|
||||
import legalImpressumIT from './locales/it/legal-impressum.js';
|
||||
import legalWiderrufIT from './locales/it/legal-widerruf.js';
|
||||
import legalBatterieIT from './locales/it/legal-batterie.js';
|
||||
// 1. Check session storage first
|
||||
try {
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
const sessionLang = sessionStorage.getItem('i18nextLng');
|
||||
if (sessionLang && sessionLang !== 'de') {
|
||||
return sessionLang;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Session storage not available
|
||||
}
|
||||
|
||||
// Polish
|
||||
import legalAgbPL from './locales/pl/legal-agb.js';
|
||||
import legalDatenschutzPL from './locales/pl/legal-datenschutz.js';
|
||||
import legalImpressumPL from './locales/pl/legal-impressum.js';
|
||||
import legalWiderrufPL from './locales/pl/legal-widerruf.js';
|
||||
import legalBatteriePL from './locales/pl/legal-batterie.js';
|
||||
// 2. Check localStorage
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const localLang = localStorage.getItem('i18nextLng');
|
||||
if (localLang && localLang !== 'de') {
|
||||
return localLang;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// LocalStorage not available
|
||||
}
|
||||
|
||||
// Romanian
|
||||
import legalAgbRO from './locales/ro/legal-agb.js';
|
||||
import legalDatenschutzRO from './locales/ro/legal-datenschutz.js';
|
||||
import legalImpressumRO from './locales/ro/legal-impressum.js';
|
||||
import legalWiderrufRO from './locales/ro/legal-widerruf.js';
|
||||
import legalBatterieRO from './locales/ro/legal-batterie.js';
|
||||
// 3. Always default to German (don't detect browser language)
|
||||
return 'de';
|
||||
},
|
||||
cacheUserLanguage(lng) {
|
||||
// Only cache in browser environment
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Russian
|
||||
import legalAgbRU from './locales/ru/legal-agb.js';
|
||||
import legalDatenschutzRU from './locales/ru/legal-datenschutz.js';
|
||||
import legalImpressumRU from './locales/ru/legal-impressum.js';
|
||||
import legalWiderrufRU from './locales/ru/legal-widerruf.js';
|
||||
import legalBatterieRU from './locales/ru/legal-batterie.js';
|
||||
|
||||
// Slovak
|
||||
import legalAgbSK from './locales/sk/legal-agb.js';
|
||||
import legalDatenschutzSK from './locales/sk/legal-datenschutz.js';
|
||||
import legalImpressumSK from './locales/sk/legal-impressum.js';
|
||||
import legalWiderrufSK from './locales/sk/legal-widerruf.js';
|
||||
import legalBatterieSK from './locales/sk/legal-batterie.js';
|
||||
|
||||
// Slovenian
|
||||
import legalAgbSL from './locales/sl/legal-agb.js';
|
||||
import legalDatenschutzSL from './locales/sl/legal-datenschutz.js';
|
||||
import legalImpressumSL from './locales/sl/legal-impressum.js';
|
||||
import legalWiderrufSL from './locales/sl/legal-widerruf.js';
|
||||
import legalBatterieSL from './locales/sl/legal-batterie.js';
|
||||
|
||||
// Serbian
|
||||
import legalAgbSR from './locales/sr/legal-agb.js';
|
||||
import legalDatenschutzSR from './locales/sr/legal-datenschutz.js';
|
||||
import legalImpressumSR from './locales/sr/legal-impressum.js';
|
||||
import legalWiderrufSR from './locales/sr/legal-widerruf.js';
|
||||
import legalBatterieSR from './locales/sr/legal-batterie.js';
|
||||
|
||||
// Swedish
|
||||
import legalAgbSV from './locales/sv/legal-agb.js';
|
||||
import legalDatenschutzSV from './locales/sv/legal-datenschutz.js';
|
||||
import legalImpressumSV from './locales/sv/legal-impressum.js';
|
||||
import legalWiderrufSV from './locales/sv/legal-widerruf.js';
|
||||
import legalBatterieSV from './locales/sv/legal-batterie.js';
|
||||
|
||||
// Turkish
|
||||
import legalAgbTR from './locales/tr/legal-agb.js';
|
||||
import legalDatenschutzTR from './locales/tr/legal-datenschutz.js';
|
||||
import legalImpressumTR from './locales/tr/legal-impressum.js';
|
||||
import legalWiderrufTR from './locales/tr/legal-widerruf.js';
|
||||
import legalBatterieTR from './locales/tr/legal-batterie.js';
|
||||
|
||||
// Ukrainian
|
||||
import legalAgbUK from './locales/uk/legal-agb.js';
|
||||
import legalDatenschutzUK from './locales/uk/legal-datenschutz.js';
|
||||
import legalImpressumUK from './locales/uk/legal-impressum.js';
|
||||
import legalWiderrufUK from './locales/uk/legal-widerruf.js';
|
||||
import legalBatterieUK from './locales/uk/legal-batterie.js';
|
||||
|
||||
// Chinese
|
||||
import legalAgbZH from './locales/zh/legal-agb.js';
|
||||
import legalDatenschutzZH from './locales/zh/legal-datenschutz.js';
|
||||
import legalImpressumZH from './locales/zh/legal-impressum.js';
|
||||
import legalWiderrufZH from './locales/zh/legal-widerruf.js';
|
||||
import legalBatterieZH from './locales/zh/legal-batterie.js';
|
||||
try {
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.setItem('i18nextLng', lng);
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('i18nextLng', lng);
|
||||
}
|
||||
} catch {
|
||||
// Storage not available
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize i18n with only German resources
|
||||
const resources = {
|
||||
de: {
|
||||
translation: translationDE,
|
||||
'legal-agb': legalAgbDE,
|
||||
'legal-datenschutz': legalDatenschutzDE,
|
||||
'legal-agb-delivery': legalAgbDeliveryDE,
|
||||
'legal-agb-payment': legalAgbPaymentDE,
|
||||
'legal-agb-consumer': legalAgbConsumerDE,
|
||||
'legal-datenschutz-basic': legalDatenschutzBasicDE,
|
||||
'legal-datenschutz-customer': legalDatenschutzCustomerDE,
|
||||
'legal-datenschutz-google-orders': legalDatenschutzGoogleOrdersDE,
|
||||
'legal-datenschutz-newsletter': legalDatenschutzNewsletterDE,
|
||||
'legal-datenschutz-chatbot': legalDatenschutzChatbotDE,
|
||||
'legal-datenschutz-cookies-payment': legalDatenschutzCookiesPaymentDE,
|
||||
'legal-datenschutz-rights': legalDatenschutzRightsDE,
|
||||
'legal-impressum': legalImpressumDE,
|
||||
'legal-widerruf': legalWiderrufDE,
|
||||
'legal-batterie': legalBatterieDE
|
||||
},
|
||||
en: {
|
||||
translation: translationEN,
|
||||
'legal-agb': legalAgbEN,
|
||||
'legal-datenschutz': legalDatenschutzEN,
|
||||
'legal-impressum': legalImpressumEN,
|
||||
'legal-widerruf': legalWiderrufEN,
|
||||
'legal-batterie': legalBatterieEN
|
||||
},
|
||||
ar: {
|
||||
translation: translationAR,
|
||||
'legal-agb': legalAgbAR,
|
||||
'legal-datenschutz': legalDatenschutzAR,
|
||||
'legal-impressum': legalImpressumAR,
|
||||
'legal-widerruf': legalWiderrufAR,
|
||||
'legal-batterie': legalBatterieAR
|
||||
},
|
||||
bg: {
|
||||
translation: translationBG,
|
||||
'legal-agb': legalAgbBG,
|
||||
'legal-datenschutz': legalDatenschutzBG,
|
||||
'legal-impressum': legalImpressumBG,
|
||||
'legal-widerruf': legalWiderrufBG,
|
||||
'legal-batterie': legalBatterieBG
|
||||
},
|
||||
cs: {
|
||||
translation: translationCS,
|
||||
'legal-agb': legalAgbCS,
|
||||
'legal-datenschutz': legalDatenschutzCS,
|
||||
'legal-impressum': legalImpressumCS,
|
||||
'legal-widerruf': legalWiderrufCS,
|
||||
'legal-batterie': legalBatterieCS
|
||||
},
|
||||
el: {
|
||||
translation: translationEL,
|
||||
'legal-agb': legalAgbEL,
|
||||
'legal-datenschutz': legalDatenschutzEL,
|
||||
'legal-impressum': legalImpressumEL,
|
||||
'legal-widerruf': legalWiderrufEL,
|
||||
'legal-batterie': legalBatterieEL
|
||||
},
|
||||
es: {
|
||||
translation: translationES,
|
||||
'legal-agb': legalAgbES,
|
||||
'legal-datenschutz': legalDatenschutzES,
|
||||
'legal-impressum': legalImpressumES,
|
||||
'legal-widerruf': legalWiderrufES,
|
||||
'legal-batterie': legalBatterieES
|
||||
},
|
||||
fr: {
|
||||
translation: translationFR,
|
||||
'legal-agb': legalAgbFR,
|
||||
'legal-datenschutz': legalDatenschutzFR,
|
||||
'legal-impressum': legalImpressumFR,
|
||||
'legal-widerruf': legalWiderrufFR,
|
||||
'legal-batterie': legalBatterieFR
|
||||
},
|
||||
hr: {
|
||||
translation: translationHR,
|
||||
'legal-agb': legalAgbHR,
|
||||
'legal-datenschutz': legalDatenschutzHR,
|
||||
'legal-impressum': legalImpressumHR,
|
||||
'legal-widerruf': legalWiderrufHR,
|
||||
'legal-batterie': legalBatterieHR
|
||||
},
|
||||
hu: {
|
||||
translation: translationHU,
|
||||
'legal-agb': legalAgbHU,
|
||||
'legal-datenschutz': legalDatenschutzHU,
|
||||
'legal-impressum': legalImpressumHU,
|
||||
'legal-widerruf': legalWiderrufHU,
|
||||
'legal-batterie': legalBatterieHU
|
||||
},
|
||||
it: {
|
||||
translation: translationIT,
|
||||
'legal-agb': legalAgbIT,
|
||||
'legal-datenschutz': legalDatenschutzIT,
|
||||
'legal-impressum': legalImpressumIT,
|
||||
'legal-widerruf': legalWiderrufIT,
|
||||
'legal-batterie': legalBatterieIT
|
||||
},
|
||||
pl: {
|
||||
translation: translationPL,
|
||||
'legal-agb': legalAgbPL,
|
||||
'legal-datenschutz': legalDatenschutzPL,
|
||||
'legal-impressum': legalImpressumPL,
|
||||
'legal-widerruf': legalWiderrufPL,
|
||||
'legal-batterie': legalBatteriePL
|
||||
},
|
||||
ro: {
|
||||
translation: translationRO,
|
||||
'legal-agb': legalAgbRO,
|
||||
'legal-datenschutz': legalDatenschutzRO,
|
||||
'legal-impressum': legalImpressumRO,
|
||||
'legal-widerruf': legalWiderrufRO,
|
||||
'legal-batterie': legalBatterieRO
|
||||
},
|
||||
ru: {
|
||||
translation: translationRU,
|
||||
'legal-agb': legalAgbRU,
|
||||
'legal-datenschutz': legalDatenschutzRU,
|
||||
'legal-impressum': legalImpressumRU,
|
||||
'legal-widerruf': legalWiderrufRU,
|
||||
'legal-batterie': legalBatterieRU
|
||||
},
|
||||
sk: {
|
||||
translation: translationSK,
|
||||
'legal-agb': legalAgbSK,
|
||||
'legal-datenschutz': legalDatenschutzSK,
|
||||
'legal-impressum': legalImpressumSK,
|
||||
'legal-widerruf': legalWiderrufSK,
|
||||
'legal-batterie': legalBatterieSK
|
||||
},
|
||||
sl: {
|
||||
translation: translationSL,
|
||||
'legal-agb': legalAgbSL,
|
||||
'legal-datenschutz': legalDatenschutzSL,
|
||||
'legal-impressum': legalImpressumSL,
|
||||
'legal-widerruf': legalWiderrufSL,
|
||||
'legal-batterie': legalBatterieSL
|
||||
},
|
||||
sr: {
|
||||
translation: translationSR,
|
||||
'legal-agb': legalAgbSR,
|
||||
'legal-datenschutz': legalDatenschutzSR,
|
||||
'legal-impressum': legalImpressumSR,
|
||||
'legal-widerruf': legalWiderrufSR,
|
||||
'legal-batterie': legalBatterieSR
|
||||
},
|
||||
sv: {
|
||||
translation: translationSV,
|
||||
'legal-agb': legalAgbSV,
|
||||
'legal-datenschutz': legalDatenschutzSV,
|
||||
'legal-impressum': legalImpressumSV,
|
||||
'legal-widerruf': legalWiderrufSV,
|
||||
'legal-batterie': legalBatterieSV
|
||||
},
|
||||
tr: {
|
||||
translation: translationTR,
|
||||
'legal-agb': legalAgbTR,
|
||||
'legal-datenschutz': legalDatenschutzTR,
|
||||
'legal-impressum': legalImpressumTR,
|
||||
'legal-widerruf': legalWiderrufTR,
|
||||
'legal-batterie': legalBatterieTR
|
||||
},
|
||||
uk: {
|
||||
translation: translationUK,
|
||||
'legal-agb': legalAgbUK,
|
||||
'legal-datenschutz': legalDatenschutzUK,
|
||||
'legal-impressum': legalImpressumUK,
|
||||
'legal-widerruf': legalWiderrufUK,
|
||||
'legal-batterie': legalBatterieUK
|
||||
},
|
||||
zh: {
|
||||
translation: translationZH,
|
||||
'legal-agb': legalAgbZH,
|
||||
'legal-datenschutz': legalDatenschutzZH,
|
||||
'legal-impressum': legalImpressumZH,
|
||||
'legal-widerruf': legalWiderrufZH,
|
||||
'legal-batterie': legalBatterieZH
|
||||
}
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use({
|
||||
type: 'languageDetector',
|
||||
async: false,
|
||||
detect: customDetector.lookup,
|
||||
init() {},
|
||||
cacheUserLanguage: customDetector.cacheUserLanguage
|
||||
})
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'de', // German as fallback since it's your primary language
|
||||
fallbackLng: 'de',
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
|
||||
// Language detection options
|
||||
// Disable automatic language detection from browser
|
||||
detection: {
|
||||
// Order of language detection methods
|
||||
order: ['localStorage', 'navigator', 'htmlTag'],
|
||||
// Cache the language selection
|
||||
caches: ['localStorage'],
|
||||
// Check for language in localStorage
|
||||
lookupLocalStorage: 'i18nextLng'
|
||||
order: ['customDetector'],
|
||||
caches: ['localStorage', 'sessionStorage']
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
@@ -372,10 +204,57 @@ i18n
|
||||
// React-specific options
|
||||
react: {
|
||||
useSuspense: false // Disable suspense for class components compatibility
|
||||
}
|
||||
},
|
||||
|
||||
// Load missing keys as fallback
|
||||
saveMissing: process.env.NODE_ENV === 'development'
|
||||
});
|
||||
|
||||
// Export withI18n and other utilities for easy access
|
||||
export { withI18n, withTranslation, withLanguage, LanguageContext, LanguageProvider } from './withTranslation.js';
|
||||
// Override changeLanguage to load languages on demand
|
||||
const originalChangeLanguage = i18n.changeLanguage.bind(i18n);
|
||||
i18n.changeLanguage = async (language) => {
|
||||
if (language !== 'de' && !languageCache.has(language)) {
|
||||
try {
|
||||
await loadLanguage(language);
|
||||
} catch {
|
||||
console.error(`Failed to load language ${language}, falling back to German`);
|
||||
language = 'de';
|
||||
}
|
||||
}
|
||||
|
||||
return originalChangeLanguage(language);
|
||||
};
|
||||
|
||||
// Check session storage on initialization and load language if needed
|
||||
const initializeLanguage = async () => {
|
||||
// Only run in browser environment
|
||||
if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionLang = sessionStorage.getItem('i18nextLng');
|
||||
if (sessionLang && sessionLang !== 'de' && !languageCache.has(sessionLang)) {
|
||||
console.log(`🔄 Restoring session language: ${sessionLang}`);
|
||||
await loadLanguage(sessionLang);
|
||||
await i18n.changeLanguage(sessionLang);
|
||||
}
|
||||
} catch {
|
||||
console.warn('Failed to restore session language');
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize language on DOM ready (browser only)
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeLanguage);
|
||||
} else {
|
||||
initializeLanguage();
|
||||
}
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
export { loadLanguage };
|
||||
|
||||
// Re-export withI18n and other utilities for compatibility
|
||||
export { withI18n, withTranslation, withLanguage, LanguageContext, LanguageProvider } from './withTranslation.js';
|
||||
@@ -5,6 +5,7 @@ export default {
|
||||
"profile": "الملف الشخصي",
|
||||
"email": "البريد الإلكتروني",
|
||||
"password": "كلمة المرور",
|
||||
"newPassword": "كلمة المرور الجديدة",
|
||||
"confirmPassword": "تأكيد كلمة المرور",
|
||||
"forgotPassword": "هل نسيت كلمة المرور؟",
|
||||
"loginWithGoogle": "تسجيل الدخول باستخدام جوجل",
|
||||
@@ -13,6 +14,7 @@ export default {
|
||||
"privacyPolicy": "سياسة الخصوصية",
|
||||
"passwordMinLength": "يجب أن تكون كلمة المرور 8 أحرف على الأقل",
|
||||
"newPasswordMinLength": "يجب أن تكون كلمة المرور الجديدة 8 أحرف على الأقل",
|
||||
"backToHome": "العودة إلى الصفحة الرئيسية",
|
||||
"menu": {
|
||||
"profile": "الملف الشخصي",
|
||||
"myProfile": "ملفي الشخصي",
|
||||
@@ -21,5 +23,28 @@ export default {
|
||||
"settings": "الإعدادات",
|
||||
"adminDashboard": "لوحة تحكم المسؤول",
|
||||
"adminUsers": "مستخدمو المسؤول"
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "إعادة تعيين كلمة المرور",
|
||||
"button": "إعادة تعيين كلمة المرور",
|
||||
"success": "تم إعادة تعيين كلمة المرور بنجاح! سيتم توجيهك لتسجيل الدخول قريبًا...",
|
||||
"invalidToken": "لم يتم العثور على رمز صالح. يرجى استخدام الرابط من بريدك الإلكتروني.",
|
||||
"error": "حدث خطأ أثناء إعادة تعيين كلمة المرور",
|
||||
"emailSent": "تم إرسال رابط لإعادة تعيين كلمة المرور إلى بريدك الإلكتروني.",
|
||||
"emailError": "حدث خطأ أثناء إرسال البريد الإلكتروني"
|
||||
},
|
||||
"errors": {
|
||||
"fillAllFields": "يرجى ملء جميع الحقول",
|
||||
"invalidEmail": "يرجى إدخال بريد إلكتروني صالح",
|
||||
"passwordsNotMatch": "كلمات المرور غير متطابقة",
|
||||
"passwordsNotMatchShort": "كلمات المرور غير متطابقة",
|
||||
"enterEmail": "يرجى إدخال بريدك الإلكتروني",
|
||||
"loginFailed": "فشل تسجيل الدخول",
|
||||
"registerFailed": "فشل التسجيل",
|
||||
"googleLoginFailed": "فشل تسجيل الدخول عبر جوجل",
|
||||
"emailExists": "يوجد مستخدم بهذا البريد الإلكتروني بالفعل. يرجى استخدام بريد إلكتروني آخر أو تسجيل الدخول."
|
||||
},
|
||||
"success": {
|
||||
"registerComplete": "تم التسجيل بنجاح. يمكنك الآن تسجيل الدخول."
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,5 +15,6 @@ export default {
|
||||
"remove": "إزالة",
|
||||
"products": "منتجات",
|
||||
"product": "منتج",
|
||||
"days": "أيام"
|
||||
"days": "أيام",
|
||||
"more": "المزيد"
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ export default {
|
||||
"prices": {
|
||||
"free": "مجاني",
|
||||
"freeFrom100": "(مجاني من 100€)",
|
||||
"dhl": "6.99 €",
|
||||
"dhl": "5.90 €",
|
||||
"dpd": "4.90 €",
|
||||
"sperrgut": "28.99 €"
|
||||
},
|
||||
@@ -27,7 +27,7 @@ export default {
|
||||
},
|
||||
"selector": {
|
||||
"title": "اختر طريقة الشحن",
|
||||
"freeShippingInfo": "💡 شحن مجاني للطلبات فوق 100€!",
|
||||
"freeShippingInfo": "💡 الشحن مجاني للطلبات فوق 100€!",
|
||||
"remainingForFree": "أضف {{amount}}€ أخرى للشحن المجاني.",
|
||||
"congratsFreeShipping": "🎉 مبروك! حصلت على شحن مجاني!",
|
||||
"cartQualifiesFree": "سلة مشترياتك بقيمة {{amount}}€ مؤهلة للشحن المجاني."
|
||||
|
||||
@@ -3,6 +3,7 @@ import navigation from './navigation.js';
|
||||
import auth from './auth.js';
|
||||
import cart from './cart.js';
|
||||
import product from './product.js';
|
||||
import productDialogs from './productDialogs.js';
|
||||
import search from './search.js';
|
||||
import sorting from './sorting.js';
|
||||
import chat from './chat.js';
|
||||
@@ -18,6 +19,17 @@ import pages from './pages.js';
|
||||
import orders from './orders.js';
|
||||
import settings from './settings.js';
|
||||
import common from './common.js';
|
||||
import kitConfig from './kitConfig.js';
|
||||
import legalDatenschutzBasic from './legal-datenschutz-basic.js';
|
||||
import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
|
||||
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
|
||||
import legalDatenschutzNewsletter from './legal-datenschutz-newsletter.js';
|
||||
import legalDatenschutzChatbot from './legal-datenschutz-chatbot.js';
|
||||
import legalDatenschutzCookiesPayment from './legal-datenschutz-cookies-payment.js';
|
||||
import legalDatenschutzRights from './legal-datenschutz-rights.js';
|
||||
import legalAgbDelivery from './legal-agb-delivery.js';
|
||||
import legalAgbPayment from './legal-agb-payment.js';
|
||||
import legalAgbConsumer from './legal-agb-consumer.js';
|
||||
|
||||
export default {
|
||||
"locale": locale,
|
||||
@@ -25,6 +37,7 @@ export default {
|
||||
"auth": auth,
|
||||
"cart": cart,
|
||||
"product": product,
|
||||
"productDialogs": productDialogs,
|
||||
"search": search,
|
||||
"sorting": sorting,
|
||||
"chat": chat,
|
||||
@@ -39,5 +52,16 @@ export default {
|
||||
"pages": pages,
|
||||
"orders": orders,
|
||||
"settings": settings,
|
||||
"common": common
|
||||
"common": common,
|
||||
"kitConfig": kitConfig,
|
||||
"legalDatenschutzBasic": legalDatenschutzBasic,
|
||||
"legalDatenschutzCustomer": legalDatenschutzCustomer,
|
||||
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders,
|
||||
"legalDatenschutzNewsletter": legalDatenschutzNewsletter,
|
||||
"legalDatenschutzChatbot": legalDatenschutzChatbot,
|
||||
"legalDatenschutzCookiesPayment": legalDatenschutzCookiesPayment,
|
||||
"legalDatenschutzRights": legalDatenschutzRights,
|
||||
"legalAgbDelivery": legalAgbDelivery,
|
||||
"legalAgbPayment": legalAgbPayment,
|
||||
"legalAgbConsumer": legalAgbConsumer
|
||||
};
|
||||
43
src/i18n/locales/ar/kitConfig.js
Normal file
@@ -0,0 +1,43 @@
|
||||
export default {
|
||||
"pageTitle": "🌱 مُكوّن جروبوكس",
|
||||
"pageSubtitle": "ركّب إعداد النمو الداخلي المثالي بتاعك",
|
||||
"bundleDiscountTitle": "🎯 احصل على خصم الباقة!",
|
||||
"loadingProducts": "جارٍ تحميل منتجات الجروبوكس...",
|
||||
"loadingLighting": "جارٍ تحميل منتجات الإضاءة...",
|
||||
"loadingVentilation": "جارٍ تحميل منتجات التهوية...",
|
||||
"loadingExtras": "جارٍ تحميل الإضافات...",
|
||||
"noProductsAvailable": "لا توجد منتجات متاحة لهذا الحجم",
|
||||
"noLightingAvailable": "لا توجد أضواء مناسبة لحجم الخيمة {{shape}}.",
|
||||
"noVentilationAvailable": "لا توجد تهوية مناسبة لحجم الخيمة {{shape}}.",
|
||||
"noExtrasAvailable": "لا توجد إضافات متاحة",
|
||||
"selectShapeTitle": "1. اختر شكل الجروبوكس",
|
||||
"selectShapeSubtitle": "اختار أولاً مساحة قاعدة الجروبوكس بتاعتك",
|
||||
"selectProductTitle": "2. اختر منتج الجروبوكس",
|
||||
"selectProductSubtitle": "اختار المنتج المناسب لجروبوكس {{shape}} بتاعك",
|
||||
"selectLightingTitle": "3. اختر الإضاءة",
|
||||
"selectLightingTitleShape": "3. اختر الإضاءة - {{shape}}",
|
||||
"selectLightingSubtitle": "من فضلك اختار حجم الخيمة الأول.",
|
||||
"selectVentilationTitle": "4. اختر التهوية",
|
||||
"selectVentilationTitleShape": "4. اختر التهوية - {{shape}}",
|
||||
"selectVentilationSubtitle": "من فضلك اختار حجم الخيمة الأول.",
|
||||
"selectExtrasTitle": "5. أضف إضافات (اختياري)",
|
||||
"yourConfiguration": "🎯 التكوين بتاعك",
|
||||
"growboxLabel": "جروبوكس: {{name}}",
|
||||
"lightingLabel": "الإضاءة: {{name}}",
|
||||
"ventilationLabel": "التهوية: {{name}}",
|
||||
"extraLabel": "إضافة: {{name}}",
|
||||
"totalPrice": "السعر الكلي:",
|
||||
"addToCart": "أضف إلى السلة",
|
||||
"selected": "✓ تم الاختيار",
|
||||
"notDeliverable": "غير متوفر للتوصيل",
|
||||
"noPrice": "لا يوجد سعر",
|
||||
"setName": "طقم جروبوكس - {{shape}}",
|
||||
"description60x60": "مُدمج - مثالي للمساحات الصغيرة",
|
||||
"description80x80": "متوسط - توازن مثالي",
|
||||
"description100x100": "كبير - للمزارعين المتمرسين",
|
||||
"description120x60": "مستطيل - استخدام أقصى للمساحة",
|
||||
"plants1to2": "1-2 نباتات",
|
||||
"plants2to4": "2-4 نباتات",
|
||||
"plants4to6": "4-6 نباتات",
|
||||
"plants3to6": "3-6 نباتات"
|
||||
};
|
||||
37
src/i18n/locales/ar/legal-agb-consumer.js
Normal file
@@ -0,0 +1,37 @@
|
||||
export default {
|
||||
"distanceSelling": {
|
||||
"title": "معلومات وفقًا لقانون البيع عن بُعد",
|
||||
"intro": "تنطبق المعلومات التالية فقط على العقود المبرمة بين Growheads والمستهلكين عن طريق طلب الكتالوج، طلب الإنترنت، أو وسائل الاتصال عن بُعد الأخرى. وهي محدودة للمستهلكين داخل الاتحاد الأوروبي.",
|
||||
"sections": {
|
||||
"1": {
|
||||
"title": "الخصائص الأساسية للسلع",
|
||||
"content": "يرجى الرجوع إلى الشروحات في الكتالوج أو على موقعنا الإلكتروني لمعرفة الخصائص الأساسية للسلع. العروض في كتالوجنا وعلى موقعنا الإلكتروني غير ملزمة. الطلبات المقدمة إلينا تُعتبر عروضًا ملزمة. يمكن لـ Growheads قبول هذه الطلبات خلال فترة 14 يومًا من استلام الطلب عن طريق إرسال تأكيد الطلب أو عن طريق شحن البضاعة."
|
||||
},
|
||||
"2": {
|
||||
"title": "التحفظ",
|
||||
"content": "إذا لم تكن جميع الأصناف المطلوبة متاحة للتسليم، نحتفظ بالحق في إجراء تسليمات جزئية، بشرط أن يكون ذلك معقولًا للعميل. قد تختلف بعض الأصناف عن الصور والوصف في الكتالوج وعلى الموقع الإلكتروني. هذا ينطبق بشكل خاص على السلع المصنوعة يدويًا. لذلك نحتفظ بالحق، إذا لزم الأمر، في تسليم سلع ذات جودة وسعر مكافئين."
|
||||
},
|
||||
"3": {
|
||||
"title": "الأسعار والضرائب",
|
||||
"content": "يمكنك العثور على أسعار الأصناف الفردية شاملة ضريبة القيمة المضافة في الكتالوج أو على موقعنا الإلكتروني. تفقد الأسعار صلاحيتها عند صدور كتالوج جديد."
|
||||
},
|
||||
"4": "جميع الأسعار عرضة للأخطاء أو تقلبات الأسعار. إذا حدث تغيير في السعر، يحق للمشتري ممارسة حقه في الإرجاع.",
|
||||
"5": {
|
||||
"title": "فترة الضمان",
|
||||
"content": "تطبق فترة الضمان القانونية لمدة 24 (أربعة وعشرين) شهرًا. في بعض الحالات الفردية، قد تنطبق فترات أطول إذا منحها المصنع."
|
||||
},
|
||||
"6": {
|
||||
"title": "حق الإرجاع / حق الانسحاب",
|
||||
"content": "يتمتع العميل بحق إرجاع لمدة 14 يومًا.\nتبدأ الفترة عند استلام العميل للبضاعة ويتم احترامها بإرسال الانسحاب في الوقت المناسب إلى Growheads. تستثنى من ذلك المواد الغذائية وغيرها من السلع القابلة للتلف، وكذلك المنتجات المصممة خصيصًا أو السلع التي تم طلبها خصيصًا بناءً على طلب العميل. يجب أن يتم الإرجاع عن طريق إعادة إرسال البضاعة خلال الفترة المحددة. إذا لم يكن بالإمكان شحن البضاعة، يجب إرسال طلب الإرجاع إلينا خلال الفترة عن طريق رسالة، بطاقة بريدية، بريد إلكتروني، أو أي وسيلة دائمة أخرى. يكفي الإرسال في الوقت المناسب إلى عنوان الشركة المذكور في البند 7) للحفاظ على الموعد النهائي. لا يتطلب الانسحاب سببًا. سيتم رد ثمن الشراء وأي تكاليف توصيل وشحن بعد استلامنا للبضاعة. القيمة الحاسمة هي قيمة البضاعة المعادة وقت الشراء، وليس قيمة الطلب الكامل. عادةً ما يمكن لـ Growheads ترتيب استلام البضاعة منك."
|
||||
},
|
||||
"7": {
|
||||
"title": "اسم وعنوان الشركة، الشكاوى، الاستدعاءات",
|
||||
"content": "Growheads\nTrachenberger Straße 14\n01129 Dresden"
|
||||
},
|
||||
"8": {
|
||||
"title": "مكان التنفيذ والاختصاص القضائي",
|
||||
"content": "مكان التنفيذ والاختصاص القضائي لجميع المطالبات هو دريسدن، ما لم تنص أحكام قانونية إلزامية على خلاف ذلك."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
20
src/i18n/locales/ar/legal-agb-delivery.js
Normal file
@@ -0,0 +1,20 @@
|
||||
export default {
|
||||
"title": "الشروط والأحكام العامة",
|
||||
"deliveryShippingConditions": "شروط التسليم والشحن",
|
||||
"deliveryTerms": {
|
||||
"1": "يستغرق الشحن من 1 إلى 7 أيام.",
|
||||
"2": "تظل البضاعة ملكًا لشركة Growheads حتى يتم استلام الدفعة كاملة.",
|
||||
"3": "إذا كان هناك اشتباه في أن البضاعة قد تضررت أثناء النقل أو أن هناك عناصر مفقودة، يجب الاحتفاظ بتغليف الشحن لفحصه من قبل خبير. يجب أن يؤكد الناقل أي ضرر في التغليف على سند التسليم، مع تحديد نوع الضرر ومداه. يجب الإبلاغ عن أضرار الشحن إلى Growheads فورًا كتابيًا عبر الفاكس أو البريد الإلكتروني أو البريد. لهذا الغرض، يجب التقاط صور للبضاعة التالفة وكذلك لصندوق الشحن التالف مع ملصق العنوان. يجب أيضًا الاحتفاظ بصندوق الشحن التالف. هذه الوثائق مطلوبة للمطالبة بالتعويض من شركة النقل.",
|
||||
"4": "عند إعادة البضائع المعيبة، يجب على العميل التأكد من أن البضائع معبأة بشكل صحيح.",
|
||||
"5": "يجب تسجيل جميع عمليات الإرجاع مسبقًا لدى Growheads.",
|
||||
"6": "يتحمل العميل مخاطر إرسال العناصر إلينا، ما لم يكن الأمر يتعلق بإرجاع بضائع معيبة.",
|
||||
"7": "يحق لـ Growheads أن تطلب استلام البضاعة من خلال Deutsche Post/GLS أو شركة شحن تختارها.",
|
||||
"8": "يتم حساب تكاليف البريد بناءً على الوزن. تحتفظ Growheads بحق تمرير أي زيادات في الأسعار من شركات النقل (رسوم المرور، رسوم الوقود).",
|
||||
"9": "عادةً ما يتم شحن طرودنا عبر: GLS، DHL و Deutsche Post AG.",
|
||||
"10": "بالنسبة للعناصر الثقيلة أو الضخمة بشكل خاص، نحتفظ بالحق في فرض رسوم إضافية على تكاليف التسليم. عادةً ما تكون هذه الرسوم مذكورة في قائمة الأسعار.",
|
||||
"11": "يمكن الدفع مقدمًا عن طريق التحويل البنكي إلى الحساب المصرفي المحدد.",
|
||||
"12": "إذا حدث تأخير في التسليم نتحمل مسؤوليته، فإن فترة السماح التي يحق للمشتري تحديدها محدودة بأسبوعين. تبدأ الفترة من استلام Growheads لإشعار فترة السماح.",
|
||||
"13": "يجب الإبلاغ كتابيًا عن العيوب الظاهرة في البضاعة فور التسليم. إذا لم يلتزم العميل بهذا الالتزام، تُستبعد مطالبات الضمان المتعلقة بالعيوب الظاهرة.",
|
||||
"14": "إذا اشتكى العميل من عيب، يجب عليه إعادة البضاعة المعيبة إلينا مع وصف دقيق للعيب قدر الإمكان. يجب إرفاق نسخة من فاتورتنا مع الشحنة. يجب إعادة البضاعة في التغليف الأصلي أو في تغليف يحمي البضاعة بنفس طريقة التغليف الأصلي، لتجنب التلف أثناء الإرجاع."
|
||||
}
|
||||
};
|
||||
16
src/i18n/locales/ar/legal-agb-payment.js
Normal file
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
"consultationLiability": {
|
||||
"title": "الاستشارة والمسؤولية",
|
||||
"1": "نقدم نصائح فنية تطبيقية حسب أفضل معرفتنا بناءً على مستوى خبرتنا ومعرفتنا الحالي.",
|
||||
"2": "المشتري مسؤول عن الالتزام باللوائح القانونية المتعلقة بالتخزين، والنقل الإضافي، واستخدام بضائعنا.",
|
||||
},
|
||||
"paymentConditions": {
|
||||
"title": "شروط الدفع",
|
||||
"1": "تظل البضائع ملكًا لشركة Growheads حتى يتم استلام الدفعة كاملة.",
|
||||
"2": "يتم دفع الفواتير مقدمًا عن طريق التحويل البنكي إلى حسابنا البنكي. إذا دفعت مقدمًا، سيتم شحن البضائع بمجرد تسجيل المبلغ في حسابنا.",
|
||||
},
|
||||
"retentionOfTitle": {
|
||||
"title": "الاحتفاظ بالملكية",
|
||||
"content": "تظل البضائع المسلمة ملكًا لشركة Growheads حتى يقوم المشتري بتسوية جميع المطالبات الموجهة ضده. إذا قام البائع بإعادة بيع البضائع، فإنه بموجب هذا يعهد إلينا بالمطالبات الناشئة عن البيع. إذا تأخر المشتري في السداد، يحق لنا في أي وقت طلب إعادة البضائع دون الانسحاب من العقد.",
|
||||
}
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
export default {
|
||||
"title": "الشروط والأحكام العامة",
|
||||
"deliveryShippingConditions": "شروط التسليم والشحن",
|
||||
"deliveryTerms": {
|
||||
"1": "يستغرق الشحن ما بين 1 إلى 7 أيام.",
|
||||
"2": "تظل البضاعة ملكًا لشركة Growheads حتى يتم استلام الدفعة كاملة.",
|
||||
"3": "إذا كان هناك اشتباه في تلف البضاعة أثناء النقل أو نقص في الأصناف، يجب الاحتفاظ بتغليف الشحن لفحصه من قبل خبير. يجب أن يؤكد الناقل أي تلف في التغليف على مذكرة التسليم مع تحديد نوع ومدى التلف. يجب الإبلاغ عن أضرار الشحن إلى Growheads فورًا كتابيًا عبر الفاكس أو البريد الإلكتروني أو البريد. لهذا الغرض، يجب التقاط صور للبضاعة التالفة وكذلك لصندوق الشحن التالف مع ملصق العنوان. يجب أيضًا الاحتفاظ بصندوق الشحن التالف. هذه الوثائق مطلوبة للمطالبة بالتعويض من شركة الشحن.",
|
||||
"4": "عند إعادة البضائع المعيبة، يجب على العميل التأكد من تغليف البضائع بشكل صحيح.",
|
||||
"5": "يجب تسجيل جميع المرتجعات مسبقًا لدى Growheads.",
|
||||
"6": "يتحمل العميل مخاطر إرسال الأصناف إلينا، ما لم تكن إعادة البضائع المعيبة.",
|
||||
"7": "يحق لـ Growheads أن تطلب استلام البضائع من خلال Deutsche Post/GLS أو ناقل من اختيارها.",
|
||||
"8": "يتم حساب تكاليف البريد بناءً على الوزن. تحتفظ Growheads بحق تمرير أي زيادات في الأسعار من شركات الشحن (رسوم المرور، رسوم الوقود).",
|
||||
"9": "عادةً ما يتم شحن طرودنا عبر: GLS، DHL و Deutsche Post AG.",
|
||||
"10": "بالنسبة للأصناف الثقيلة أو الضخمة بشكل خاص، نحتفظ بحق فرض رسوم إضافية على تكاليف التسليم. عادةً ما تكون هذه الرسوم مذكورة في قائمة الأسعار.",
|
||||
"11": "يمكن الدفع مقدمًا عن طريق التحويل البنكي إلى الحساب المصرفي المحدد.",
|
||||
"12": "إذا حدث تأخير في التسليم نتحمل مسؤوليته، فإن فترة السماح التي يحق للمشتري تحديدها محدودة بأسبوعين. تبدأ الفترة من استلام Growheads لإشعار فترة السماح.",
|
||||
"13": "يجب الإبلاغ كتابيًا عن العيوب الظاهرة في البضاعة فور التسليم. إذا لم يفعل العميل ذلك، تُستبعد مطالبات الضمان عن العيوب الظاهرة.",
|
||||
"14": "إذا اشتكى العميل من عيب، يجب عليه إعادة البضاعة المعيبة إلينا مع وصف دقيق للعيب. يجب إرفاق نسخة من فاتورتنا مع الشحنة. يجب إعادة البضاعة في التغليف الأصلي أو في تغليف يحمي البضاعة بنفس طريقة التغليف الأصلي لتجنب التلف أثناء النقل العكسي."
|
||||
},
|
||||
"consultationLiability": {
|
||||
"title": "الاستشارة والمسؤولية",
|
||||
"1": "نقدم استشارات فنية تطبيقية بأفضل ما لدينا من معرفة بناءً على خبرتنا ومهاراتنا.",
|
||||
"2": "المشتري مسؤول عن الالتزام باللوائح القانونية المتعلقة بالتخزين والنقل والاستخدام للبضائع."
|
||||
},
|
||||
"paymentConditions": {
|
||||
"title": "شروط الدفع",
|
||||
"1": "تظل البضاعة ملكًا لشركة Growheads حتى يتم استلام الدفعة كاملة.",
|
||||
"2": "تُدفع الفواتير مقدمًا عن طريق التحويل البنكي إلى حسابنا. إذا دفعت مقدمًا، سيتم شحن البضاعة بمجرد تسجيل المبلغ في حسابنا."
|
||||
},
|
||||
"retentionOfTitle": {
|
||||
"title": "الاحتفاظ بالملكية",
|
||||
"content": "تظل البضاعة المسلمة ملكًا لشركة Growheads حتى يسدد المشتري جميع المطالبات الموجهة إليه. إذا قام البائع بإعادة بيع البضاعة، فإنه يتنازل بموجب هذا عن المطالبات الناشئة من البيع لنا. إذا تأخر المشتري في دفعاته، يمكننا في أي وقت طلب إعادة البضاعة دون الانسحاب من العقد."
|
||||
},
|
||||
"distanceSelling": {
|
||||
"title": "معلومات وفقًا لقانون البيع عن بعد",
|
||||
"intro": "تنطبق المعلومات التالية فقط على العقود المبرمة بين Growheads والمستهلكين عن طريق طلب الكتالوج أو الإنترنت أو وسائل الاتصال عن بعد الأخرى. وهي محدودة للمستهلكين داخل الاتحاد الأوروبي.",
|
||||
"sections": {
|
||||
"1": {
|
||||
"title": "الخصائص الأساسية للبضاعة",
|
||||
"content": "يرجى الرجوع إلى الشروحات في الكتالوج أو على موقعنا الإلكتروني لمعرفة الخصائص الأساسية للبضاعة. العروض في كتالوجنا وعلى موقعنا غير ملزمة. الطلبات المقدمة إلينا تعتبر عروضًا ملزمة. يمكن لـ Growheads قبولها خلال 14 يومًا من استلام الطلب عن طريق إرسال تأكيد الطلب أو شحن البضاعة."
|
||||
},
|
||||
"2": {
|
||||
"title": "التحفظ",
|
||||
"content": "إذا لم تكن جميع الأصناف المطلوبة متوفرة للتسليم، نحتفظ بحق التسليم الجزئي إذا كان ذلك معقولًا للعميل. قد تختلف بعض الأصناف عن الصور والوصف في الكتالوج والموقع الإلكتروني، خاصةً البضائع المصنوعة يدويًا. لذلك نحتفظ بحق تسليم بضائع ذات جودة وسعر معادل إذا لزم الأمر."
|
||||
},
|
||||
"3": {
|
||||
"title": "الأسعار والضرائب",
|
||||
"content": "يمكنك العثور على أسعار الأصناف الفردية شاملة ضريبة القيمة المضافة في الكتالوج أو على موقعنا. تصبح الأسعار غير صالحة عند صدور كتالوج جديد."
|
||||
},
|
||||
"4": "جميع الأسعار عرضة للأخطاء أو تقلبات الأسعار. إذا حدث تغيير في السعر، يحق للمشتري ممارسة حقه في الإرجاع.",
|
||||
"5": {
|
||||
"title": "مدة الضمان",
|
||||
"content": "تطبق فترة الضمان القانونية لمدة 24 (أربعة وعشرين) شهرًا. في حالات فردية، قد تنطبق فترات أطول إذا منحها المصنع."
|
||||
},
|
||||
"6": {
|
||||
"title": "حق الإرجاع / حق الانسحاب",
|
||||
"content": "يتمتع العميل بحق إرجاع لمدة 14 يومًا.\nتبدأ الفترة من استلام العميل للبضاعة وتُعتبر محفوظة بإرسال الانسحاب في الوقت المناسب إلى Growheads. تستثنى من ذلك الأغذية والسلع القابلة للتلف، وكذلك المنتجات المصممة خصيصًا أو البضائع التي تم طلبها بناءً على رغبة العميل. يجب أن يتم الإرجاع عن طريق إعادة البضاعة خلال الفترة. إذا لم يكن بالإمكان شحن البضاعة، يجب إرسال طلب الإرجاع إلينا خلال الفترة عن طريق رسالة، بطاقة بريدية، بريد إلكتروني، أو وسيلة دائمة أخرى. يكفي الإرسال في الوقت المناسب إلى العنوان المذكور تحت 7) للحفاظ على المهلة. لا يتطلب الانسحاب سببًا. سيتم رد ثمن الشراء وأي تكاليف تسليم وشحن بعد استلامنا للبضاعة. القيمة الحاسمة هي قيمة البضاعة المعادة وقت الشراء، وليس قيمة الطلب الكامل. عادةً ما يمكن لـ Growheads ترتيب الاستلام منك."
|
||||
},
|
||||
"7": {
|
||||
"title": "اسم وعنوان الشركة، الشكاوى، الاستدعاءات",
|
||||
"content": "Growheads\nTrachenberger Straße 14\n01129 Dresden"
|
||||
},
|
||||
"8": {
|
||||
"title": "مكان التنفيذ والاختصاص القضائي",
|
||||
"content": "مكان التنفيذ والاختصاص القضائي لجميع المطالبات هو دريسدن، ما لم تنص أحكام قانونية إلزامية على خلاف ذلك."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
export default {
|
||||
"title": "معلومات قانون البطاريات",
|
||||
"intro": "فيما يتعلق ببيع البطاريات أو تسليم الأجهزة التي تحتوي على بطاريات، نحن ملزمون بإبلاغكم بما يلي:",
|
||||
"returnObligation": "بصفتك مستخدم نهائي، أنت ملزم قانونيًا بإعادة البطاريات المستخدمة. يمكنك إعادة البطاريات القديمة التي نمتلكها أو التي كانت ضمن مجموعتنا كبطاريات جديدة مجانًا إلى مستودع الشحن الخاص بنا (عنوان الشحن).",
|
||||
"returnObligation": "بصفتك مستخدم نهائي، أنت ملزم قانونيًا بإرجاع البطاريات المستخدمة. يمكنك إرجاع البطاريات القديمة التي نمتلكها أو التي كانت ضمن مجموعتنا كبطاريات جديدة مجانًا إلى مستودع الشحن الخاص بنا (عنوان الشحن).",
|
||||
"symbolsInfo": "الرموز المعروضة على البطاريات تعني ما يلي:",
|
||||
"wasteSymbol": "رمز سلة المهملات المعلمة بعلامة إلغاء يعني أنه لا يجوز التخلص من البطارية مع النفايات المنزلية.",
|
||||
"chemicalSymbols": "Pb = البطارية تحتوي على أكثر من 0.004 بالمئة بالوزن من الرصاص\nCd = البطارية تحتوي على أكثر من 0.002 بالمئة بالوزن من الكادميوم\nHg = البطارية تحتوي على أكثر من 0.0005 بالمئة بالوزن من الزئبق."
|
||||
"chemicalSymbols": "Pb = البطارية تحتوي على أكثر من 0.004 بالمئة رصاص بالوزن\nCd = البطارية تحتوي على أكثر من 0.002 بالمئة كادميوم بالوزن\nHg = البطارية تحتوي على أكثر من 0.0005 بالمئة زئبق بالوزن."
|
||||
};
|
||||
|
||||
18
src/i18n/locales/ar/legal-datenschutz-basic.js
Normal file
@@ -0,0 +1,18 @@
|
||||
export default {
|
||||
"title": "سياسة الخصوصية",
|
||||
"responsibleParty": {
|
||||
"title": "الجهة المسؤولة بموجب قانون حماية البيانات:",
|
||||
"company": "Growheads\nTrachenberger Straße 14\n01129 Dresden"
|
||||
},
|
||||
"generalInfo": "ما لم يُذكر خلاف ذلك أدناه، فإن تقديم بياناتك الشخصية ليس مطلوبًا قانونيًا أو تعاقديًا، ولا هو ضروري لإبرام عقد. أنت غير ملزم بتقديم البيانات. عدم تقديمها لن يترتب عليه أي عواقب. هذا ينطبق فقط طالما لم يتم الإشارة إلى خلاف ذلك في عمليات المعالجة التالية. \"البيانات الشخصية\" تعني جميع المعلومات المتعلقة بشخص طبيعي محدد أو يمكن تحديده.",
|
||||
"sections": {
|
||||
"informationDeletion": {
|
||||
"title": "المعلومات، الحذف، الحظر",
|
||||
"content": "يمكنك في أي وقت طلب معلومات عن بياناتك الشخصية، مصدرها والمستلمين لها، وهدف معالجة البيانات، كما يمكنك طلب تصحيح أو حظر أو حذف هذه البيانات مجانًا. يرجى استخدام خيارات الاتصال الموجودة في تذييل الصفحة أو في الإشعار القانوني (Impressum) لهذا الغرض. نحن متاحون أيضًا في أي وقت لأي أسئلة إضافية حول الموضوع. يرجى ملاحظة أننا غير مخولين ولن نقوم بحذف بيانات الفواتير، البيانات البنكية، والبيانات التي تم إرسالها إلى مزود خدمة الشحن. البيانات التي يمكن حذفها تشمل: حسابات العملاء على خادم الويب، وكذلك في نظام إدارة البضائع، والبريد الإلكتروني الذي لا يرتبط مباشرة بطلب.",
|
||||
},
|
||||
"serverLogfiles": {
|
||||
"title": "ملفات سجل الخادم",
|
||||
"content": "يمكنك زيارة مواقعنا الإلكترونية دون تقديم أي معلومات عن نفسك. في كل مرة تصل فيها إلى موقعنا، يتم إرسال بيانات الاستخدام من متصفح الإنترنت الخاص بك وتخزينها في ملفات السجل (ملفات سجل الخادم). تشمل هذه البيانات المخزنة، على سبيل المثال، اسم الصفحة التي تم الوصول إليها، تاريخ ووقت الوصول، كمية البيانات المنقولة، ومزود الخدمة الذي طلب الوصول. تُستخدم هذه البيانات فقط لضمان التشغيل السلس لموقعنا وتحسين عرضنا. هذه البيانات ليست بيانات شخصية. لا يتم دمج هذه البيانات مع مصادر بيانات أخرى. إذا علمنا بوجود مؤشرات محددة على استخدام غير قانوني، نحتفظ بالحق في فحص هذه البيانات لاحقًا."
|
||||
}
|
||||
}
|
||||
};
|
||||
12
src/i18n/locales/ar/legal-datenschutz-chatbot.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export default {
|
||||
"sections": {
|
||||
"chatbot": {
|
||||
"title": "استخدام روبوت دردشة ذكي (OpenAI API)",
|
||||
"content": "نحن نستخدم روبوت دردشة مدعوم بالذكاء الاصطناعي على موقعنا الإلكتروني، والذي يتم تشغيله عبر واجهة برمجة التطبيقات (API) لمزود الخدمة OpenAI. يهدف روبوت الدردشة إلى الرد على استفسارات الزوار بكفاءة وبشكل تلقائي، وبالتالي توفير وظيفة دعم. عند استخدامك لروبوت الدردشة، يتم معالجة مدخلاتك بواسطة النظام لتوليد ردود مناسبة. تتم المعالجة بشكل مجهول – لا يتم جمع أو تخزين عناوين IP أو أي بيانات شخصية أخرى (مثل الاسم أو بيانات الاتصال).",
|
||||
"legalBasis": "الأساس القانوني لاستخدام روبوت الدردشة هو مصلحتنا المشروعة وفقًا للمادة 6 الفقرة 1 الحرف f من DSGVO. تكمن هذه المصلحة في توفير دعم فعال للزوار وكذلك تحسين تجربة المستخدم على موقعنا الإلكتروني.",
|
||||
"dataRecipient": "المستلم لبيانات الدردشة هو OpenAI (OpenAI OpCo, LLC) كمزود خدمة فني. تقوم OpenAI بمعالجة محتوى الدردشة المرسل على خوادمها حصريًا لغرض توليد الردود. تعمل OpenAI كمعالج بيانات وفقًا للمادة 28 من DSGVO ولا تستخدم البيانات لأغراضها الخاصة. لقد أبرمنا عقد معالجة بيانات مع OpenAI، والذي يتضمن بنود العقد النموذجية للاتحاد الأوروبي كضمانات مناسبة لحماية البيانات. يقع المقر الرئيسي لـ OpenAI في الولايات المتحدة الأمريكية؛ ومن خلال الموافقة على بنود العقد النموذجية، يتم ضمان مستوى حماية بيانات يعادل مستوى الاتحاد الأوروبي عند نقل بياناتك.",
|
||||
"dataRetention": "نحتفظ باستفسارات الدردشة الخاصة بك فقط طالما كان ذلك ضروريًا للمعالجة والرد. بمجرد الانتهاء من طلبك، يتم حذف سجلات الدردشة أو إخفاء هويتها على الفور. وفقًا لتصريحاتها الخاصة، تحتفظ OpenAI ببيانات الدردشة المعالجة مؤقتًا فقط وتحذفها تلقائيًا بعد مدة أقصاها 30 يومًا.",
|
||||
"voluntaryUse": "استخدام روبوت الدردشة اختياري. إذا لم تستخدم روبوت الدردشة، فلن يتم نقل أي بيانات إلى OpenAI. يرجى عدم إدخال أي بيانات شخصية حساسة في الدردشة."
|
||||
}
|
||||
}
|
||||
};
|
||||