Compare commits
230 Commits
e02b18e17f
...
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 | ||
|
|
8bc80c872d | ||
|
|
85504c725f | ||
|
|
04e97c2522 | ||
|
|
93887ce397 | ||
|
|
33fadc0279 | ||
|
|
5c2b4172da | ||
|
|
c663e902ea | ||
|
|
aa82e8d1d2 | ||
|
|
67f0126343 | ||
|
|
47a882b667 | ||
|
|
e43b894bfc | ||
|
|
c9477e53b6 | ||
|
|
0015872894 | ||
|
|
cb8ce69903 | ||
|
|
64048e6d0b | ||
|
|
e4b70dcbe2 | ||
|
|
1de3ba0115 | ||
|
|
4ef27da561 | ||
|
|
0895919448 | ||
|
|
6a017400fa | ||
|
|
3611908021 | ||
|
|
98393cea21 | ||
|
|
c078abec1a | ||
|
|
a7cfbce072 | ||
|
|
65611865c8 | ||
|
|
a8c77e1107 | ||
|
|
4f5bc96c9b | ||
|
|
3e3e676ded | ||
|
|
08c04909e0 | ||
|
|
510907b48a |
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
|
/node_modules
|
||||||
/.pnp
|
|
||||||
.pnp.js
|
|
||||||
.cursor/
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
/dist
|
/dist
|
||||||
/public/index.prerender.html
|
/logs
|
||||||
/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
|
|
||||||
47
.vscode/launch.json
vendored
@@ -16,7 +16,8 @@
|
|||||||
"skipFiles": [
|
"skipFiles": [
|
||||||
"<node_internals>/**"
|
"<node_internals>/**"
|
||||||
]
|
]
|
||||||
},{
|
},
|
||||||
|
{
|
||||||
"name": "Start",
|
"name": "Start",
|
||||||
"type": "node-terminal",
|
"type": "node-terminal",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
@@ -28,6 +29,50 @@
|
|||||||
"skipFiles": [
|
"skipFiles": [
|
||||||
"<node_internals>/**"
|
"<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
|
- Abhängigkeiten automatisch installieren falls nötig
|
||||||
- Entwicklungsserver mit API-Proxy zu seedheads.de starten
|
- Entwicklungsserver mit API-Proxy zu seedheads.de starten
|
||||||
- Browser öffnen auf http://localhost:9500
|
- 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",
|
"@mui/material": "^7.1.1",
|
||||||
"@stripe/react-stripe-js": "^3.7.0",
|
"@stripe/react-stripe-js": "^3.7.0",
|
||||||
"@stripe/stripe-js": "^7.3.1",
|
"@stripe/stripe-js": "^7.3.1",
|
||||||
|
"async-mutex": "^0.5.0",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
"country-flag-icons": "^1.5.19",
|
"country-flag-icons": "^1.5.19",
|
||||||
"html-react-parser": "^5.2.5",
|
"html-react-parser": "^5.2.5",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-i18next": "^15.6.0",
|
"react-i18next": "^15.6.0",
|
||||||
"react-router-dom": "^7.6.2",
|
"react-router-dom": "^7.6.2",
|
||||||
|
"sanitize-html": "^2.17.0",
|
||||||
"sharp": "^0.34.2",
|
"sharp": "^0.34.2",
|
||||||
"socket.io-client": "^4.7.5"
|
"socket.io-client": "^4.7.5"
|
||||||
},
|
},
|
||||||
@@ -58,7 +60,9 @@
|
|||||||
"webpack-bundle-analyzer": "^4.10.2",
|
"webpack-bundle-analyzer": "^4.10.2",
|
||||||
"webpack-cli": "^6.0.1",
|
"webpack-cli": "^6.0.1",
|
||||||
"webpack-dev-server": "^5.2.2",
|
"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": {
|
"node_modules/@ampproject/remapping": {
|
||||||
@@ -4198,6 +4202,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
@@ -4541,9 +4554,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001727",
|
"version": "1.0.30001757",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
|
||||||
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
|
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -5253,6 +5266,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/default-browser": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz",
|
||||||
@@ -8874,7 +8896,6 @@
|
|||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -9446,6 +9467,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/parse5": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
@@ -9654,7 +9681,6 @@
|
|||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -10543,6 +10569,60 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/saxes": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||||
@@ -11215,7 +11295,6 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -11827,7 +11906,6 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
@@ -12747,6 +12825,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/xmlhttprequest-ssl": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||||
@@ -12755,6 +12843,16 @@
|
|||||||
"node": ">=0.4.0"
|
"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": {
|
"node_modules/xtend": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz",
|
"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": "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",
|
"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",
|
"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",
|
"build": "npm run build:client",
|
||||||
"analyze": "cross-env ANALYZE=true NODE_ENV=production webpack --progress --mode production",
|
"analyze": "cross-env ANALYZE=true NODE_ENV=production webpack --progress --mode production",
|
||||||
"lint": "eslint src/**/*.{js,jsx}",
|
"lint": "eslint src/**/*.{js,jsx}",
|
||||||
"prerender": "node prerender.cjs",
|
"prerender": "node prerender.cjs",
|
||||||
"prerender:prod": "cross-env NODE_ENV=production 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",
|
"build:prerender": "npm run build:client && npm run prerender:prod",
|
||||||
"translate": "node translate-i18n.js",
|
"translate": "node translate-i18n.js",
|
||||||
"translate:english": "node translate-i18n.js --only-english",
|
"translate:english": "node translate-i18n.js --only-english",
|
||||||
"translate:skip-english": "node translate-i18n.js --skip-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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -30,6 +33,7 @@
|
|||||||
"@mui/material": "^7.1.1",
|
"@mui/material": "^7.1.1",
|
||||||
"@stripe/react-stripe-js": "^3.7.0",
|
"@stripe/react-stripe-js": "^3.7.0",
|
||||||
"@stripe/stripe-js": "^7.3.1",
|
"@stripe/stripe-js": "^7.3.1",
|
||||||
|
"async-mutex": "^0.5.0",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
"country-flag-icons": "^1.5.19",
|
"country-flag-icons": "^1.5.19",
|
||||||
"html-react-parser": "^5.2.5",
|
"html-react-parser": "^5.2.5",
|
||||||
@@ -41,6 +45,7 @@
|
|||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-i18next": "^15.6.0",
|
"react-i18next": "^15.6.0",
|
||||||
"react-router-dom": "^7.6.2",
|
"react-router-dom": "^7.6.2",
|
||||||
|
"sanitize-html": "^2.17.0",
|
||||||
"sharp": "^0.34.2",
|
"sharp": "^0.34.2",
|
||||||
"socket.io-client": "^4.7.5"
|
"socket.io-client": "^4.7.5"
|
||||||
},
|
},
|
||||||
@@ -73,6 +78,8 @@
|
|||||||
"webpack-bundle-analyzer": "^4.10.2",
|
"webpack-bundle-analyzer": "^4.10.2",
|
||||||
"webpack-cli": "^6.0.1",
|
"webpack-cli": "^6.0.1",
|
||||||
"webpack-dev-server": "^5.2.2",
|
"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);
|
||||||
|
});
|
||||||
179
prerender.cjs
@@ -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
|
// Import modules
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
@@ -103,7 +121,6 @@ const shopConfig = require("./src/config.js").default;
|
|||||||
const { renderPage } = require("./prerender/renderer.cjs");
|
const { renderPage } = require("./prerender/renderer.cjs");
|
||||||
const {
|
const {
|
||||||
collectAllCategories,
|
collectAllCategories,
|
||||||
writeCombinedCssFile,
|
|
||||||
} = require("./prerender/utils.cjs");
|
} = require("./prerender/utils.cjs");
|
||||||
const {
|
const {
|
||||||
generateProductMetaTags,
|
generateProductMetaTags,
|
||||||
@@ -119,6 +136,7 @@ const {
|
|||||||
generateLlmsTxt,
|
generateLlmsTxt,
|
||||||
generateCategoryLlmsTxt,
|
generateCategoryLlmsTxt,
|
||||||
generateAllCategoryLlmsPages,
|
generateAllCategoryLlmsPages,
|
||||||
|
generateCategoryProductList,
|
||||||
} = require("./prerender/seo.cjs");
|
} = require("./prerender/seo.cjs");
|
||||||
const {
|
const {
|
||||||
fetchCategoryProducts,
|
fetchCategoryProducts,
|
||||||
@@ -141,6 +159,7 @@ const Batteriegesetzhinweise =
|
|||||||
const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default;
|
const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default;
|
||||||
const Sitemap = require("./src/pages/Sitemap.js").default;
|
const Sitemap = require("./src/pages/Sitemap.js").default;
|
||||||
const PrerenderSitemap = require("./src/PrerenderSitemap.js").default;
|
const PrerenderSitemap = require("./src/PrerenderSitemap.js").default;
|
||||||
|
const PrerenderCategoriesPage = require("./src/PrerenderCategoriesPage.js").default;
|
||||||
const AGB = require("./src/pages/AGB.js").default;
|
const AGB = require("./src/pages/AGB.js").default;
|
||||||
const NotFound404 = require("./src/pages/NotFound404.js").default;
|
const NotFound404 = require("./src/pages/NotFound404.js").default;
|
||||||
|
|
||||||
@@ -149,7 +168,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
|||||||
const socketUrl = "http://127.0.0.1:9303";
|
const socketUrl = "http://127.0.0.1:9303";
|
||||||
const workerSocket = io(socketUrl, {
|
const workerSocket = io(socketUrl, {
|
||||||
path: "/socket.io/",
|
path: "/socket.io/",
|
||||||
transports: ["polling", "websocket"],
|
transports: ["websocket"],
|
||||||
reconnection: false,
|
reconnection: false,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
@@ -171,10 +190,11 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const productDetails = await fetchProductDetails(workerSocket, productSeoName);
|
const productDetails = await fetchProductDetails(workerSocket, productSeoName);
|
||||||
|
|
||||||
const actualSeoName = productDetails.product.seoName || productSeoName;
|
const actualSeoName = productDetails.product.seoName || productSeoName;
|
||||||
const productComponent = React.createElement(PrerenderProduct, {
|
const productComponent = React.createElement(PrerenderProduct, {
|
||||||
productData: productDetails,
|
productData: productDetails,
|
||||||
|
t: global.i18n.t.bind(global.i18n),
|
||||||
});
|
});
|
||||||
|
|
||||||
const filename = `Artikel/${actualSeoName}`;
|
const filename = `Artikel/${actualSeoName}`;
|
||||||
@@ -186,7 +206,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
|||||||
}, shopConfig.baseUrl, shopConfig);
|
}, shopConfig.baseUrl, shopConfig);
|
||||||
// Get category info from categoryMap if available
|
// Get category info from categoryMap if available
|
||||||
const categoryInfo = productDetails.product.categoryId ? categoryMap[productDetails.product.categoryId] : null;
|
const categoryInfo = productDetails.product.categoryId ? categoryMap[productDetails.product.categoryId] : null;
|
||||||
|
|
||||||
const jsonLdScript = generateProductJsonLd({
|
const jsonLdScript = generateProductJsonLd({
|
||||||
...productDetails.product,
|
...productDetails.product,
|
||||||
seoName: actualSeoName,
|
seoName: actualSeoName,
|
||||||
@@ -201,7 +221,8 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
|||||||
combinedMetaTags,
|
combinedMetaTags,
|
||||||
true,
|
true,
|
||||||
config,
|
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) {
|
if (success) {
|
||||||
@@ -214,9 +235,9 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
|||||||
success,
|
success,
|
||||||
workerId
|
workerId
|
||||||
};
|
};
|
||||||
|
|
||||||
results.push(result);
|
results.push(result);
|
||||||
|
|
||||||
// Call progress callback if provided
|
// Call progress callback if provided
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback(result);
|
progressCallback(result);
|
||||||
@@ -232,14 +253,14 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
|||||||
error: error.message,
|
error: error.message,
|
||||||
workerId
|
workerId
|
||||||
};
|
};
|
||||||
|
|
||||||
results.push(result);
|
results.push(result);
|
||||||
|
|
||||||
// Call progress callback if provided
|
// Call progress callback if provided
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback(result);
|
progressCallback(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(processNextProduct, 25);
|
setTimeout(processNextProduct, 25);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -271,16 +292,16 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
|
|||||||
const barLength = 30;
|
const barLength = 30;
|
||||||
const filledLength = Math.round((barLength * current) / total);
|
const filledLength = Math.round((barLength * current) / total);
|
||||||
const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength);
|
const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength);
|
||||||
|
|
||||||
// @note Single line progress update to prevent flickering
|
// @note Single line progress update to prevent flickering
|
||||||
const truncatedName = productName ? ` - ${productName.substring(0, 25)}${productName.length > 25 ? '...' : ''}` : '';
|
const truncatedName = productName ? ` - ${productName.substring(0, 25)}${productName.length > 25 ? '...' : ''}` : '';
|
||||||
|
|
||||||
// Build worker stats on one line
|
// Build worker stats on one line
|
||||||
let workerStats = '';
|
let workerStats = '';
|
||||||
for (let i = 0; i < Math.min(maxWorkers, 8); i++) { // Limit to 8 workers to fit on screen
|
for (let i = 0; i < Math.min(maxWorkers, 8); i++) { // Limit to 8 workers to fit on screen
|
||||||
workerStats += `W${i + 1}:${workerCounts[i]}/${workerSuccess[i]} `;
|
workerStats += `W${i + 1}:${workerCounts[i]}/${workerSuccess[i]} `;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single line update without complex cursor movements
|
// Single line update without complex cursor movements
|
||||||
process.stdout.write(`\r [${bar}] ${percentage}% (${current}/${total})${truncatedName}\n ${workerStats}${current < total ? '\x1b[1A' : '\n'}`);
|
process.stdout.write(`\r [${bar}] ${percentage}% (${current}/${total})${truncatedName}\n ${workerStats}${current < total ? '\x1b[1A' : '\n'}`);
|
||||||
};
|
};
|
||||||
@@ -288,26 +309,26 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
|
|||||||
// Split products among workers
|
// Split products among workers
|
||||||
const productsPerWorker = Math.ceil(allProductsArray.length / maxWorkers);
|
const productsPerWorker = Math.ceil(allProductsArray.length / maxWorkers);
|
||||||
const workerPromises = [];
|
const workerPromises = [];
|
||||||
|
|
||||||
// Initial progress bar
|
// Initial progress bar
|
||||||
updateProgressBar(0, totalProducts);
|
updateProgressBar(0, totalProducts);
|
||||||
|
|
||||||
for (let i = 0; i < maxWorkers; i++) {
|
for (let i = 0; i < maxWorkers; i++) {
|
||||||
const start = i * productsPerWorker;
|
const start = i * productsPerWorker;
|
||||||
const end = Math.min(start + productsPerWorker, allProductsArray.length);
|
const end = Math.min(start + productsPerWorker, allProductsArray.length);
|
||||||
const productsForWorker = allProductsArray.slice(start, end);
|
const productsForWorker = allProductsArray.slice(start, end);
|
||||||
|
|
||||||
if (productsForWorker.length > 0) {
|
if (productsForWorker.length > 0) {
|
||||||
const promise = renderProductWorker(productsForWorker, i + 1, (result) => {
|
const promise = renderProductWorker(productsForWorker, i + 1, (result) => {
|
||||||
// Progress callback - called each time a product is completed
|
// Progress callback - called each time a product is completed
|
||||||
completedProducts++;
|
completedProducts++;
|
||||||
progressResults.push(result);
|
progressResults.push(result);
|
||||||
lastProductName = result.productName;
|
lastProductName = result.productName;
|
||||||
|
|
||||||
// Update per-worker counters
|
// Update per-worker counters
|
||||||
const workerIndex = result.workerId - 1; // Convert to 0-based index
|
const workerIndex = result.workerId - 1; // Convert to 0-based index
|
||||||
workerCounts[workerIndex]++;
|
workerCounts[workerIndex]++;
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
totalSuccessCount++;
|
totalSuccessCount++;
|
||||||
workerSuccess[workerIndex]++;
|
workerSuccess[workerIndex]++;
|
||||||
@@ -315,11 +336,11 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
|
|||||||
// Don't log errors immediately to avoid interfering with progress bar
|
// Don't log errors immediately to avoid interfering with progress bar
|
||||||
// Errors will be shown after completion
|
// Errors will be shown after completion
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update progress bar with worker stats
|
// Update progress bar with worker stats
|
||||||
updateProgressBar(completedProducts, totalProducts, lastProductName);
|
updateProgressBar(completedProducts, totalProducts, lastProductName);
|
||||||
}, categoryMap);
|
}, categoryMap);
|
||||||
|
|
||||||
workerPromises.push(promise);
|
workerPromises.push(promise);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,10 +348,10 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
|
|||||||
try {
|
try {
|
||||||
// Wait for all workers to complete
|
// Wait for all workers to complete
|
||||||
await Promise.all(workerPromises);
|
await Promise.all(workerPromises);
|
||||||
|
|
||||||
// Ensure final progress update
|
// Ensure final progress update
|
||||||
updateProgressBar(totalProducts, totalProducts, lastProductName);
|
updateProgressBar(totalProducts, totalProducts, lastProductName);
|
||||||
|
|
||||||
// Show any errors that occurred
|
// Show any errors that occurred
|
||||||
const errorResults = progressResults.filter(r => !r.success && r.error);
|
const errorResults = progressResults.filter(r => !r.success && r.error);
|
||||||
if (errorResults.length > 0) {
|
if (errorResults.length > 0) {
|
||||||
@@ -339,7 +360,7 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
|
|||||||
console.log(` - ${result.productSeoName}: ${result.error}`);
|
console.log(` - ${result.productSeoName}: ${result.error}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalSuccessCount;
|
return totalSuccessCount;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in parallel rendering:', error);
|
console.error('Error in parallel rendering:', error);
|
||||||
@@ -349,14 +370,14 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
|
|||||||
|
|
||||||
const renderApp = async (categoryData, socket) => {
|
const renderApp = async (categoryData, socket) => {
|
||||||
if (categoryData) {
|
if (categoryData) {
|
||||||
global.window.productCache = {
|
global.window.categoryCache = {
|
||||||
categoryTree_209: { categoryTree: categoryData, timestamp: Date.now() },
|
"209_de": categoryData,
|
||||||
};
|
};
|
||||||
// @note Make cache available to components during rendering
|
// @note Make cache available to components during rendering
|
||||||
global.productCache = global.window.productCache;
|
global.categoryCache = global.window.categoryCache;
|
||||||
} else {
|
} else {
|
||||||
global.window.productCache = {};
|
global.window.categoryCache = {};
|
||||||
global.productCache = {};
|
global.categoryCache = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to call renderPage with config
|
// Helper to call renderPage with config
|
||||||
@@ -402,6 +423,14 @@ const renderApp = async (categoryData, socket) => {
|
|||||||
process.exit(1);
|
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
|
// Render static pages
|
||||||
console.log("\n📄 Rendering static pages...");
|
console.log("\n📄 Rendering static pages...");
|
||||||
|
|
||||||
@@ -437,6 +466,13 @@ const renderApp = async (categoryData, socket) => {
|
|||||||
description: "Sitemap page",
|
description: "Sitemap page",
|
||||||
needsCategoryData: true,
|
needsCategoryData: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
component: PrerenderCategoriesPage,
|
||||||
|
path: "/Kategorien",
|
||||||
|
filename: "Kategorien",
|
||||||
|
description: "Categories page",
|
||||||
|
needsCategoryData: true,
|
||||||
|
},
|
||||||
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
|
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
|
||||||
{ component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" },
|
{ component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" },
|
||||||
{
|
{
|
||||||
@@ -506,7 +542,14 @@ const renderApp = async (categoryData, socket) => {
|
|||||||
let categoryPagesRendered = 0;
|
let categoryPagesRendered = 0;
|
||||||
let categoriesWithProducts = 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
|
// Skip categories without seoName
|
||||||
if (!category.seoName) {
|
if (!category.seoName) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -524,8 +567,7 @@ const renderApp = async (categoryData, socket) => {
|
|||||||
try {
|
try {
|
||||||
productData = await fetchCategoryProducts(socket, category.id);
|
productData = await fetchCategoryProducts(socket, category.id);
|
||||||
console.log(
|
console.log(
|
||||||
` ✅ Found ${
|
` ✅ Found ${productData.products ? productData.products.length : 0
|
||||||
productData.products ? productData.products.length : 0
|
|
||||||
} products`
|
} products`
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -609,7 +651,7 @@ const renderApp = async (categoryData, socket) => {
|
|||||||
const totalProducts = allProducts.size;
|
const totalProducts = allProducts.size;
|
||||||
const numCPUs = os.cpus().length;
|
const numCPUs = os.cpus().length;
|
||||||
const maxWorkers = Math.min(numCPUs, totalProducts, 8); // Cap at 8 workers to avoid overwhelming the server
|
const maxWorkers = Math.min(numCPUs, totalProducts, 8); // Cap at 8 workers to avoid overwhelming the server
|
||||||
|
|
||||||
// Create category map for breadcrumbs
|
// Create category map for breadcrumbs
|
||||||
const categoryMap = {};
|
const categoryMap = {};
|
||||||
allCategories.forEach(category => {
|
allCategories.forEach(category => {
|
||||||
@@ -618,11 +660,11 @@ const renderApp = async (categoryData, socket) => {
|
|||||||
seoName: category.seoName
|
seoName: category.seoName
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`\n📦 Rendering ${totalProducts} individual product pages using ${maxWorkers} parallel workers...`
|
`\n📦 Rendering ${totalProducts} individual product pages using ${maxWorkers} parallel workers...`
|
||||||
);
|
);
|
||||||
|
|
||||||
const productPagesRendered = await renderProductsInParallel(
|
const productPagesRendered = await renderProductsInParallel(
|
||||||
Array.from(allProducts),
|
Array.from(allProducts),
|
||||||
maxWorkers,
|
maxWorkers,
|
||||||
@@ -640,8 +682,7 @@ const renderApp = async (categoryData, socket) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the combined CSS file after all pages are rendered
|
// No longer writing combined CSS file - each page has its own embedded CSS
|
||||||
writeCombinedCssFile(config.globalCssCollection, config.outputDir);
|
|
||||||
|
|
||||||
// Generate XML sitemap with all rendered pages
|
// Generate XML sitemap with all rendered pages
|
||||||
console.log("\n🗺️ Generating XML sitemap...");
|
console.log("\n🗺️ Generating XML sitemap...");
|
||||||
@@ -675,21 +716,21 @@ const renderApp = async (categoryData, socket) => {
|
|||||||
// Generate products.xml (Google Shopping feed) in parallel to sitemap.xml
|
// Generate products.xml (Google Shopping feed) in parallel to sitemap.xml
|
||||||
if (allProductsData.length > 0) {
|
if (allProductsData.length > 0) {
|
||||||
console.log("\n🛒 Generating products.xml (Google Shopping feed)...");
|
console.log("\n🛒 Generating products.xml (Google Shopping feed)...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const productsXml = generateProductsXml(allProductsData, shopConfig.baseUrl, shopConfig);
|
const productsXml = generateProductsXml(allProductsData, shopConfig.baseUrl, shopConfig);
|
||||||
|
|
||||||
const productsXmlPath = path.resolve(__dirname, config.outputDir, "products.xml");
|
const productsXmlPath = path.resolve(__dirname, config.outputDir, "products.xml");
|
||||||
|
|
||||||
// Write with explicit UTF-8 encoding
|
// Write with explicit UTF-8 encoding
|
||||||
fs.writeFileSync(productsXmlPath, productsXml, { encoding: 'utf8' });
|
fs.writeFileSync(productsXmlPath, productsXml, { encoding: 'utf8' });
|
||||||
|
|
||||||
console.log(`✅ products.xml generated: ${productsXmlPath}`);
|
console.log(`✅ products.xml generated: ${productsXmlPath}`);
|
||||||
console.log(` - Products included: ${allProductsData.length}`);
|
console.log(` - Products included: ${allProductsData.length}`);
|
||||||
console.log(` - Format: Google Shopping RSS 2.0 feed`);
|
console.log(` - Format: Google Shopping RSS 2.0 feed`);
|
||||||
console.log(` - Encoding: UTF-8`);
|
console.log(` - Encoding: UTF-8`);
|
||||||
console.log(` - Includes: title, description, price, availability, images`);
|
console.log(` - Includes: title, description, price, availability, images`);
|
||||||
|
|
||||||
// Verify the file is valid UTF-8
|
// Verify the file is valid UTF-8
|
||||||
try {
|
try {
|
||||||
const verification = fs.readFileSync(productsXmlPath, 'utf8');
|
const verification = fs.readFileSync(productsXmlPath, 'utf8');
|
||||||
@@ -697,7 +738,27 @@ const renderApp = async (categoryData, socket) => {
|
|||||||
} catch (verifyError) {
|
} catch (verifyError) {
|
||||||
console.log(` - File verification: ⚠️ ${verifyError.message}`);
|
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) {
|
} catch (error) {
|
||||||
console.error(`❌ Error generating products.xml: ${error.message}`);
|
console.error(`❌ Error generating products.xml: ${error.message}`);
|
||||||
console.log("\n⚠️ Skipping products.xml generation due to errors");
|
console.log("\n⚠️ Skipping products.xml generation due to errors");
|
||||||
@@ -708,18 +769,18 @@ const renderApp = async (categoryData, socket) => {
|
|||||||
|
|
||||||
// Generate llms.txt (LLM-friendly markdown sitemap) and category-specific files
|
// Generate llms.txt (LLM-friendly markdown sitemap) and category-specific files
|
||||||
console.log("\n🤖 Generating LLM sitemap files...");
|
console.log("\n🤖 Generating LLM sitemap files...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate main llms.txt overview file
|
// Generate main llms.txt overview file
|
||||||
const llmsTxt = generateLlmsTxt(allCategories, allProductsData, shopConfig.baseUrl, shopConfig);
|
const llmsTxt = generateLlmsTxt(allCategories, allProductsData, shopConfig.baseUrl, shopConfig);
|
||||||
const llmsTxtPath = path.resolve(__dirname, config.outputDir, "llms.txt");
|
const llmsTxtPath = path.resolve(__dirname, config.outputDir, "llms.txt");
|
||||||
fs.writeFileSync(llmsTxtPath, llmsTxt, { encoding: 'utf8' });
|
fs.writeFileSync(llmsTxtPath, llmsTxt, { encoding: 'utf8' });
|
||||||
|
|
||||||
console.log(`✅ Main llms.txt generated: ${llmsTxtPath}`);
|
console.log(`✅ Main llms.txt generated: ${llmsTxtPath}`);
|
||||||
console.log(` - Static pages: 8 pages`);
|
console.log(` - Static pages: 8 pages`);
|
||||||
console.log(` - Categories: ${allCategories.length} with links to detailed files`);
|
console.log(` - Categories: ${allCategories.length} with links to detailed files`);
|
||||||
console.log(` - File size: ${Math.round(llmsTxt.length / 1024)}KB`);
|
console.log(` - File size: ${Math.round(llmsTxt.length / 1024)}KB`);
|
||||||
|
|
||||||
// Group products by category for category-specific files
|
// Group products by category for category-specific files
|
||||||
const productsByCategory = {};
|
const productsByCategory = {};
|
||||||
allProductsData.forEach((product) => {
|
allProductsData.forEach((product) => {
|
||||||
@@ -729,47 +790,53 @@ const renderApp = async (categoryData, socket) => {
|
|||||||
}
|
}
|
||||||
productsByCategory[categoryId].push(product);
|
productsByCategory[categoryId].push(product);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate category-specific LLM files with pagination
|
// Generate category-specific LLM files with pagination
|
||||||
let categoryFilesGenerated = 0;
|
let categoryFilesGenerated = 0;
|
||||||
let totalCategoryProducts = 0;
|
let totalCategoryProducts = 0;
|
||||||
let totalPaginatedFiles = 0;
|
let totalPaginatedFiles = 0;
|
||||||
|
|
||||||
for (const category of allCategories) {
|
for (const category of allCategories) {
|
||||||
if (category.seoName) {
|
if (category.seoName) {
|
||||||
const categoryProducts = productsByCategory[category.id] || [];
|
const categoryProducts = productsByCategory[category.id] || [];
|
||||||
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||||
|
|
||||||
// Generate all paginated files for this category
|
// Generate all paginated files for this category
|
||||||
const categoryPages = generateAllCategoryLlmsPages(category, categoryProducts, shopConfig.baseUrl, shopConfig);
|
const categoryPages = generateAllCategoryLlmsPages(category, categoryProducts, shopConfig.baseUrl, shopConfig);
|
||||||
|
|
||||||
// Write each paginated file
|
// Write each paginated file
|
||||||
for (const page of categoryPages) {
|
for (const page of categoryPages) {
|
||||||
const pagePath = path.resolve(__dirname, config.outputDir, page.fileName);
|
const pagePath = path.resolve(__dirname, config.outputDir, page.fileName);
|
||||||
fs.writeFileSync(pagePath, page.content, { encoding: 'utf8' });
|
fs.writeFileSync(pagePath, page.content, { encoding: 'utf8' });
|
||||||
totalPaginatedFiles++;
|
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 pageCount = categoryPages.length;
|
||||||
const totalSize = categoryPages.reduce((sum, page) => sum + page.content.length, 0);
|
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(` ✅ 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++;
|
categoryFilesGenerated++;
|
||||||
totalCategoryProducts += categoryProducts.length;
|
totalCategoryProducts += categoryProducts.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(` 📄 Total paginated files generated: ${totalPaginatedFiles}`);
|
console.log(` 📄 Total paginated files generated: ${totalPaginatedFiles}`);
|
||||||
console.log(` 📦 Total products across all categories: ${totalCategoryProducts}`);
|
console.log(` 📦 Total products across all categories: ${totalCategoryProducts}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const verification = fs.readFileSync(llmsTxtPath, 'utf8');
|
const verification = fs.readFileSync(llmsTxtPath, 'utf8');
|
||||||
console.log(` - File verification: ✅ All files valid UTF-8`);
|
console.log(` - File verification: ✅ All files valid UTF-8`);
|
||||||
} catch (verifyError) {
|
} catch (verifyError) {
|
||||||
console.log(` - File verification: ⚠️ ${verifyError.message}`);
|
console.log(` - File verification: ⚠️ ${verifyError.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ Error generating LLM sitemap files: ${error.message}`);
|
console.error(`❌ Error generating LLM sitemap files: ${error.message}`);
|
||||||
console.log("\n⚠️ Skipping LLM sitemap generation due to errors");
|
console.log("\n⚠️ Skipping LLM sitemap generation due to errors");
|
||||||
@@ -789,7 +856,7 @@ const fetchCategoryDataAndRender = () => {
|
|||||||
|
|
||||||
const socket = io(socketUrl, {
|
const socket = io(socketUrl, {
|
||||||
path: "/socket.io/",
|
path: "/socket.io/",
|
||||||
transports: ["polling", "websocket"], // Using polling first is more robust
|
transports: ["websocket"],
|
||||||
reconnection: false,
|
reconnection: false,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,10 +50,18 @@ const getWebpackEntrypoints = () => {
|
|||||||
return entrypoints;
|
return entrypoints;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Read global CSS styles and fix font paths for prerender
|
// Read global CSS styles - use webpack processed CSS in production, raw CSS in development
|
||||||
let globalCss = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'index.css'), 'utf8');
|
let globalCss = '';
|
||||||
// Fix relative font paths for prerendered HTML (remove ../public to make them relative to public root)
|
if (isProduction) {
|
||||||
globalCss = globalCss.replace(/url\('\.\.\/public/g, "url('");
|
// 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
|
// Global CSS collection
|
||||||
const globalCssCollection = new Set();
|
const globalCssCollection = new Set();
|
||||||
|
|||||||
@@ -37,9 +37,15 @@ const fetchCategoryProducts = (socket, categoryId) => {
|
|||||||
reject(new Error(`Timeout fetching products for category ${categoryId}`));
|
reject(new Error(`Timeout fetching products for category ${categoryId}`));
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
|
// Prerender system fetches German version by default
|
||||||
socket.emit(
|
socket.emit(
|
||||||
"getCategoryProducts",
|
"getCategoryProducts",
|
||||||
{ full:true, categoryId: parseInt(categoryId) },
|
{
|
||||||
|
full: true,
|
||||||
|
categoryId: categoryId === "neu" ? "neu" : parseInt(categoryId),
|
||||||
|
language: 'de',
|
||||||
|
requestTranslation: false
|
||||||
|
},
|
||||||
(response) => {
|
(response) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
if (response && response.products !== undefined) {
|
if (response && response.products !== undefined) {
|
||||||
@@ -68,7 +74,13 @@ const fetchProductDetails = (socket, productSeoName) => {
|
|||||||
);
|
);
|
||||||
}, 5000);
|
}, 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);
|
clearTimeout(timeout);
|
||||||
if (response && response.product) {
|
if (response && response.product) {
|
||||||
response.product.seoName = productSeoName;
|
response.product.seoName = productSeoName;
|
||||||
@@ -140,7 +152,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
|||||||
"public",
|
"public",
|
||||||
"assets",
|
"assets",
|
||||||
"images",
|
"images",
|
||||||
"sh.png"
|
"sh.avif"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ensure assets/images directory exists
|
// Ensure assets/images directory exists
|
||||||
@@ -173,7 +185,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
|||||||
if (imageIds.length > 0) {
|
if (imageIds.length > 0) {
|
||||||
// Process first image for each product
|
// Process first image for each product
|
||||||
const bildId = parseInt(imageIds[0]);
|
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);
|
const imagePath = path.join(assetsPath, estimatedFilename);
|
||||||
|
|
||||||
@@ -187,7 +199,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
|||||||
const imageBuffer = await fetchProductImage(socket, bildId);
|
const imageBuffer = await fetchProductImage(socket, bildId);
|
||||||
|
|
||||||
// If overlay exists, apply it to the image
|
// If overlay exists, apply it to the image
|
||||||
if (fs.existsSync(overlayPath)) {
|
if (false && fs.existsSync(overlayPath)) {
|
||||||
try {
|
try {
|
||||||
// Get image dimensions to center the overlay
|
// Get image dimensions to center the overlay
|
||||||
const baseImage = sharp(Buffer.from(imageBuffer));
|
const baseImage = sharp(Buffer.from(imageBuffer));
|
||||||
@@ -219,12 +231,12 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
|||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.jpeg() // Ensure output is JPEG
|
.avif() // Ensure output is AVIF
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
fs.writeFileSync(imagePath, processedImageBuffer);
|
fs.writeFileSync(imagePath, processedImageBuffer);
|
||||||
console.log(
|
console.log(
|
||||||
` ✅ Applied centered inverted sh.png overlay to ${estimatedFilename}`
|
` ✅ Applied centered inverted sh.avif overlay to ${estimatedFilename}`
|
||||||
);
|
);
|
||||||
} catch (overlayError) {
|
} catch (overlayError) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -269,7 +281,7 @@ const saveCategoryImages = async (socket, categories, outputDir) => {
|
|||||||
// Debug: Log categories that will be processed
|
// Debug: Log categories that will be processed
|
||||||
console.log(" 🔍 Categories to process:");
|
console.log(" 🔍 Categories to process:");
|
||||||
categories.forEach((cat, index) => {
|
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(
|
const assetsPath = path.resolve(
|
||||||
@@ -296,7 +308,7 @@ const saveCategoryImages = async (socket, categories, outputDir) => {
|
|||||||
for (const category of categories) {
|
for (const category of categories) {
|
||||||
categoriesProcessed++;
|
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);
|
const imagePath = path.join(assetsPath, estimatedFilename);
|
||||||
|
|
||||||
// Skip if image already exists
|
// Skip if image already exists
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ const renderPage = (
|
|||||||
metaTags = "",
|
metaTags = "",
|
||||||
needsRouter = false,
|
needsRouter = false,
|
||||||
config,
|
config,
|
||||||
suppressLogs = false
|
suppressLogs = false,
|
||||||
|
productData = null
|
||||||
) => {
|
) => {
|
||||||
const {
|
const {
|
||||||
isProduction,
|
isProduction,
|
||||||
@@ -26,7 +27,7 @@ const renderPage = (
|
|||||||
globalCssCollection,
|
globalCssCollection,
|
||||||
webpackEntrypoints,
|
webpackEntrypoints,
|
||||||
} = config;
|
} = config;
|
||||||
const { writeCombinedCssFile, optimizeCss } = require("./utils.cjs");
|
const { optimizeCss } = require("./utils.cjs");
|
||||||
|
|
||||||
// @note Set prerender fallback flag in global environment for CategoryBox during SSR
|
// @note Set prerender fallback flag in global environment for CategoryBox during SSR
|
||||||
if (typeof global !== "undefined" && global.window) {
|
if (typeof global !== "undefined" && global.window) {
|
||||||
@@ -51,26 +52,20 @@ const renderPage = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
let renderedMarkup;
|
let renderedMarkup;
|
||||||
|
let pageSpecificCss = ""; // Declare outside try block for broader scope
|
||||||
|
|
||||||
try {
|
try {
|
||||||
renderedMarkup = ReactDOMServer.renderToString(pageElement);
|
renderedMarkup = ReactDOMServer.renderToString(pageElement);
|
||||||
const emotionChunks = extractCriticalToChunks(renderedMarkup);
|
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) {
|
if (emotionChunks.styles.length > 0) {
|
||||||
const oldSize = globalCssCollection.size;
|
|
||||||
|
|
||||||
emotionChunks.styles.forEach((style) => {
|
emotionChunks.styles.forEach((style) => {
|
||||||
if (style.css) {
|
if (style.css) {
|
||||||
globalCssCollection.add(style.css);
|
pageSpecificCss += style.css + "\n";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (!suppressLogs) console.log(` - CSS rules: ${emotionChunks.styles.length}`);
|
||||||
// Check if new styles were added
|
|
||||||
if (globalCssCollection.size > oldSize) {
|
|
||||||
// Write CSS file immediately when new styles are added
|
|
||||||
writeCombinedCssFile(globalCssCollection, outputDir);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ Rendering failed for ${filename}:`, error);
|
console.error(`❌ Rendering failed for ${filename}:`, error);
|
||||||
@@ -126,26 +121,12 @@ const renderPage = (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Read and inline prerender CSS to eliminate render-blocking request
|
// Inline page-specific CSS directly (no shared prerender.css file)
|
||||||
try {
|
if (pageSpecificCss.trim()) {
|
||||||
const prerenderCssPath = path.resolve(__dirname, "..", outputDir, "prerender.css");
|
// Use advanced CSS optimization on page-specific CSS
|
||||||
if (fs.existsSync(prerenderCssPath)) {
|
const optimizedPageCss = optimizeCss(pageSpecificCss);
|
||||||
const prerenderCssContent = fs.readFileSync(prerenderCssPath, "utf8");
|
inlinedCss += optimizedPageCss;
|
||||||
// Use advanced CSS optimization
|
if (!suppressLogs) console.log(` ✅ Inlined page-specific CSS (${Math.round(optimizedPageCss.length / 1024)}KB)`);
|
||||||
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`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add JavaScript files
|
// Add JavaScript files
|
||||||
@@ -177,28 +158,52 @@ const renderPage = (
|
|||||||
const prerenderFallbackScript = `
|
const prerenderFallbackScript = `
|
||||||
<script>
|
<script>
|
||||||
// Save prerendered content to window object for SocketProvider fallback
|
// Save prerendered content to window object for SocketProvider fallback
|
||||||
window.__PRERENDER_FALLBACK__ = {
|
window.__PRERENDER_FALLBACK__ = {
|
||||||
path: '${location}',
|
path: '${location}',
|
||||||
content: ${JSON.stringify(renderedMarkup)},
|
content: ${JSON.stringify(renderedMarkup)},
|
||||||
timestamp: ${Date.now()}
|
timestamp: ${Date.now()}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// DEBUG: Multiple alerts throughout the loading process
|
||||||
|
// Debug alerts removed
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// @note Create script to populate window.productCache with ONLY the static category tree
|
// @note Create script to populate window.productCache with ONLY the static category tree
|
||||||
let productCacheScript = '';
|
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
|
// Only include the static categoryTree_209, not any dynamic data that gets added during rendering
|
||||||
const staticCache = {};
|
const staticCache = {};
|
||||||
if (global.window.productCache.categoryTree_209) {
|
if (global.window.categoryCache["209_de"]) {
|
||||||
staticCache.categoryTree_209 = global.window.productCache.categoryTree_209;
|
staticCache["209_de"] = global.window.categoryCache["209_de"];
|
||||||
}
|
}
|
||||||
|
|
||||||
const staticCacheData = JSON.stringify(staticCache);
|
const staticCacheData = JSON.stringify(staticCache);
|
||||||
productCacheScript = `
|
productCacheScript = `
|
||||||
<script>
|
<script>
|
||||||
// Populate window.productCache with static category tree only
|
// Populate window.categoryCache with static category tree only
|
||||||
window.productCache = ${staticCacheData};
|
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>
|
</script>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -214,7 +219,7 @@ const renderPage = (
|
|||||||
|
|
||||||
template = template.replace(
|
template = template.replace(
|
||||||
"</head>",
|
"</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>/;
|
const rootDivRegex = /<div id="root"[\s\S]*?>[\s\S]*?<\/div>/;
|
||||||
@@ -222,8 +227,10 @@ const renderPage = (
|
|||||||
|
|
||||||
let newHtml;
|
let newHtml;
|
||||||
if (rootDivRegex.test(template)) {
|
if (rootDivRegex.test(template)) {
|
||||||
|
if (!suppressLogs) console.log(` 📝 Root div found, replacing with ${renderedMarkup.length} chars of markup`);
|
||||||
newHtml = template.replace(rootDivRegex, replacementHtml);
|
newHtml = template.replace(rootDivRegex, replacementHtml);
|
||||||
} else {
|
} else {
|
||||||
|
if (!suppressLogs) console.log(` ⚠️ No root div found, appending to body`);
|
||||||
newHtml = template.replace("<body>", `<body>${replacementHtml}`);
|
newHtml = template.replace("<body>", `<body>${replacementHtml}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,10 +247,9 @@ const renderPage = (
|
|||||||
if (!suppressLogs) {
|
if (!suppressLogs) {
|
||||||
console.log(`✅ ${description} prerendered to ${outputPath}`);
|
console.log(`✅ ${description} prerendered to ${outputPath}`);
|
||||||
console.log(` - Markup length: ${renderedMarkup.length} characters`);
|
console.log(` - Markup length: ${renderedMarkup.length} characters`);
|
||||||
console.log(` - CSS rules: ${Object.keys(cache.inserted).length}`);
|
if (productDetailCacheScript) {
|
||||||
console.log(` - Total inlined CSS: ${Math.round(combinedCss.length / 1024)}KB`);
|
console.log(` - Product detail cache populated for SPA hydration`);
|
||||||
console.log(` - Render-blocking CSS eliminated: ${inlinedCss ? 'YES' : 'NO'}`);
|
}
|
||||||
console.log(` - Fallback content saved to window.__PRERENDER_FALLBACK__`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
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}`;
|
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 = {
|
const jsonLd = {
|
||||||
"@context": "https://schema.org/",
|
"@context": "https://schema.org/",
|
||||||
"@type": "CollectionPage",
|
"@type": "CollectionPage",
|
||||||
@@ -42,7 +55,7 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
|||||||
product.pictureList && product.pictureList.trim()
|
product.pictureList && product.pictureList.trim()
|
||||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||||
.split(",")[0]
|
.split(",")[0]
|
||||||
.trim()}.jpg`
|
.trim()}.avif`
|
||||||
: `${baseUrl}/assets/images/nopicture.jpg`,
|
: `${baseUrl}/assets/images/nopicture.jpg`,
|
||||||
description: product.description
|
description: product.description
|
||||||
? product.description.replace(/<[^>]*>/g, "").substring(0, 200)
|
? product.description.replace(/<[^>]*>/g, "").substring(0, 200)
|
||||||
@@ -57,6 +70,7 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
|||||||
url: `${baseUrl}/Artikel/${product.seoName}`,
|
url: `${baseUrl}/Artikel/${product.seoName}`,
|
||||||
price: product.price && !isNaN(product.price) ? product.price.toString() : "0.00",
|
price: product.price && !isNaN(product.price) ? product.price.toString() : "0.00",
|
||||||
priceCurrency: config.currency,
|
priceCurrency: config.currency,
|
||||||
|
priceValidUntil: priceValidUntil,
|
||||||
availability: product.available
|
availability: product.available
|
||||||
? "https://schema.org/InStock"
|
? "https://schema.org/InStock"
|
||||||
: "https://schema.org/OutOfStock",
|
: "https://schema.org/OutOfStock",
|
||||||
@@ -65,6 +79,41 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
|||||||
name: config.brandName,
|
name: config.brandName,
|
||||||
},
|
},
|
||||||
itemCondition: "https://schema.org/NewCondition",
|
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;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||||
const currentDate = new Date().toISOString();
|
const currentDate = new Date().toISOString();
|
||||||
|
|
||||||
@@ -119,6 +122,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
689: "543561", // Seeds (Saatgut)
|
689: "543561", // Seeds (Saatgut)
|
||||||
706: "543561", // Stecklinge (cuttings) – ebenfalls Pflanzen/Saatgut
|
706: "543561", // Stecklinge (cuttings) – ebenfalls Pflanzen/Saatgut
|
||||||
376: "2802", // Grow-Sets – Pflanzen- & Kräuteranbausets
|
376: "2802", // Grow-Sets – Pflanzen- & Kräuteranbausets
|
||||||
|
915: "2802", // Grow-Sets > Set-Zubehör – Pflanzen- & Kräuteranbausets
|
||||||
|
|
||||||
// Headshop & Accessories
|
// Headshop & Accessories
|
||||||
709: "4082", // Headshop – Rauchzubehör
|
709: "4082", // Headshop – Rauchzubehör
|
||||||
@@ -126,8 +130,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
714: "4082", // Headshop > Bongs > Zubehör – Rauchzubehör
|
714: "4082", // Headshop > Bongs > Zubehör – Rauchzubehör
|
||||||
748: "4082", // Headshop > Bongs > Köpfe – Rauchzubehör
|
748: "4082", // Headshop > Bongs > Köpfe – Rauchzubehör
|
||||||
749: "4082", // Headshop > Bongs > Chillums/Diffusoren/Kupplungen – 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
|
896: "3151", // Headshop > Vaporizer – Vaporizer
|
||||||
|
923: "4082", // Headshop > Papes & Blunts – Rauchzubehör
|
||||||
710: "5109", // Headshop > Grinder – Gewürzmühlen (Küchenhelfer)
|
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
|
// Measuring & Packaging
|
||||||
186: "5631", // Headshop > Wiegen & Verpacken – Aufbewahrung/Zubehör
|
186: "5631", // Headshop > Wiegen & Verpacken – Aufbewahrung/Zubehör
|
||||||
@@ -137,6 +146,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
407: "3561", // Headshop > Grove Bags – Aufbewahrungsbehälter
|
407: "3561", // Headshop > Grove Bags – Aufbewahrungsbehälter
|
||||||
449: "1496", // Headshop > Cliptütchen – Lebensmittelverpackungsmaterial
|
449: "1496", // Headshop > Cliptütchen – Lebensmittelverpackungsmaterial
|
||||||
539: "3110", // Headshop > Gläser & Dosen – Lebensmittelbehälter
|
539: "3110", // Headshop > Gläser & Dosen – Lebensmittelbehälter
|
||||||
|
920: "581", // Headshop > Räucherstäbchen – Raumdüfte (Home Fragrances)
|
||||||
|
|
||||||
// Lighting & Equipment
|
// Lighting & Equipment
|
||||||
694: "3006", // Lampen – Lampen (Beleuchtung)
|
694: "3006", // Lampen – Lampen (Beleuchtung)
|
||||||
@@ -223,7 +233,6 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
387: "543541", // Zubehör > Anbauzubehör > Literatur – Bücher
|
387: "543541", // Zubehör > Anbauzubehör > Literatur – Bücher
|
||||||
|
|
||||||
// General categories
|
// General categories
|
||||||
705: "2802", // Grow-Sets > Set-Konfigurator – (ebenfalls Pflanzen-Anbausets)
|
|
||||||
686: "1700", // Belüftung > Aktivkohlefilter > Zubehör – Ventilatoren
|
686: "1700", // Belüftung > Aktivkohlefilter > Zubehör – Ventilatoren
|
||||||
741: "1700", // Belüftung > Ab- und Zuluft > Zubehör – Ventilatoren
|
741: "1700", // Belüftung > Ab- und Zuluft > Zubehör – Ventilatoren
|
||||||
294: "3568", // Bewässerung > Zubehör – Bewässerungssysteme
|
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"?>
|
let productsXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss xmlns:g="http://base.google.com/ns/1.0" version="2.0">
|
<rss xmlns:g="http://base.google.com/ns/1.0" version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>${config.descriptions.short}</title>
|
<title>${config.descriptions.de.short}</title>
|
||||||
<link>${baseUrl}</link>
|
<link>${baseUrl}</link>
|
||||||
<description>${config.descriptions.short}</description>
|
<description>${config.descriptions.de.short}</description>
|
||||||
<lastBuildDate>${currentDate}</lastBuildDate>
|
<lastBuildDate>${currentDate}</lastBuildDate>
|
||||||
<language>de-DE</language>`;
|
<language>de-DE</language>`;
|
||||||
|
|
||||||
@@ -297,15 +306,43 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
let processedCount = 0;
|
let processedCount = 0;
|
||||||
let skippedCount = 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)
|
// 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
|
// Add each product as an item
|
||||||
allProductsData.forEach((product, index) => {
|
allProductsData.forEach((product, index) => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Skip products without essential data
|
// Skip products without essential data
|
||||||
if (!product || !product.seoName) {
|
if (!product || !product.seoName) {
|
||||||
skippedCount++;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,27 +350,168 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
const productCategoryId = product.categoryId || product.category_id || product.category || null;
|
const productCategoryId = product.categoryId || product.category_id || product.category || null;
|
||||||
if (productCategoryId && skipCategoryIds.includes(parseInt(productCategoryId))) {
|
if (productCategoryId && skipCategoryIds.includes(parseInt(productCategoryId))) {
|
||||||
skippedCount++;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip products without GTIN
|
// Skip products with excluded terms in title or description
|
||||||
|
const productTitle = (product.name || "").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'],
|
||||||
|
description: ['cannabis']
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check title for excluded terms
|
||||||
|
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
|
||||||
|
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()) {
|
if (!product.gtin || !product.gtin.toString().trim()) {
|
||||||
skippedCount++;
|
skippedCount++;
|
||||||
|
skipReasons.missingGTIN.count++;
|
||||||
|
skipReasons.missingGTIN.products.push({
|
||||||
|
id: product.articleNumber || product.seoName,
|
||||||
|
name: product.name || 'N/A',
|
||||||
|
url: `/Artikel/${product.seoName}`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate GTIN format and checksum
|
||||||
|
const gtinString = product.gtin.toString().trim();
|
||||||
|
|
||||||
|
// Helper function to validate GTIN with proper checksum validation
|
||||||
|
const isValidGTIN = (gtin) => {
|
||||||
|
if (!/^\d{8}$|^\d{12,14}$/.test(gtin)) return false; // Only 8, 12, 13, 14 digits allowed
|
||||||
|
|
||||||
|
const digits = gtin.split('').map(Number);
|
||||||
|
const length = digits.length;
|
||||||
|
let sum = 0;
|
||||||
|
|
||||||
|
if (length === 8) {
|
||||||
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip products without pictures
|
// Skip products without pictures
|
||||||
if (!product.pictureList || !product.pictureList.trim()) {
|
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++;
|
skippedCount++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean description for feed (remove HTML tags and limit length)
|
// Clean description for feed (remove HTML tags and limit length)
|
||||||
const rawDescription = product.description
|
const feedDescription = cleanTextContent(productDescription).substring(0, 500);
|
||||||
? cleanTextContent(product.description).substring(0, 500)
|
const cleanDescription = escapeXml(feedDescription) || "Produktbeschreibung nicht verfügbar";
|
||||||
: `${product.name || 'Product'} - Art.-Nr.: ${product.articleNumber || 'N/A'}`;
|
|
||||||
|
|
||||||
const cleanDescription = escapeXml(rawDescription) || "Produktbeschreibung nicht verfügbar";
|
|
||||||
|
|
||||||
// Clean product name
|
// Clean product name
|
||||||
const rawName = product.name || "Unnamed Product";
|
const rawName = product.name || "Unnamed Product";
|
||||||
@@ -342,6 +520,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
// Validate essential fields
|
// Validate essential fields
|
||||||
if (!cleanName || cleanName.length < 2) {
|
if (!cleanName || cleanName.length < 2) {
|
||||||
skippedCount++;
|
skippedCount++;
|
||||||
|
skipReasons.nameTooShort.count++;
|
||||||
|
skipReasons.nameTooShort.products.push({
|
||||||
|
id: product.articleNumber || product.seoName,
|
||||||
|
name: rawName,
|
||||||
|
cleanedName: cleanName,
|
||||||
|
url: `/Artikel/${product.seoName}`
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +535,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
|
|
||||||
// Generate image URL
|
// Generate image URL
|
||||||
const imageUrl = product.pictureList && product.pictureList.trim()
|
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`;
|
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||||
|
|
||||||
// Generate brand (manufacturer)
|
// Generate brand (manufacturer)
|
||||||
@@ -363,6 +548,18 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
// Generate availability
|
// Generate availability
|
||||||
const availability = product.available ? "in stock" : "out of stock";
|
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)
|
// Generate price (ensure it's a valid number)
|
||||||
const price = product.price && !isNaN(product.price)
|
const price = product.price && !isNaN(product.price)
|
||||||
? `${parseFloat(product.price).toFixed(2)} ${config.currency}`
|
? `${parseFloat(product.price).toFixed(2)} ${config.currency}`
|
||||||
@@ -371,11 +568,18 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
// Skip products with price == 0
|
// Skip products with price == 0
|
||||||
if (!product.price || parseFloat(product.price) === 0) {
|
if (!product.price || parseFloat(product.price) === 0) {
|
||||||
skippedCount++;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate GTIN/EAN if available
|
// Generate GTIN/EAN if available (use the already validated gtinString)
|
||||||
const gtin = product.gtin ? escapeXml(product.gtin.toString().trim()) : null;
|
const gtin = gtinString ? escapeXml(gtinString) : null;
|
||||||
|
|
||||||
// Generate product ID (using articleNumber or seoName)
|
// Generate product ID (using articleNumber or seoName)
|
||||||
const rawProductId = product.articleNumber || product.seoName || `product_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
|
const rawProductId = product.articleNumber || product.seoName || `product_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
|
||||||
@@ -386,7 +590,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
const googleCategory = getGoogleProductCategory(categoryId);
|
const googleCategory = getGoogleProductCategory(categoryId);
|
||||||
const escapedGoogleCategory = escapeXml(googleCategory);
|
const escapedGoogleCategory = escapeXml(googleCategory);
|
||||||
|
|
||||||
// Build item XML with proper formatting
|
// Build item XML with proper formatting (all validation passed, safe to write XML)
|
||||||
productsXml += `
|
productsXml += `
|
||||||
<item>
|
<item>
|
||||||
<g:id>${productId}</g:id>
|
<g:id>${productId}</g:id>
|
||||||
@@ -412,11 +616,11 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
<g:gtin>${gtin}</g:gtin>`;
|
<g:gtin>${gtin}</g:gtin>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add weight if available
|
// Add weight (we know it exists at this point since we validated it earlier)
|
||||||
if (product.weight && !isNaN(product.weight)) {
|
// Convert from kg to grams (multiply by 1000)
|
||||||
productsXml += `
|
const weightInGrams = parseFloat(product.weight) * 1000;
|
||||||
<g:shipping_weight>${parseFloat(product.weight).toFixed(2)} g</g:shipping_weight>`;
|
productsXml += `
|
||||||
}
|
<g:shipping_weight>${weightInGrams.toFixed(2)} g</g:shipping_weight>`;
|
||||||
|
|
||||||
// Add unit pricing data (required by German law for many products)
|
// Add unit pricing data (required by German law for many products)
|
||||||
const unitPricingData = determineUnitPricingData(product);
|
const unitPricingData = determineUnitPricingData(product);
|
||||||
@@ -437,6 +641,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
} catch (itemError) {
|
} catch (itemError) {
|
||||||
console.log(` ⚠️ Skipped product ${index + 1}: ${itemError.message}`);
|
console.log(` ⚠️ Skipped product ${index + 1}: ${itemError.message}`);
|
||||||
skippedCount++;
|
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'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -444,7 +655,133 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
</channel>
|
</channel>
|
||||||
</rss>`;
|
</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;
|
return productsXml;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const generateHomepageMetaTags = (baseUrl, config) => {
|
const generateHomepageMetaTags = (baseUrl, config) => {
|
||||||
const description = config.descriptions.long;
|
const description = config.descriptions.de.long;
|
||||||
const keywords = config.keywords;
|
const keywords = config.keywords.de;
|
||||||
const imageUrl = `${baseUrl}${config.images.logo}`;
|
const imageUrl = `${baseUrl}${config.images.logo}`;
|
||||||
|
|
||||||
// Ensure URLs are properly formatted
|
// Ensure URLs are properly formatted
|
||||||
@@ -12,7 +12,7 @@ const generateHomepageMetaTags = (baseUrl, config) => {
|
|||||||
<meta name="keywords" content="${keywords}">
|
<meta name="keywords" content="${keywords}">
|
||||||
|
|
||||||
<!-- Open Graph Meta Tags -->
|
<!-- 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:description" content="${description}">
|
||||||
<meta property="og:image" content="${imageUrl}">
|
<meta property="og:image" content="${imageUrl}">
|
||||||
<meta property="og:url" content="${canonicalUrl}">
|
<meta property="og:url" content="${canonicalUrl}">
|
||||||
@@ -21,7 +21,7 @@ const generateHomepageMetaTags = (baseUrl, config) => {
|
|||||||
|
|
||||||
<!-- Twitter Card Meta Tags -->
|
<!-- Twitter Card Meta Tags -->
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<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:description" content="${description}">
|
||||||
<meta name="twitter:image" content="${imageUrl}">
|
<meta name="twitter:image" content="${imageUrl}">
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
|
|||||||
"@type": "WebSite",
|
"@type": "WebSite",
|
||||||
name: config.brandName,
|
name: config.brandName,
|
||||||
url: canonicalUrl,
|
url: canonicalUrl,
|
||||||
description: config.descriptions.long,
|
description: config.descriptions.de.long,
|
||||||
publisher: {
|
publisher: {
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
name: config.brandName,
|
name: config.brandName,
|
||||||
@@ -73,7 +73,7 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
|
|||||||
"@type": "LocalBusiness",
|
"@type": "LocalBusiness",
|
||||||
"name": config.brandName,
|
"name": config.brandName,
|
||||||
"alternateName": config.siteName,
|
"alternateName": config.siteName,
|
||||||
"description": config.descriptions.long,
|
"description": config.descriptions.de.long,
|
||||||
"url": canonicalUrl,
|
"url": canonicalUrl,
|
||||||
"logo": logoUrl,
|
"logo": logoUrl,
|
||||||
"image": logoUrl,
|
"image": logoUrl,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const {
|
|||||||
generateLlmsTxt,
|
generateLlmsTxt,
|
||||||
generateCategoryLlmsTxt,
|
generateCategoryLlmsTxt,
|
||||||
generateAllCategoryLlmsPages,
|
generateAllCategoryLlmsPages,
|
||||||
|
generateCategoryProductList,
|
||||||
} = require('./llms.cjs');
|
} = require('./llms.cjs');
|
||||||
|
|
||||||
// Export all functions for use in the main application
|
// Export all functions for use in the main application
|
||||||
@@ -61,4 +62,5 @@ module.exports = {
|
|||||||
generateLlmsTxt,
|
generateLlmsTxt,
|
||||||
generateCategoryLlmsTxt,
|
generateCategoryLlmsTxt,
|
||||||
generateAllCategoryLlmsPages,
|
generateAllCategoryLlmsPages,
|
||||||
|
generateCategoryProductList,
|
||||||
};
|
};
|
||||||
@@ -55,29 +55,29 @@ GrowHeads.de is a German online shop and local store in Dresden specializing in
|
|||||||
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||||
const productsPerPage = 50;
|
const productsPerPage = 50;
|
||||||
const totalPages = Math.ceil(productCount / productsPerPage);
|
const totalPages = Math.ceil(productCount / productsPerPage);
|
||||||
|
|
||||||
llmsTxt += `#### ${category.name} (${productCount} products)`;
|
llmsTxt += `#### ${category.name} (${productCount} products)`;
|
||||||
|
|
||||||
if (totalPages > 1) {
|
if (totalPages > 1) {
|
||||||
llmsTxt += `
|
llmsTxt += `
|
||||||
- **Product Catalog**: ${totalPages} pages available
|
- **Product Catalog**: ${totalPages} pages available
|
||||||
- **Page 1**: ${baseUrl}/llms-${categorySlug}-page-1.txt (Products 1-${Math.min(productsPerPage, productCount)})`;
|
- **Page 1**: ${baseUrl}/llms-${categorySlug}-page-1.txt (Products 1-${Math.min(productsPerPage, productCount)})`;
|
||||||
|
|
||||||
if (totalPages > 2) {
|
if (totalPages > 2) {
|
||||||
llmsTxt += `
|
llmsTxt += `
|
||||||
- **Page 2**: ${baseUrl}/llms-${categorySlug}-page-2.txt (Products ${productsPerPage + 1}-${Math.min(productsPerPage * 2, productCount)})`;
|
- **Page 2**: ${baseUrl}/llms-${categorySlug}-page-2.txt (Products ${productsPerPage + 1}-${Math.min(productsPerPage * 2, productCount)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalPages > 3) {
|
if (totalPages > 3) {
|
||||||
llmsTxt += `
|
llmsTxt += `
|
||||||
- **...**: Additional pages available`;
|
- **...**: Additional pages available`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalPages > 2) {
|
if (totalPages > 2) {
|
||||||
llmsTxt += `
|
llmsTxt += `
|
||||||
- **Page ${totalPages}**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt (Products ${((totalPages - 1) * productsPerPage) + 1}-${productCount})`;
|
- **Page ${totalPages}**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt (Products ${((totalPages - 1) * productsPerPage) + 1}-${productCount})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
llmsTxt += `
|
llmsTxt += `
|
||||||
- **Access Pattern**: Replace "page-X" with desired page number (1-${totalPages})`;
|
- **Access Pattern**: Replace "page-X" with desired page number (1-${totalPages})`;
|
||||||
} else if (productCount > 0) {
|
} else if (productCount > 0) {
|
||||||
@@ -87,7 +87,7 @@ GrowHeads.de is a German online shop and local store in Dresden specializing in
|
|||||||
llmsTxt += `
|
llmsTxt += `
|
||||||
- **Product Catalog**: No products available`;
|
- **Product Catalog**: No products available`;
|
||||||
}
|
}
|
||||||
|
|
||||||
llmsTxt += `
|
llmsTxt += `
|
||||||
|
|
||||||
`;
|
`;
|
||||||
@@ -106,7 +106,7 @@ GrowHeads.de is a German online shop and local store in Dresden specializing in
|
|||||||
const generateCategoryLlmsTxt = (category, categoryProducts = [], baseUrl, config, pageNumber = 1, productsPerPage = 50) => {
|
const generateCategoryLlmsTxt = (category, categoryProducts = [], baseUrl, config, pageNumber = 1, productsPerPage = 50) => {
|
||||||
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
|
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
|
||||||
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||||
|
|
||||||
// Calculate pagination
|
// Calculate pagination
|
||||||
const totalProducts = categoryProducts.length;
|
const totalProducts = categoryProducts.length;
|
||||||
const totalPages = Math.ceil(totalProducts / productsPerPage);
|
const totalPages = Math.ceil(totalProducts / productsPerPage);
|
||||||
@@ -140,28 +140,28 @@ This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in
|
|||||||
|
|
||||||
**How to access other pages in this category:**
|
**How to access other pages in this category:**
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (pageNumber > 1) {
|
if (pageNumber > 1) {
|
||||||
categoryLlmsTxt += `- **Previous Page**: ${baseUrl}/llms-${categorySlug}-page-${pageNumber - 1}.txt
|
categoryLlmsTxt += `- **Previous Page**: ${baseUrl}/llms-${categorySlug}-page-${pageNumber - 1}.txt
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageNumber < totalPages) {
|
if (pageNumber < totalPages) {
|
||||||
categoryLlmsTxt += `- **Next Page**: ${baseUrl}/llms-${categorySlug}-page-${pageNumber + 1}.txt
|
categoryLlmsTxt += `- **Next Page**: ${baseUrl}/llms-${categorySlug}-page-${pageNumber + 1}.txt
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
categoryLlmsTxt += `- **First Page**: ${baseUrl}/llms-${categorySlug}-page-1.txt
|
categoryLlmsTxt += `- **First Page**: ${baseUrl}/llms-${categorySlug}-page-1.txt
|
||||||
- **Last Page**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt
|
- **Last Page**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt
|
||||||
|
|
||||||
**All pages in this category:**
|
**All pages in this category:**
|
||||||
`;
|
`;
|
||||||
|
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
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)})
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
categoryLlmsTxt += `
|
categoryLlmsTxt += `
|
||||||
|
|
||||||
`;
|
`;
|
||||||
@@ -173,17 +173,23 @@ This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in
|
|||||||
// Clean description for markdown (remove HTML tags and limit length)
|
// Clean description for markdown (remove HTML tags and limit length)
|
||||||
const cleanDescription = product.description
|
const cleanDescription = product.description
|
||||||
? product.description
|
? product.description
|
||||||
.replace(/<[^>]*>/g, "")
|
.replace(/<[^>]*>/g, "")
|
||||||
.replace(/\n/g, " ")
|
.replace(/\n/g, " ")
|
||||||
.trim()
|
.trim()
|
||||||
.substring(0, 300)
|
.substring(0, 300)
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const globalIndex = startIndex + index + 1;
|
const globalIndex = startIndex + index + 1;
|
||||||
categoryLlmsTxt += `## ${globalIndex}. ${product.name}
|
categoryLlmsTxt += `## ${globalIndex}. ${product.name}
|
||||||
|
|
||||||
- **Product URL**: ${baseUrl}/Artikel/${product.seoName}
|
- **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'}
|
- **Price**: €${product.price || '0.00'}
|
||||||
- **Brand**: ${product.manufacturer || config.brandName}
|
- **Brand**: ${product.manufacturer || config.brandName}
|
||||||
- **Availability**: ${product.available ? 'In Stock' : 'Out of Stock'}`;
|
- **Availability**: ${product.available ? 'In Stock' : 'Out of Stock'}`;
|
||||||
@@ -228,13 +234,13 @@ This category currently contains no products.
|
|||||||
if (pageNumber > 1) {
|
if (pageNumber > 1) {
|
||||||
categoryLlmsTxt += `← [Previous Page](${baseUrl}/llms-${categorySlug}-page-${pageNumber - 1}.txt) | `;
|
categoryLlmsTxt += `← [Previous Page](${baseUrl}/llms-${categorySlug}-page-${pageNumber - 1}.txt) | `;
|
||||||
}
|
}
|
||||||
|
|
||||||
categoryLlmsTxt += `[Category Overview](${baseUrl}/llms-${categorySlug}-page-1.txt)`;
|
categoryLlmsTxt += `[Category Overview](${baseUrl}/llms-${categorySlug}-page-1.txt)`;
|
||||||
|
|
||||||
if (pageNumber < totalPages) {
|
if (pageNumber < totalPages) {
|
||||||
categoryLlmsTxt += ` | [Next Page](${baseUrl}/llms-${categorySlug}-page-${pageNumber + 1}.txt) →`;
|
categoryLlmsTxt += ` | [Next Page](${baseUrl}/llms-${categorySlug}-page-${pageNumber + 1}.txt) →`;
|
||||||
}
|
}
|
||||||
|
|
||||||
categoryLlmsTxt += `
|
categoryLlmsTxt += `
|
||||||
|
|
||||||
`;
|
`;
|
||||||
@@ -248,17 +254,52 @@ This category currently contains no products.
|
|||||||
return categoryLlmsTxt;
|
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
|
// Helper function to generate all pages for a category
|
||||||
const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl, config, productsPerPage = 50) => {
|
const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl, config, productsPerPage = 50) => {
|
||||||
const totalProducts = categoryProducts.length;
|
const totalProducts = categoryProducts.length;
|
||||||
const totalPages = Math.ceil(totalProducts / productsPerPage);
|
const totalPages = Math.ceil(totalProducts / productsPerPage);
|
||||||
const pages = [];
|
const pages = [];
|
||||||
|
|
||||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
|
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
|
||||||
const pageContent = generateCategoryLlmsTxt(category, categoryProducts, baseUrl, config, pageNumber, productsPerPage);
|
const pageContent = generateCategoryLlmsTxt(category, categoryProducts, baseUrl, config, pageNumber, productsPerPage);
|
||||||
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||||
const fileName = `llms-${categorySlug}-page-${pageNumber}.txt`;
|
const fileName = `llms-${categorySlug}-page-${pageNumber}.txt`;
|
||||||
|
|
||||||
pages.push({
|
pages.push({
|
||||||
fileName,
|
fileName,
|
||||||
content: pageContent,
|
content: pageContent,
|
||||||
@@ -266,7 +307,7 @@ const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl,
|
|||||||
totalPages
|
totalPages
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return pages;
|
return pages;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -274,4 +315,5 @@ module.exports = {
|
|||||||
generateLlmsTxt,
|
generateLlmsTxt,
|
||||||
generateCategoryLlmsTxt,
|
generateCategoryLlmsTxt,
|
||||||
generateAllCategoryLlmsPages,
|
generateAllCategoryLlmsPages,
|
||||||
|
generateCategoryProductList,
|
||||||
};
|
};
|
||||||
@@ -1,19 +1,26 @@
|
|||||||
const generateProductMetaTags = (product, baseUrl, config) => {
|
const generateProductMetaTags = (product, baseUrl, config) => {
|
||||||
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
|
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
|
||||||
|
|
||||||
const imageUrl =
|
const imageUrl =
|
||||||
product.pictureList && product.pictureList.trim()
|
product.pictureList && product.pictureList.trim()
|
||||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||||
.split(",")[0]
|
.split(",")[0]
|
||||||
.trim()}.jpg`
|
.trim()}.avif`
|
||||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||||
|
|
||||||
|
|
||||||
// Clean description for meta (remove HTML tags and limit length)
|
// Clean description for meta (remove HTML tags and limit length)
|
||||||
const cleanDescription = product.description
|
const cleanDescription = product.kurzBeschreibung
|
||||||
? product.description
|
? product.kurzBeschreibung
|
||||||
.replace(/<[^>]*>/g, "")
|
.replace(/<[^>]*>/g, "")
|
||||||
.replace(/\n/g, " ")
|
.replace(/\n/g, " ")
|
||||||
.substring(0, 160)
|
.substring(0, 160)
|
||||||
: `${product.name} - Art.-Nr.: ${product.articleNumber}`;
|
: product.description
|
||||||
|
? product.description
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.replace(/\n/g, " ")
|
||||||
|
.substring(0, 160)
|
||||||
|
: `${product.name} - Art.-Nr.: ${product.articleNumber}`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<!-- SEO Meta Tags -->
|
<!-- SEO Meta Tags -->
|
||||||
@@ -47,6 +54,11 @@ const generateProductMetaTags = (product, baseUrl, config) => {
|
|||||||
<!-- Additional Meta Tags -->
|
<!-- Additional Meta Tags -->
|
||||||
<meta name="robots" content="index, follow">
|
<meta name="robots" content="index, follow">
|
||||||
<link rel="canonical" href="${productUrl}">
|
<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()
|
product.pictureList && product.pictureList.trim()
|
||||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||||
.split(",")[0]
|
.split(",")[0]
|
||||||
.trim()}.jpg`
|
.trim()}.avif`
|
||||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||||
|
|
||||||
// Clean description for JSON-LD (remove HTML tags)
|
// Clean description for JSON-LD (remove HTML tags)
|
||||||
@@ -94,6 +106,41 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
|
|||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
name: config.brandName,
|
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)
|
// Add current category (skip root category 209)
|
||||||
if (categoryNode.id !== 209) {
|
if (categoryNode.id !== 209) {
|
||||||
|
// Extract subcategory IDs from children
|
||||||
|
const subcategoryIds = categoryNode.children
|
||||||
|
? categoryNode.children.map(child => child.id)
|
||||||
|
: [];
|
||||||
|
|
||||||
categories.push({
|
categories.push({
|
||||||
id: categoryNode.id,
|
id: categoryNode.id,
|
||||||
name: categoryNode.name,
|
name: categoryNode.name,
|
||||||
seoName: categoryNode.seoName,
|
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;
|
||||||
289
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 {
|
import {
|
||||||
Routes,
|
Routes,
|
||||||
Route,
|
Route,
|
||||||
Navigate,
|
Navigate,
|
||||||
useLocation,
|
useLocation,
|
||||||
useNavigate,
|
useNavigate
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { ThemeProvider } from "@mui/material/styles";
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
import CssBaseline from "@mui/material/CssBaseline";
|
import CssBaseline from "@mui/material/CssBaseline";
|
||||||
@@ -14,11 +15,12 @@ import Fab from "@mui/material/Fab";
|
|||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import SmartToyIcon from "@mui/icons-material/SmartToy";
|
import SmartToyIcon from "@mui/icons-material/SmartToy";
|
||||||
import PaletteIcon from "@mui/icons-material/Palette";
|
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 { 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 config from "./config.js";
|
||||||
import ScrollToTop from "./components/ScrollToTop.js";
|
import ScrollToTop from "./components/ScrollToTop.js";
|
||||||
|
|
||||||
@@ -32,11 +34,11 @@ import Header from "./components/Header.js";
|
|||||||
import Footer from "./components/Footer.js";
|
import Footer from "./components/Footer.js";
|
||||||
import MainPageLayout from "./components/MainPageLayout.js";
|
import MainPageLayout from "./components/MainPageLayout.js";
|
||||||
|
|
||||||
// Lazy load all route components to reduce initial bundle size
|
// TEMPORARILY DISABLE ALL LAZY LOADING TO ELIMINATE CircularProgress
|
||||||
const Content = lazy(() => import(/* webpackChunkName: "content" */ "./components/Content.js"));
|
import Content from "./components/Content.js";
|
||||||
const ProductDetailWithSocket = lazy(() => import(/* webpackChunkName: "product-detail" */ "./components/ProductDetailWithSocket.js"));
|
import ProductDetail from "./components/ProductDetail.js";
|
||||||
const ProfilePageWithSocket = lazy(() => import(/* webpackChunkName: "profile" */ "./pages/ProfilePage.js"));
|
import ProfilePage from "./pages/ProfilePage.js";
|
||||||
const ResetPassword = lazy(() => import(/* webpackChunkName: "reset-password" */ "./pages/ResetPassword.js"));
|
import ResetPassword from "./pages/ResetPassword.js";
|
||||||
|
|
||||||
// Lazy load admin pages - only loaded when admin users access them
|
// Lazy load admin pages - only loaded when admin users access them
|
||||||
const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js"));
|
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 AGB = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/AGB.js"));
|
||||||
//const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); <Route path="/404" element={<NotFound404 />} />
|
//const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); <Route path="/404" element={<NotFound404 />} />
|
||||||
const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js"));
|
const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js"));
|
||||||
|
const CategoriesPage = lazy(() => import(/* webpackChunkName: "categories" */ "./pages/CategoriesPage.js"));
|
||||||
const Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
|
const Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
|
||||||
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
|
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
|
||||||
const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js"));
|
const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js"));
|
||||||
@@ -63,53 +66,45 @@ const ThcTestPage = lazy(() => import(/* webpackChunkName: "thc-test" */ "./page
|
|||||||
// Lazy load payment success page
|
// Lazy load payment success page
|
||||||
const PaymentSuccess = lazy(() => import(/* webpackChunkName: "payment" */ "./components/PaymentSuccess.js"));
|
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 theme from separate file to reduce main bundle size
|
||||||
import defaultTheme from "./theme.js";
|
import defaultTheme from "./theme.js";
|
||||||
// Lazy load theme customizer for development only
|
// Lazy load theme customizer for development only
|
||||||
const ThemeCustomizerDialog = lazy(() => import(/* webpackChunkName: "theme-customizer" */ "./components/ThemeCustomizerDialog.js"));
|
const ThemeCustomizerDialog = lazy(() => import(/* webpackChunkName: "theme-customizer" */ "./components/ThemeCustomizerDialog.js"));
|
||||||
import { createTheme } from "@mui/material/styles";
|
|
||||||
|
|
||||||
const deleteMessages = () => {
|
const deleteMessages = () => {
|
||||||
console.log("Deleting messages");
|
console.log("Deleting messages");
|
||||||
window.chatMessages = [];
|
window.chatMessages = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Component to initialize telemetry service with socket
|
|
||||||
const TelemetryInitializer = ({ socket }) => {
|
|
||||||
const telemetryServiceRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
|
||||||
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 }) => {
|
|
||||||
// State to manage chat visibility
|
// State to manage chat visibility
|
||||||
const [isChatOpen, setChatOpen] = useState(false);
|
const [isChatOpen, setChatOpen] = useState(false);
|
||||||
const [authVersion, setAuthVersion] = useState(0);
|
const [authVersion, setAuthVersion] = useState(0);
|
||||||
// @note Theme customizer state for development mode
|
// @note Theme customizer state for development mode
|
||||||
const [isThemeCustomizerOpen, setThemeCustomizerOpen] = useState(false);
|
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
|
// Get current location
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location.hash && location.hash.startsWith("#ORD-")) {
|
if (location.hash && location.hash.length > 1) {
|
||||||
if (location.pathname !== "/profile") {
|
// Check if it's a potential order ID (starts with # and has alphanumeric characters with dashes)
|
||||||
navigate(`/profile${location.hash}`, { replace: true });
|
const potentialOrderId = location.hash.substring(1);
|
||||||
|
if (/^[A-Z0-9]+-[A-Z0-9]+$/i.test(potentialOrderId)) {
|
||||||
|
if (location.pathname !== "/profile") {
|
||||||
|
navigate(`/profile${location.hash}`, { replace: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [location, navigate]);
|
}, [location, navigate]);
|
||||||
@@ -124,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 getCategoryId = () => {
|
||||||
const match = location.pathname.match(/^\/Kategorie\/(.+)$/);
|
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();
|
const categoryId = getCategoryId();
|
||||||
@@ -152,35 +181,40 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
setThemeCustomizerOpen(!isThemeCustomizerOpen);
|
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
|
// Check if we're in development mode
|
||||||
const isDevelopment = process.env.NODE_ENV === "development";
|
const isDevelopment = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
const {socket,socketB} = useContext(SocketContext);
|
// Check if current route is a prerender test route
|
||||||
console.log("AppContent: socket", socket);
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -192,21 +226,30 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
bgcolor: "background.default",
|
bgcolor: "background.default",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<TitleUpdater />
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<TelemetryInitializer socket={socket} />
|
|
||||||
<Header active categoryId={categoryId} key={authVersion} />
|
<Header active categoryId={categoryId} key={authVersion} />
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
<Box component="main" sx={{ flexGrow: 1 }}>
|
||||||
<Suspense fallback={
|
<Suspense fallback={
|
||||||
<Box
|
// Use prerender fallback if available, otherwise show loading spinner
|
||||||
sx={{
|
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
|
||||||
display: "flex",
|
<div
|
||||||
justifyContent: "center",
|
dangerouslySetInnerHTML={{
|
||||||
alignItems: "center",
|
__html: window.__PRERENDER_FALLBACK__.content,
|
||||||
minHeight: "60vh",
|
}}
|
||||||
}}
|
/>
|
||||||
>
|
) : (
|
||||||
<CircularProgress color="primary" />
|
<Box
|
||||||
</Box>
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "60vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress color="primary" />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
}>
|
}>
|
||||||
<CarouselProvider>
|
<CarouselProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -218,19 +261,19 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
{/* Category page - Render Content in parallel */}
|
{/* Category page - Render Content in parallel */}
|
||||||
<Route
|
<Route
|
||||||
path="/Kategorie/:categoryId"
|
path="/Kategorie/:categoryId"
|
||||||
element={<Content socket={socket} socketB={socketB} />}
|
element={<Content />}
|
||||||
/>
|
/>
|
||||||
{/* Single product page */}
|
{/* Single product page */}
|
||||||
<Route
|
<Route
|
||||||
path="/Artikel/:seoName"
|
path="/Artikel/:seoName"
|
||||||
element={<ProductDetailWithSocket />}
|
element={<ProductDetail />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Search page - Render Content in parallel */}
|
{/* Search page - Render Content in parallel */}
|
||||||
<Route path="/search" element={<Content socket={socket} socketB={socketB} />} />
|
<Route path="/search" element={<Content />} />
|
||||||
|
|
||||||
{/* Profile page */}
|
{/* Profile page */}
|
||||||
<Route path="/profile" element={<ProfilePageWithSocket />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
|
|
||||||
{/* Payment success page for Mollie redirects */}
|
{/* Payment success page for Mollie redirects */}
|
||||||
<Route path="/payment/success" element={<PaymentSuccess />} />
|
<Route path="/payment/success" element={<PaymentSuccess />} />
|
||||||
@@ -238,22 +281,23 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
{/* Reset password page */}
|
{/* Reset password page */}
|
||||||
<Route
|
<Route
|
||||||
path="/resetPassword"
|
path="/resetPassword"
|
||||||
element={<ResetPassword socket={socket} socketB={socketB} />}
|
element={<ResetPassword />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Admin page */}
|
{/* Admin page */}
|
||||||
<Route path="/admin" element={<AdminPage socket={socket} socketB={socketB} />} />
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
|
|
||||||
{/* Admin Users page */}
|
{/* Admin Users page */}
|
||||||
<Route path="/admin/users" element={<UsersPage socket={socket} socketB={socketB} />} />
|
<Route path="/admin/users" element={<UsersPage />} />
|
||||||
|
|
||||||
{/* Admin Server Logs page */}
|
{/* Admin Server Logs page */}
|
||||||
<Route path="/admin/logs" element={<ServerLogsPage socket={socket} socketB={socketB} />} />
|
<Route path="/admin/logs" element={<ServerLogsPage />} />
|
||||||
|
|
||||||
{/* Legal pages */}
|
{/* Legal pages */}
|
||||||
<Route path="/datenschutz" element={<Datenschutz />} />
|
<Route path="/datenschutz" element={<Datenschutz />} />
|
||||||
<Route path="/agb" element={<AGB />} />
|
<Route path="/agb" element={<AGB />} />
|
||||||
<Route path="/sitemap" element={<Sitemap />} />
|
<Route path="/sitemap" element={<Sitemap />} />
|
||||||
|
<Route path="/Kategorien" element={<CategoriesPage />} />
|
||||||
<Route path="/impressum" element={<Impressum />} />
|
<Route path="/impressum" element={<Impressum />} />
|
||||||
<Route
|
<Route
|
||||||
path="/batteriegesetzhinweise"
|
path="/batteriegesetzhinweise"
|
||||||
@@ -262,7 +306,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
<Route path="/widerrufsrecht" element={<Widerrufsrecht />} />
|
<Route path="/widerrufsrecht" element={<Widerrufsrecht />} />
|
||||||
|
|
||||||
{/* Grow Tent Configurator */}
|
{/* Grow Tent Configurator */}
|
||||||
<Route path="/Konfigurator" element={<GrowTentKonfigurator socket={socket} socketB={socketB} />} />
|
<Route path="/Konfigurator" element={<GrowTentKonfigurator />} />
|
||||||
|
|
||||||
{/* Separate pages that are truly different */}
|
{/* Separate pages that are truly different */}
|
||||||
<Route path="/presseverleih" element={<PresseverleihPage />} />
|
<Route path="/presseverleih" element={<PresseverleihPage />} />
|
||||||
@@ -276,11 +320,20 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
{/* Conditionally render the Chat Assistant */}
|
{/* Conditionally render the Chat Assistant */}
|
||||||
{isChatOpen && (
|
{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
|
<ChatAssistant
|
||||||
open={isChatOpen}
|
open={isChatOpen}
|
||||||
onClose={handleChatClose}
|
onClose={handleChatClose}
|
||||||
socket={socket}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
@@ -302,7 +355,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
</Fab>
|
</Fab>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* GitHub Issue Reporter FAB */}
|
{/* GitHub Issue Reporter FAB
|
||||||
<Tooltip title="Fehler oder Problem melden" placement="left">
|
<Tooltip title="Fehler oder Problem melden" placement="left">
|
||||||
<Fab
|
<Fab
|
||||||
color="error"
|
color="error"
|
||||||
@@ -317,7 +370,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
>
|
>
|
||||||
<BugReportIcon sx={{ fontSize: "1.2rem" }} />
|
<BugReportIcon sx={{ fontSize: "1.2rem" }} />
|
||||||
</Fab>
|
</Fab>
|
||||||
</Tooltip>
|
</Tooltip>*/}
|
||||||
|
|
||||||
{/* Development-only Theme Customizer FAB */}
|
{/* Development-only Theme Customizer FAB */}
|
||||||
{isDevelopment && (
|
{isDevelopment && (
|
||||||
@@ -338,9 +391,38 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
</Tooltip>
|
</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 */}
|
{/* Development-only Theme Customizer Dialog */}
|
||||||
{isDevelopment && isThemeCustomizerOpen && (
|
{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
|
<ThemeCustomizerDialog
|
||||||
open={isThemeCustomizerOpen}
|
open={isThemeCustomizerOpen}
|
||||||
onClose={() => setThemeCustomizerOpen(false)}
|
onClose={() => setThemeCustomizerOpen(false)}
|
||||||
@@ -374,27 +456,16 @@ const App = () => {
|
|||||||
return (
|
return (
|
||||||
<LanguageProvider i18n={i18n}>
|
<LanguageProvider i18n={i18n}>
|
||||||
<ThemeProvider theme={dynamicTheme}>
|
<ThemeProvider theme={dynamicTheme}>
|
||||||
<CssBaseline />
|
<ProductContextProvider>
|
||||||
<SocketProvider
|
<CategoryContextProvider>
|
||||||
url={config.apiBaseUrl}
|
<CssBaseline />
|
||||||
fallback={
|
<AppContent
|
||||||
<Box
|
currentTheme={currentTheme}
|
||||||
sx={{
|
dynamicTheme={dynamicTheme}
|
||||||
display: "flex",
|
onThemeChange={handleThemeChange}
|
||||||
justifyContent: "center",
|
/>
|
||||||
alignItems: "center",
|
</CategoryContextProvider>
|
||||||
height: "100vh",
|
</ProductContextProvider>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CircularProgress color="primary" />
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AppContent
|
|
||||||
currentTheme={currentTheme}
|
|
||||||
onThemeChange={handleThemeChange}
|
|
||||||
/>
|
|
||||||
</SocketProvider>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const PrerenderAppContent = (socket) => (
|
|||||||
<CategoryList categoryId={209} activeCategoryId={null} socket={socket}/>
|
<CategoryList categoryId={209} activeCategoryId={null} socket={socket}/>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
<Box component="main" sx={{ flexGrow: 1 }}>
|
||||||
<CarouselProvider>
|
<CarouselProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<MainPageLayout />} />
|
<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 Footer from './components/Footer.js';
|
||||||
import { Logo, SearchBar, CategoryList } from './components/header/index.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 || [];
|
const products = productData?.products || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -111,7 +111,7 @@ const PrerenderCategory = ({ categoryId, categoryName, categorySeoName, productD
|
|||||||
component="img"
|
component="img"
|
||||||
height="200"
|
height="200"
|
||||||
image={product.pictureList && product.pictureList.trim()
|
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'
|
: '/assets/images/nopicture.jpg'
|
||||||
}
|
}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
const React = require('react');
|
import React from 'react';
|
||||||
const {
|
import {
|
||||||
Box,
|
Box,
|
||||||
AppBar,
|
AppBar,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
Container
|
Container
|
||||||
} = require('@mui/material');
|
} from '@mui/material';
|
||||||
const Footer = require('./components/Footer.js').default;
|
import Footer from './components/Footer.js';
|
||||||
const { Logo, CategoryList } = require('./components/header/index.js');
|
import { Logo, CategoryList } from './components/header/index.js';
|
||||||
const MainPageLayout = require('./components/MainPageLayout.js').default;
|
|
||||||
const { CarouselProvider } = require('./contexts/CarouselContext.js');
|
|
||||||
|
|
||||||
class PrerenderHome extends React.Component {
|
class PrerenderHome extends React.Component {
|
||||||
render() {
|
render() {
|
||||||
@@ -29,45 +28,103 @@ class PrerenderHome extends React.Component {
|
|||||||
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
|
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Toolbar,
|
Toolbar,
|
||||||
{ sx: { minHeight: 64 } },
|
{ sx: { minHeight: 64, py: { xs: 0.5, sm: 0 } } },
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Container,
|
Container,
|
||||||
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center' } },
|
{ maxWidth: 'lg', sx: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
px: { xs: 0, sm: 3 }
|
||||||
|
} },
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Box,
|
Box,
|
||||||
{
|
{
|
||||||
sx: {
|
sx: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
flexDirection: { xs: 'column', sm: 'row' }
|
flexDirection: { xs: 'column', sm: 'row' }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Box,
|
Box,
|
||||||
{
|
{
|
||||||
sx: {
|
sx: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
width: '100%',
|
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(CategoryList, { categoryId: 209, activeCategoryId: null })
|
||||||
),
|
),
|
||||||
React.createElement(
|
|
||||||
Box,
|
|
||||||
{ sx: { flexGrow: 1 } },
|
|
||||||
React.createElement(CarouselProvider, null, React.createElement(MainPageLayout))
|
|
||||||
),
|
|
||||||
React.createElement(Footer)
|
React.createElement(Footer)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { default: PrerenderHome };
|
export default PrerenderHome;
|
||||||
@@ -66,6 +66,7 @@ class PrerenderKonfigurator extends Component {
|
|||||||
15%
|
15%
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
|
{/* Note: This is a prerender file - translation key would be: product.discount.from3Products */}
|
||||||
ab 3 Produkten
|
ab 3 Produkten
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -74,6 +75,7 @@ class PrerenderKonfigurator extends Component {
|
|||||||
24%
|
24%
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
|
{/* Note: This is a prerender file - translation key would be: product.discount.from5Products */}
|
||||||
ab 5 Produkten
|
ab 5 Produkten
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -82,11 +84,13 @@ class PrerenderKonfigurator extends Component {
|
|||||||
36%
|
36%
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
|
{/* Note: This is a prerender file - translation key would be: product.discount.from7Products */}
|
||||||
ab 7 Produkten
|
ab 7 Produkten
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" sx={{ color: '#666', mt: 1, display: 'block' }}>
|
<Typography variant="caption" sx={{ color: '#666', mt: 1, display: 'block' }}>
|
||||||
|
{/* Note: This is a prerender file - translation key would be: product.discount.moreProductsMoreSavings */}
|
||||||
Je mehr Produkte du auswählst, desto mehr sparst du!
|
Je mehr Produkte du auswählst, desto mehr sparst du!
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
const React = require('react');
|
import React from 'react';
|
||||||
const {
|
import {
|
||||||
Box,
|
Box,
|
||||||
AppBar,
|
AppBar,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
Container
|
Container
|
||||||
} = require('@mui/material');
|
} from '@mui/material';
|
||||||
const Footer = require('./components/Footer.js').default;
|
import Footer from './components/Footer.js';
|
||||||
const { Logo } = require('./components/header/index.js');
|
import { Logo } from './components/header/index.js';
|
||||||
const NotFound404 = require('./pages/NotFound404.js').default;
|
import NotFound404 from './pages/NotFound404.js';
|
||||||
|
|
||||||
class PrerenderNotFound extends React.Component {
|
class PrerenderNotFound extends React.Component {
|
||||||
render() {
|
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');
|
import React from 'react';
|
||||||
const {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Typography,
|
Typography,
|
||||||
Card,
|
|
||||||
CardMedia,
|
|
||||||
Grid,
|
|
||||||
Box,
|
Box,
|
||||||
Chip,
|
Chip,
|
||||||
Stack,
|
Stack,
|
||||||
AppBar,
|
AppBar,
|
||||||
Toolbar
|
Toolbar,
|
||||||
} = require('@mui/material');
|
Button
|
||||||
const Footer = require('./components/Footer.js').default;
|
} from '@mui/material';
|
||||||
const { Logo } = require('./components/header/index.js');
|
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 {
|
class PrerenderProduct extends React.Component {
|
||||||
render() {
|
render() {
|
||||||
@@ -20,21 +27,29 @@ class PrerenderProduct extends React.Component {
|
|||||||
|
|
||||||
if (!productData) {
|
if (!productData) {
|
||||||
return React.createElement(
|
return React.createElement(
|
||||||
Container,
|
Box,
|
||||||
{ maxWidth: 'lg', sx: { py: 4 } },
|
{ sx: { p: 4, textAlign: "center" } },
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Typography,
|
Typography,
|
||||||
{ variant: 'h4', component: 'h1', gutterBottom: true },
|
{ variant: 'h5', gutterBottom: true },
|
||||||
'Product not found'
|
'Produkt nicht gefunden'
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Typography,
|
||||||
|
null,
|
||||||
|
'Das gesuchte Produkt existiert nicht oder wurde entfernt.'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const product = productData.product;
|
const product = productData.product;
|
||||||
const attributes = productData.attributes || [];
|
const attributes = productData.attributes || [];
|
||||||
const mainImage = product.pictureList && product.pictureList.trim()
|
|
||||||
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.jpg`
|
// Format price with tax
|
||||||
: '/assets/images/nopicture.jpg';
|
const priceWithTax = new Intl.NumberFormat("de-DE", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(product.price);
|
||||||
|
|
||||||
return React.createElement(
|
return React.createElement(
|
||||||
Box,
|
Box,
|
||||||
@@ -48,149 +63,509 @@ class PrerenderProduct extends React.Component {
|
|||||||
bgcolor: 'background.default'
|
bgcolor: 'background.default'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
React.createElement(
|
React.createElement(
|
||||||
AppBar,
|
AppBar,
|
||||||
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
|
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Toolbar,
|
Toolbar,
|
||||||
{ sx: { minHeight: 64 } },
|
{ sx: { minHeight: 64, py: { xs: 0.5, sm: 0 } } },
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Container,
|
Container,
|
||||||
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center' } },
|
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center', px: { xs: 0, sm: 3 } } },
|
||||||
React.createElement(Logo)
|
// 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(
|
React.createElement(
|
||||||
Container,
|
Box,
|
||||||
{ maxWidth: 'lg', sx: { py: 4, flexGrow: 1 } },
|
{ sx: { flexGrow: 1 } },
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Grid,
|
Container,
|
||||||
{ container: true, spacing: 4 },
|
{
|
||||||
// Product Image
|
maxWidth: "lg",
|
||||||
|
sx: {
|
||||||
|
p: { xs: 2, md: 2 },
|
||||||
|
pb: { xs: 4, md: 8 },
|
||||||
|
flexGrow: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Back button (breadcrumbs section)
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Grid,
|
Box,
|
||||||
{ item: true, xs: 12, md: 6 },
|
{
|
||||||
|
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(
|
React.createElement(
|
||||||
Card,
|
Box,
|
||||||
{ sx: { height: '100%' } },
|
{
|
||||||
React.createElement(
|
sx: {
|
||||||
CardMedia,
|
ml: { xs: 0, md: 0 },
|
||||||
{
|
display: "inline-flex",
|
||||||
component: 'img',
|
px: 0,
|
||||||
height: '400',
|
py: 1,
|
||||||
image: mainImage,
|
backgroundColor: "#2e7d32", // primary dark green
|
||||||
alt: product.name,
|
borderRadius: 1,
|
||||||
sx: { objectFit: 'contain', p: 2 }
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
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'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
// Product Details
|
|
||||||
React.createElement(
|
|
||||||
Grid,
|
|
||||||
{ item: true, xs: 12, md: 6 },
|
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Stack,
|
Box,
|
||||||
{ spacing: 3 },
|
{
|
||||||
|
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(
|
React.createElement(
|
||||||
Typography,
|
ProductImage,
|
||||||
{ variant: 'h3', component: 'h1', gutterBottom: true },
|
{
|
||||||
product.name
|
product: product,
|
||||||
),
|
socket: null,
|
||||||
React.createElement(
|
socketB: null,
|
||||||
Typography,
|
fullscreenOpen: false,
|
||||||
{ variant: 'h6', color: 'text.secondary' },
|
onOpenFullscreen: null,
|
||||||
'Artikelnummer: '+product.articleNumber+' '+(product.gtin ? ` | GTIN: ${product.gtin}` : "")
|
onCloseFullscreen: null
|
||||||
|
}
|
||||||
),
|
),
|
||||||
|
// Product Details Section
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Box,
|
Box,
|
||||||
{ sx: { mt: 1 } },
|
{
|
||||||
React.createElement(
|
sx: {
|
||||||
Typography,
|
flex: "1 1 60%",
|
||||||
{ variant: 'h4', color: 'primary', fontWeight: 'bold' },
|
p: { xs: 2, md: 4 },
|
||||||
new Intl.NumberFormat('de-DE', {
|
display: "flex",
|
||||||
style: 'currency',
|
flexDirection: "column",
|
||||||
currency: 'EUR'
|
|
||||||
}).format(product.price)
|
|
||||||
),
|
|
||||||
product.vat && React.createElement(
|
|
||||||
Typography,
|
|
||||||
{ variant: 'body2', color: 'text.secondary' },
|
|
||||||
`inkl. ${product.vat}% MwSt.`
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Typography,
|
|
||||||
{
|
|
||||||
variant: 'body1',
|
|
||||||
color: product.available ? 'success.main' : 'error.main',
|
|
||||||
fontWeight: 'medium',
|
|
||||||
sx: { mt: 1 }
|
|
||||||
},
|
|
||||||
product.available ? '✅ Verfügbar' : '❌ Nicht verfügbar'
|
|
||||||
)
|
|
||||||
),
|
|
||||||
product.description && React.createElement(
|
|
||||||
Box,
|
|
||||||
{ sx: { mt: 2 } },
|
|
||||||
React.createElement(
|
|
||||||
Typography,
|
|
||||||
{ variant: 'h6', gutterBottom: true },
|
|
||||||
'Beschreibung'
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
'div',
|
|
||||||
{
|
|
||||||
dangerouslySetInnerHTML: { __html: product.description },
|
|
||||||
style: {
|
|
||||||
fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
|
|
||||||
fontSize: '1rem',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
color: '#33691E'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
},
|
||||||
),
|
// Product identifiers
|
||||||
// Product specifications
|
React.createElement(
|
||||||
React.createElement(
|
Box,
|
||||||
Box,
|
{ sx: { mb: 1 } },
|
||||||
{ sx: { mt: 2 } },
|
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(
|
React.createElement(
|
||||||
Typography,
|
Typography,
|
||||||
{ variant: 'h6', gutterBottom: true },
|
{
|
||||||
'Produktdetails'
|
variant: 'h4',
|
||||||
|
component: 'h1',
|
||||||
|
gutterBottom: true,
|
||||||
|
sx: {
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#333"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cleanProductName(product.name)
|
||||||
),
|
),
|
||||||
React.createElement(
|
// Manufacturer if available - exact match to SPA: only render Box if manufacturer exists
|
||||||
Stack,
|
product.manufacturer && React.createElement(
|
||||||
{ direction: 'row', spacing: 1, flexWrap: 'wrap', gap: 1 },
|
Box,
|
||||||
product.manufacturer && React.createElement(
|
{ sx: { display: "flex", alignItems: "center", mb: 2 } },
|
||||||
Chip,
|
React.createElement(
|
||||||
{ label: `Hersteller: ${product.manufacturer}`, variant: 'outlined' }
|
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.weight && product.weight > 0 && React.createElement(
|
// Right side - action buttons (exact replica with invisible versions)
|
||||||
Chip,
|
React.createElement(
|
||||||
{ label: `Gewicht: ${product.weight} kg`, variant: 'outlined' }
|
Stack,
|
||||||
),
|
{ direction: 'column', spacing: 1, sx: { flexShrink: 0 } },
|
||||||
...attributes.map((attr, index) =>
|
// "Frage zum Artikel" button - exact replica but invisible
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Chip,
|
Button,
|
||||||
{
|
{
|
||||||
key: index,
|
variant: "outlined",
|
||||||
label: `${attr.cName}: ${attr.cWert}`,
|
size: "small",
|
||||||
variant: 'outlined',
|
sx: {
|
||||||
color: 'primary'
|
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(
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
// "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: { mb: 2 } },
|
||||||
|
React.createElement(
|
||||||
|
Typography,
|
||||||
|
{ variant: 'body2', color: 'text.secondary' },
|
||||||
|
(this.props.t ? this.props.t('product.weight', { weight: product.weight.toFixed(1).replace(".", ",") }) : `Gewicht: ${product.weight.toFixed(1).replace(".", ",")} kg`)
|
||||||
|
)
|
||||||
|
) : null,
|
||||||
|
// Price and availability section
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{
|
||||||
|
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: "h4",
|
||||||
|
color: "primary",
|
||||||
|
sx: { fontWeight: "bold" }
|
||||||
|
},
|
||||||
|
priceWithTax
|
||||||
|
),
|
||||||
|
// VAT info (exact match to SPA - direct Typography, no wrapper)
|
||||||
|
React.createElement(
|
||||||
|
Typography,
|
||||||
|
{ variant: 'body2', color: 'text.secondary' },
|
||||||
|
(this.props.t ? this.props.t('product.inclVat', { vat: product.vat }) : `inkl. ${product.vat}% MwSt.`) +
|
||||||
|
(product.cGrundEinheit && product.fGrundPreis ?
|
||||||
|
`; ${new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(product.fGrundPreis)}/${product.cGrundEinheit}` :
|
||||||
|
"")
|
||||||
|
),
|
||||||
|
// Shipping class (exact match to SPA - direct Typography, conditional render)
|
||||||
|
product.versandklasse &&
|
||||||
|
product.versandklasse != "standard" &&
|
||||||
|
product.versandklasse != "kostenlos" && React.createElement(
|
||||||
|
Typography,
|
||||||
|
{ variant: 'body2', color: 'text.secondary' },
|
||||||
|
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: 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.7',
|
||||||
|
color: '#333'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
React.createElement(Footer)
|
React.createElement(Footer)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { default: PrerenderProduct };
|
export default PrerenderProduct;
|
||||||
@@ -1,17 +1,11 @@
|
|||||||
const React = require('react');
|
import React from 'react';
|
||||||
const {
|
import {
|
||||||
Box,
|
|
||||||
AppBar,
|
|
||||||
Toolbar,
|
|
||||||
Container,
|
|
||||||
Typography,
|
Typography,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemText
|
ListItemText
|
||||||
} = require('@mui/material');
|
} from '@mui/material';
|
||||||
const Footer = require('./components/Footer.js').default;
|
import LegalPage from './pages/LegalPage.js';
|
||||||
const { Logo, CategoryList } = require('./components/header/index.js');
|
|
||||||
const LegalPage = require('./pages/LegalPage.js').default;
|
|
||||||
|
|
||||||
const PrerenderSitemap = ({ categoryData }) => {
|
const PrerenderSitemap = ({ categoryData }) => {
|
||||||
// Process category data to flatten the hierarchy
|
// Process category data to flatten the hierarchy
|
||||||
@@ -134,4 +128,4 @@ const PrerenderSitemap = ({ categoryData }) => {
|
|||||||
return React.createElement(LegalPage, { title: 'Sitemap', content: content });
|
return React.createElement(LegalPage, { title: 'Sitemap', content: content });
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { default: PrerenderSitemap };
|
export default PrerenderSitemap;
|
||||||
@@ -216,6 +216,7 @@ class AddToCartButton extends Component {
|
|||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={this.handleDecrement}
|
onClick={this.handleDecrement}
|
||||||
|
aria-label="Menge verringern"
|
||||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||||
>
|
>
|
||||||
<RemoveIcon />
|
<RemoveIcon />
|
||||||
@@ -265,6 +266,7 @@ class AddToCartButton extends Component {
|
|||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={this.handleIncrement}
|
onClick={this.handleIncrement}
|
||||||
|
aria-label="Menge erhöhen"
|
||||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||||
>
|
>
|
||||||
<AddIcon />
|
<AddIcon />
|
||||||
@@ -274,6 +276,7 @@ class AddToCartButton extends Component {
|
|||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={this.handleClearCart}
|
onClick={this.handleClearCart}
|
||||||
|
aria-label={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
"&:hover": { color: "error.light" },
|
"&:hover": { color: "error.light" },
|
||||||
@@ -287,6 +290,7 @@ class AddToCartButton extends Component {
|
|||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={this.toggleCart}
|
onClick={this.toggleCart}
|
||||||
|
aria-label={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
"&:hover": { color: "primary.light" },
|
"&:hover": { color: "primary.light" },
|
||||||
@@ -363,6 +367,7 @@ class AddToCartButton extends Component {
|
|||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={this.handleDecrement}
|
onClick={this.handleDecrement}
|
||||||
|
aria-label="Menge verringern"
|
||||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||||
>
|
>
|
||||||
<RemoveIcon />
|
<RemoveIcon />
|
||||||
@@ -412,6 +417,7 @@ class AddToCartButton extends Component {
|
|||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={this.handleIncrement}
|
onClick={this.handleIncrement}
|
||||||
|
aria-label="Menge erhöhen"
|
||||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||||
>
|
>
|
||||||
<AddIcon />
|
<AddIcon />
|
||||||
@@ -421,6 +427,7 @@ class AddToCartButton extends Component {
|
|||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={this.handleClearCart}
|
onClick={this.handleClearCart}
|
||||||
|
aria-label={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
"&:hover": { color: "error.light" },
|
"&:hover": { color: "error.light" },
|
||||||
@@ -434,6 +441,7 @@ class AddToCartButton extends Component {
|
|||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={this.toggleCart}
|
onClick={this.toggleCart}
|
||||||
|
aria-label={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
"&:hover": { color: "primary.light" },
|
"&:hover": { color: "primary.light" },
|
||||||
|
|||||||
241
src/components/ArticleAvailabilityForm.js
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
FormLabel,
|
||||||
|
RadioGroup,
|
||||||
|
FormControlLabel,
|
||||||
|
Radio
|
||||||
|
} from '@mui/material';
|
||||||
|
import { withI18n } from '../i18n/withTranslation.js';
|
||||||
|
|
||||||
|
class ArticleAvailabilityForm extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
telegramId: '',
|
||||||
|
notificationMethod: 'email',
|
||||||
|
message: '',
|
||||||
|
loading: false,
|
||||||
|
success: false,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInputChange = (field) => (event) => {
|
||||||
|
this.setState({ [field]: event.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleNotificationMethodChange = (event) => {
|
||||||
|
this.setState({
|
||||||
|
notificationMethod: event.target.value,
|
||||||
|
// Clear the other field when switching methods
|
||||||
|
email: event.target.value === 'email' ? this.state.email : '',
|
||||||
|
telegramId: event.target.value === 'telegram' ? this.state.telegramId : ''
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSubmit = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// Prepare data for API emission
|
||||||
|
const availabilityData = {
|
||||||
|
type: 'availability_inquiry',
|
||||||
|
productId: this.props.productId,
|
||||||
|
productName: this.props.productName,
|
||||||
|
name: this.state.name,
|
||||||
|
notificationMethod: this.state.notificationMethod,
|
||||||
|
email: this.state.notificationMethod === 'email' ? this.state.email : '',
|
||||||
|
telegramId: this.state.notificationMethod === 'telegram' ? this.state.telegramId : '',
|
||||||
|
message: this.state.message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emit data via socket
|
||||||
|
console.log('Availability Inquiry Data to emit:', availabilityData);
|
||||||
|
|
||||||
|
window.socketManager.emit('availability_inquiry_submit', availabilityData);
|
||||||
|
|
||||||
|
// Set up response handler
|
||||||
|
window.socketManager.once('availability_inquiry_response', (response) => {
|
||||||
|
if (response.success) {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
success: true,
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
telegramId: '',
|
||||||
|
notificationMethod: 'email',
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
error: response.error || this.props.t("productDialogs.errorGeneric")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear messages after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setState({ success: false, error: null });
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setState({ loading: true });
|
||||||
|
|
||||||
|
// Fallback timeout in case backend doesn't respond
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.state.loading) {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
success: true,
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
telegramId: '',
|
||||||
|
notificationMethod: 'email',
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear success message after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setState({ success: false });
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
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' }}>
|
||||||
|
{t("productDialogs.availabilityTitle")}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
{t("productDialogs.availabilitySubtitle")}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<Alert severity="success" sx={{ mb: 3 }}>
|
||||||
|
{notificationMethod === 'email' ? t("productDialogs.availabilitySuccessEmail") : t("productDialogs.availabilitySuccessTelegram")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label={t("productDialogs.nameLabel")}
|
||||||
|
value={name}
|
||||||
|
onChange={this.handleInputChange('name')}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
disabled={loading}
|
||||||
|
placeholder={t("productDialogs.namePlaceholder")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl component="fieldset" disabled={loading}>
|
||||||
|
<FormLabel component="legend" sx={{ mb: 1 }}>
|
||||||
|
{t("productDialogs.notificationMethodLabel")}
|
||||||
|
</FormLabel>
|
||||||
|
<RadioGroup
|
||||||
|
value={notificationMethod}
|
||||||
|
onChange={this.handleNotificationMethodChange}
|
||||||
|
row
|
||||||
|
>
|
||||||
|
<FormControlLabel
|
||||||
|
value="email"
|
||||||
|
control={<Radio />}
|
||||||
|
label={t("productDialogs.emailLabel")}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
value="telegram"
|
||||||
|
control={<Radio />}
|
||||||
|
label={t("productDialogs.telegramBotLabel")}
|
||||||
|
/>
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{notificationMethod === 'email' && (
|
||||||
|
<TextField
|
||||||
|
label={t("productDialogs.emailLabel")}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={this.handleInputChange('email')}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
disabled={loading}
|
||||||
|
placeholder={t("productDialogs.emailPlaceholder")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notificationMethod === 'telegram' && (
|
||||||
|
<TextField
|
||||||
|
label={t("productDialogs.telegramIdLabel")}
|
||||||
|
value={telegramId}
|
||||||
|
onChange={this.handleInputChange('telegramId')}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
disabled={loading}
|
||||||
|
placeholder={t("productDialogs.telegramPlaceholder")}
|
||||||
|
helperText={t("productDialogs.telegramHelper")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={t("productDialogs.messageLabel")}
|
||||||
|
value={message}
|
||||||
|
onChange={this.handleInputChange('message')}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder={t("productDialogs.messagePlaceholder")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
disabled={loading || !name || (notificationMethod === 'email' && !email) || (notificationMethod === 'telegram' && !telegramId)}
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
py: 1.5,
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
backgroundColor: 'warning.main',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'warning.dark'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||||
|
{t("productDialogs.sending")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("productDialogs.submitAvailability")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(ArticleAvailabilityForm);
|
||||||
235
src/components/ArticleQuestionForm.js
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
Alert,
|
||||||
|
CircularProgress
|
||||||
|
} from '@mui/material';
|
||||||
|
import { withI18n } from '../i18n/withTranslation.js';
|
||||||
|
import PhotoUpload from './PhotoUpload.js';
|
||||||
|
|
||||||
|
class ArticleQuestionForm extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
question: '',
|
||||||
|
photos: [],
|
||||||
|
loading: false,
|
||||||
|
success: false,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
this.photoUploadRef = React.createRef();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInputChange = (field) => (event) => {
|
||||||
|
this.setState({ [field]: event.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
handlePhotosChange = (files) => {
|
||||||
|
this.setState({ photos: files });
|
||||||
|
};
|
||||||
|
|
||||||
|
convertPhotosToBase64 = (photos) => {
|
||||||
|
return Promise.all(
|
||||||
|
photos.map(photo => {
|
||||||
|
return new Promise((resolve, _reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
resolve({
|
||||||
|
name: photo.name,
|
||||||
|
type: photo.type,
|
||||||
|
size: photo.size,
|
||||||
|
data: e.target.result // base64 string
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(photo);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSubmit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
this.setState({ loading: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert photos to base64
|
||||||
|
const photosBase64 = await this.convertPhotosToBase64(this.state.photos);
|
||||||
|
|
||||||
|
// Prepare data for API emission
|
||||||
|
const questionData = {
|
||||||
|
type: 'article_question',
|
||||||
|
productId: this.props.productId,
|
||||||
|
productName: this.props.productName,
|
||||||
|
name: this.state.name,
|
||||||
|
email: this.state.email,
|
||||||
|
question: this.state.question,
|
||||||
|
photos: photosBase64,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emit data via socket
|
||||||
|
console.log('Article Question Data to emit:', questionData);
|
||||||
|
|
||||||
|
|
||||||
|
window.socketManager.emit('article_question_submit', questionData);
|
||||||
|
|
||||||
|
// Set up response handler
|
||||||
|
window.socketManager.once('article_question_response', (response) => {
|
||||||
|
if (response.success) {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
success: true,
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
question: '',
|
||||||
|
photos: []
|
||||||
|
});
|
||||||
|
// Reset the photo upload component
|
||||||
|
if (this.photoUploadRef.current) {
|
||||||
|
this.photoUploadRef.current.reset();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
error: response.error || this.props.t("productDialogs.errorGeneric")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear messages after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setState({ success: false, error: null });
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
error: this.props.t("productDialogs.errorPhotos")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback timeout in case backend doesn't respond
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.state.loading) {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
success: true,
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
question: '',
|
||||||
|
photos: []
|
||||||
|
});
|
||||||
|
// Reset the photo upload component
|
||||||
|
if (this.photoUploadRef.current) {
|
||||||
|
this.photoUploadRef.current.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear success message after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setState({ success: false });
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
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' }}>
|
||||||
|
{t("productDialogs.questionTitle")}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
{t("productDialogs.questionSubtitle")}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<Alert severity="success" sx={{ mb: 3 }}>
|
||||||
|
{t("productDialogs.questionSuccess")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label={t("productDialogs.nameLabel")}
|
||||||
|
value={name}
|
||||||
|
onChange={this.handleInputChange('name')}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
disabled={loading}
|
||||||
|
placeholder={t("productDialogs.namePlaceholder")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={t("productDialogs.emailLabel")}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={this.handleInputChange('email')}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
disabled={loading}
|
||||||
|
placeholder={t("productDialogs.emailPlaceholder")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={t("productDialogs.questionLabel")}
|
||||||
|
value={question}
|
||||||
|
onChange={this.handleInputChange('question')}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder={t("productDialogs.questionPlaceholder")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PhotoUpload
|
||||||
|
ref={this.photoUploadRef}
|
||||||
|
onChange={this.handlePhotosChange}
|
||||||
|
disabled={loading}
|
||||||
|
maxFiles={3}
|
||||||
|
label={t("productDialogs.photosLabelQuestion")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
disabled={loading || !name || !email || !question}
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
py: 1.5,
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||||
|
{t("productDialogs.sending")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("productDialogs.submitQuestion")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(ArticleQuestionForm);
|
||||||
263
src/components/ArticleRatingForm.js
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
Rating
|
||||||
|
} from '@mui/material';
|
||||||
|
import StarIcon from '@mui/icons-material/Star';
|
||||||
|
import { withI18n } from '../i18n/withTranslation.js';
|
||||||
|
import PhotoUpload from './PhotoUpload.js';
|
||||||
|
|
||||||
|
class ArticleRatingForm extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
rating: 0,
|
||||||
|
review: '',
|
||||||
|
photos: [],
|
||||||
|
loading: false,
|
||||||
|
success: false,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
this.photoUploadRef = React.createRef();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInputChange = (field) => (event) => {
|
||||||
|
this.setState({ [field]: event.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleRatingChange = (event, newValue) => {
|
||||||
|
this.setState({ rating: newValue });
|
||||||
|
};
|
||||||
|
|
||||||
|
handlePhotosChange = (files) => {
|
||||||
|
this.setState({ photos: files });
|
||||||
|
};
|
||||||
|
|
||||||
|
convertPhotosToBase64 = (photos) => {
|
||||||
|
return Promise.all(
|
||||||
|
photos.map(photo => {
|
||||||
|
return new Promise((resolve, _reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
resolve({
|
||||||
|
name: photo.name,
|
||||||
|
type: photo.type,
|
||||||
|
size: photo.size,
|
||||||
|
data: e.target.result // base64 string
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(photo);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSubmit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
this.setState({ loading: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert photos to base64
|
||||||
|
const photosBase64 = await this.convertPhotosToBase64(this.state.photos);
|
||||||
|
|
||||||
|
// Prepare data for API emission
|
||||||
|
const ratingData = {
|
||||||
|
type: 'article_rating',
|
||||||
|
productId: this.props.productId,
|
||||||
|
productName: this.props.productName,
|
||||||
|
name: this.state.name,
|
||||||
|
email: this.state.email,
|
||||||
|
rating: this.state.rating,
|
||||||
|
review: this.state.review,
|
||||||
|
photos: photosBase64,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emit data via socket
|
||||||
|
console.log('Article Rating Data to emit:', ratingData);
|
||||||
|
|
||||||
|
window.socketManager.emit('article_rating_submit', ratingData);
|
||||||
|
|
||||||
|
// Set up response handler
|
||||||
|
window.socketManager.once('article_rating_response', (response) => {
|
||||||
|
if (response.success) {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
success: true,
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
rating: 0,
|
||||||
|
review: '',
|
||||||
|
photos: []
|
||||||
|
});
|
||||||
|
// Reset the photo upload component
|
||||||
|
if (this.photoUploadRef.current) {
|
||||||
|
this.photoUploadRef.current.reset();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
error: response.error || this.props.t("productDialogs.errorGeneric")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear messages after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setState({ success: false, error: null });
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
error: this.props.t("productDialogs.errorPhotos")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback timeout in case backend doesn't respond
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.state.loading) {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
success: true,
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
rating: 0,
|
||||||
|
review: '',
|
||||||
|
photos: []
|
||||||
|
});
|
||||||
|
// Reset the photo upload component
|
||||||
|
if (this.photoUploadRef.current) {
|
||||||
|
this.photoUploadRef.current.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear success message after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setState({ success: false });
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
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' }}>
|
||||||
|
{t("productDialogs.ratingTitle")}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
{t("productDialogs.ratingSubtitle")}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<Alert severity="success" sx={{ mb: 3 }}>
|
||||||
|
{t("productDialogs.ratingSuccess")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label={t("productDialogs.nameLabel")}
|
||||||
|
value={name}
|
||||||
|
onChange={this.handleInputChange('name')}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
disabled={loading}
|
||||||
|
placeholder={t("productDialogs.namePlaceholder")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={t("productDialogs.emailLabel")}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={this.handleInputChange('email')}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
disabled={loading}
|
||||||
|
placeholder={t("productDialogs.emailPlaceholder")}
|
||||||
|
helperText={t("productDialogs.emailHelper")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
|
{t("productDialogs.ratingLabel")}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Rating
|
||||||
|
name="article-rating"
|
||||||
|
value={rating}
|
||||||
|
onChange={this.handleRatingChange}
|
||||||
|
size="large"
|
||||||
|
disabled={loading}
|
||||||
|
emptyIcon={<StarIcon style={{ opacity: 0.55 }} fontSize="inherit" />}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{rating > 0 ? t("productDialogs.ratingStars", { rating }) : t("productDialogs.pleaseRate")}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={t("productDialogs.reviewLabel")}
|
||||||
|
value={review}
|
||||||
|
onChange={this.handleInputChange('review')}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder={t("productDialogs.reviewPlaceholder")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PhotoUpload
|
||||||
|
ref={this.photoUploadRef}
|
||||||
|
onChange={this.handlePhotosChange}
|
||||||
|
disabled={loading}
|
||||||
|
maxFiles={5}
|
||||||
|
label={t("productDialogs.photosLabelRating")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
disabled={loading || !name || !email || rating === 0}
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
py: 1.5,
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||||
|
{t("productDialogs.sending")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("productDialogs.submitRating")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(ArticleRatingForm);
|
||||||
@@ -64,7 +64,7 @@ class CartDropdown extends Component {
|
|||||||
<>
|
<>
|
||||||
<Box sx={{ bgcolor: 'primary.main', color: 'white', p: 2 }}>
|
<Box sx={{ bgcolor: 'primary.main', color: 'white', p: 2 }}>
|
||||||
<Typography variant="h6">
|
<Typography variant="h6">
|
||||||
{cartItems.length} {cartItems.length === 1 ? 'Produkt' : 'Produkte'}
|
{cartItems.length} {cartItems.length === 1 ? (this.props.t ? this.props.t('cart.itemCount.singular') : 'Produkt') : (this.props.t ? this.props.t('cart.itemCount.plural') : 'Produkte')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -74,7 +74,6 @@ class CartDropdown extends Component {
|
|||||||
{cartItems.map((item) => (
|
{cartItems.map((item) => (
|
||||||
<CartItem
|
<CartItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
socket={this.props.socket}
|
|
||||||
item={item}
|
item={item}
|
||||||
id={item.id}
|
id={item.id}
|
||||||
/>
|
/>
|
||||||
@@ -84,7 +83,7 @@ class CartDropdown extends Component {
|
|||||||
{/* Display total weight if greater than 0 */}
|
{/* Display total weight if greater than 0 */}
|
||||||
{totalWeight > 0 && (
|
{totalWeight > 0 && (
|
||||||
<Typography variant="subtitle2" sx={{ px: 2, mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ px: 2, mb: 1 }}>
|
||||||
Gesamtgewicht: {totalWeight.toFixed(2)} kg
|
{this.props.t ? this.props.t('cart.summary.totalWeight', { weight: totalWeight.toFixed(2) }) : `Gesamtgewicht: ${totalWeight.toFixed(2)} kg`}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -95,7 +94,7 @@ class CartDropdown extends Component {
|
|||||||
// Detailed summary with shipping costs
|
// Detailed summary with shipping costs
|
||||||
<>
|
<>
|
||||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||||
Bestellübersicht
|
{this.props.t ? this.props.t('cart.summary.title') : 'Bestellübersicht'}
|
||||||
</Typography>
|
</Typography>
|
||||||
{deliveryMethod && (
|
{deliveryMethod && (
|
||||||
<Typography variant="body2" sx={{ mb: 1, color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ mb: 1, color: 'text.secondary' }}>
|
||||||
@@ -105,14 +104,14 @@ class CartDropdown extends Component {
|
|||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Waren (netto):</TableCell>
|
<TableCell>{this.props.t ? this.props.t('cart.summary.goodsNet') : 'Waren (netto):'}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{currencyFormatter.format(priceCalculations.totalNet)}
|
{currencyFormatter.format(priceCalculations.totalNet)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{deliveryCost > 0 && (
|
{deliveryCost > 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Versandkosten (netto):</TableCell>
|
<TableCell>{this.props.t ? this.props.t('cart.summary.shippingNet') : 'Versandkosten (netto):'}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{currencyFormatter.format(shippingNetPrice)}
|
{currencyFormatter.format(shippingNetPrice)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -135,30 +134,30 @@ class CartDropdown extends Component {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtsumme Waren:</TableCell>
|
<TableCell sx={{ fontWeight: 'bold' }}>{this.props.t ? this.props.t('cart.summary.totalGoods') : 'Gesamtsumme Waren:'}</TableCell>
|
||||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||||
{currencyFormatter.format(priceCalculations.totalGross)}
|
{currencyFormatter.format(priceCalculations.totalGross)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell sx={{ fontWeight: 'bold' }}>
|
<TableCell sx={{ fontWeight: 'bold' }}>
|
||||||
Versandkosten:
|
{this.props.t ? this.props.t('cart.summary.shippingCosts') : 'Versandkosten:'}
|
||||||
{deliveryCost === 0 && priceCalculations.totalGross < 100 && (
|
{deliveryCost === 0 && priceCalculations.totalGross < 100 && (
|
||||||
<span style={{ color: '#2e7d32', fontSize: '0.8em', marginLeft: '4px' }}>
|
<span style={{ color: '#2e7d32', fontSize: '0.8em', marginLeft: '4px' }}>
|
||||||
(kostenlos ab 100€)
|
{this.props.t ? this.props.t('cart.summary.freeFrom100') : '(kostenlos ab 100€)'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||||
{deliveryCost === 0 ? (
|
{deliveryCost === 0 ? (
|
||||||
<span style={{ color: '#2e7d32' }}>kostenlos</span>
|
<span style={{ color: '#2e7d32' }}>{this.props.t ? this.props.t('cart.summary.free') : 'kostenlos'}</span>
|
||||||
) : (
|
) : (
|
||||||
currencyFormatter.format(deliveryCost)
|
currencyFormatter.format(deliveryCost)
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow sx={{ borderTop: '1px solid #e0e0e0' }}>
|
<TableRow sx={{ borderTop: '1px solid #e0e0e0' }}>
|
||||||
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>Gesamtsumme:</TableCell>
|
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>{this.props.t ? this.props.t('cart.summary.total') : 'Gesamtsumme:'}</TableCell>
|
||||||
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
|
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
|
||||||
{currencyFormatter.format(totalGross)}
|
{currencyFormatter.format(totalGross)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -20,14 +20,13 @@ class CartItem extends Component {
|
|||||||
this.setState({image:window.tinyPicCache[picid],loading:false, error: false})
|
this.setState({image:window.tinyPicCache[picid],loading:false, error: false})
|
||||||
}else{
|
}else{
|
||||||
this.setState({image: null, loading: true, error: false});
|
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){
|
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});
|
this.setState({image: window.tinyPicCache[picid], loading: false});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,10 +75,24 @@ class CartItem extends Component {
|
|||||||
component="div"
|
component="div"
|
||||||
sx={{ fontWeight: 'bold', mb: 0.5 }}
|
sx={{ fontWeight: 'bold', mb: 0.5 }}
|
||||||
>
|
>
|
||||||
<Link to={`/Artikel/${item.seoName}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
{item.seoName ? (
|
||||||
{item.name}
|
<Link to={`/Artikel/${item.seoName}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
</Link>
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
item.name
|
||||||
|
)}
|
||||||
</Typography>
|
</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 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1, mt: 1 }}>
|
||||||
<Typography
|
<Typography
|
||||||
@@ -117,7 +130,7 @@ class CartItem extends Component {
|
|||||||
)}
|
)}
|
||||||
{item.versandklasse && item.versandklasse != 'standard' && item.versandklasse != 'kostenlos' && (
|
{item.versandklasse && item.versandklasse != 'standard' && item.versandklasse != 'kostenlos' && (
|
||||||
<Typography variant="body2" color="warning.main" fontWeight="medium" component="div">
|
<Typography variant="body2" color="warning.main" fontWeight="medium" component="div">
|
||||||
{item.versandklasse}
|
{item.versandklasse == 'nur Abholung' ? this.props.t('delivery.descriptions.pickupOnly') : item.versandklasse}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{item.vat && (
|
{item.vat && (
|
||||||
@@ -147,7 +160,7 @@ class CartItem extends Component {
|
|||||||
display: "block"
|
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") :
|
(this.props.t ? this.props.t('delivery.times.cutting14Days') : "Lieferzeit: 14 Tage") :
|
||||||
item.available == 1 ?
|
item.available == 1 ?
|
||||||
(this.props.t ? this.props.t('delivery.times.standard2to3Days') : "Lieferzeit: 2-3 Tage") :
|
(this.props.t ? this.props.t('delivery.times.standard2to3Days') : "Lieferzeit: 2-3 Tage") :
|
||||||
@@ -163,4 +176,4 @@ class CartItem extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n()(CartItem);
|
export default withI18n()(CartItem);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState, useEffect, useContext } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import SocketContext from '../contexts/SocketContext.js';
|
|
||||||
|
|
||||||
// @note SwashingtonCP font is now loaded globally via index.css
|
// @note SwashingtonCP font is now loaded globally via index.css
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ const CategoryBox = ({
|
|||||||
const [imageUrl, setImageUrl] = useState(null);
|
const [imageUrl, setImageUrl] = useState(null);
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const context = useContext(SocketContext);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let objectUrl = null;
|
let objectUrl = null;
|
||||||
@@ -47,7 +47,7 @@ const CategoryBox = ({
|
|||||||
// Create fresh blob URL from cached binary data
|
// Create fresh blob URL from cached binary data
|
||||||
try {
|
try {
|
||||||
const uint8Array = new Uint8Array(cachedImageData);
|
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);
|
objectUrl = URL.createObjectURL(blob);
|
||||||
setImageUrl(objectUrl);
|
setImageUrl(objectUrl);
|
||||||
setImageError(false);
|
setImageError(false);
|
||||||
@@ -60,11 +60,10 @@ const CategoryBox = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If socket is available and connected, fetch the image
|
if (id && !isLoading) {
|
||||||
if (context && context.socket && context.socket.connected && id && !isLoading) {
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
context.socket.emit('getCategoryPic', { categoryId: id }, (response) => {
|
window.socketManager.emit('getCategoryPic', { categoryId: id }, (response) => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
@@ -74,7 +73,7 @@ const CategoryBox = ({
|
|||||||
try {
|
try {
|
||||||
// Convert binary data to blob URL
|
// Convert binary data to blob URL
|
||||||
const uint8Array = new Uint8Array(imageData);
|
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);
|
objectUrl = URL.createObjectURL(blob);
|
||||||
setImageUrl(objectUrl);
|
setImageUrl(objectUrl);
|
||||||
setImageError(false);
|
setImageError(false);
|
||||||
@@ -119,7 +118,7 @@ const CategoryBox = ({
|
|||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [context, context?.socket?.connected, id, isLoading]);
|
}, [id, isLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
@@ -159,7 +158,7 @@ const CategoryBox = ({
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
backgroundImage: ((typeof window !== 'undefined' && window.__PRERENDER_FALLBACK__) ||
|
backgroundImage: ((typeof window !== 'undefined' && window.__PRERENDER_FALLBACK__) ||
|
||||||
(typeof global !== 'undefined' && global.window && global.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'),
|
: (imageUrl && !imageError ? `url("${imageUrl}")` : 'none'),
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
|
|||||||
@@ -81,18 +81,6 @@ class ChatAssistant extends Component {
|
|||||||
if (prevState.messages !== this.state.messages || prevState.isTyping !== this.state.isTyping) {
|
if (prevState.messages !== this.state.messages || prevState.isTyping !== this.state.isTyping) {
|
||||||
this.scrollToBottom();
|
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() {
|
componentWillUnmount() {
|
||||||
@@ -104,19 +92,18 @@ class ChatAssistant extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addSocketListeners = () => {
|
addSocketListeners = () => {
|
||||||
if (this.props.socket && this.props.socket.connected) {
|
|
||||||
// Remove existing listeners first to avoid duplicates
|
|
||||||
this.removeSocketListeners();
|
this.removeSocketListeners();
|
||||||
this.props.socket.on('aiassyResponse', this.handleBotResponse);
|
window.socketManager.on('aiassyResponse', this.handleBotResponse);
|
||||||
this.props.socket.on('aiassyStatus', this.handleStateResponse);
|
window.socketManager.on('aiassyStatus', this.handleStateResponse);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
removeSocketListeners = () => {
|
removeSocketListeners = () => {
|
||||||
if (this.props.socket) {
|
|
||||||
this.props.socket.off('aiassyResponse', this.handleBotResponse);
|
window.socketManager.off('aiassyResponse', this.handleBotResponse);
|
||||||
this.props.socket.off('aiassyStatus', this.handleStateResponse);
|
window.socketManager.off('aiassyStatus', this.handleStateResponse);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleBotResponse = (msgId,response) => {
|
handleBotResponse = (msgId,response) => {
|
||||||
@@ -194,8 +181,8 @@ class ChatAssistant extends Component {
|
|||||||
};
|
};
|
||||||
}, () => {
|
}, () => {
|
||||||
// Emit message to socket server after state is updated
|
// Emit message to socket server after state is updated
|
||||||
if (userMessage.trim() && this.props.socket && this.props.socket.connected) {
|
if (userMessage.trim()) {
|
||||||
this.props.socket.emit('aiassyMessage', userMessage);
|
window.socketManager.emit('aiassyMessage', userMessage);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -300,12 +287,10 @@ class ChatAssistant extends Component {
|
|||||||
reader.onloadend = () => {
|
reader.onloadend = () => {
|
||||||
const base64Audio = reader.result.split(',')[1];
|
const base64Audio = reader.result.split(',')[1];
|
||||||
// Send audio data to server
|
// Send audio data to server
|
||||||
if (this.props.socket && this.props.socket.connected) {
|
window.socketManager.emit('aiassyAudioMessage', {
|
||||||
this.props.socket.emit('aiassyAudioMessage', {
|
|
||||||
audio: base64Audio,
|
audio: base64Audio,
|
||||||
format: 'wav'
|
format: 'wav'
|
||||||
});
|
});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -389,12 +374,12 @@ class ChatAssistant extends Component {
|
|||||||
reader.onloadend = () => {
|
reader.onloadend = () => {
|
||||||
const base64Image = reader.result.split(',')[1];
|
const base64Image = reader.result.split(',')[1];
|
||||||
// Send image data to server
|
// Send image data to server
|
||||||
if (this.props.socket && this.props.socket.connected) {
|
|
||||||
this.props.socket.emit('aiassyPicMessage', {
|
window.socketManager.emit('aiassyPicMessage', {
|
||||||
image: base64Image,
|
image: base64Image,
|
||||||
format: 'jpeg'
|
format: 'jpeg'
|
||||||
});
|
});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -476,20 +461,20 @@ class ChatAssistant extends Component {
|
|||||||
const inputsDisabled = isGuest && !privacyConfirmed;
|
const inputsDisabled = isGuest && !privacyConfirmed;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
elevation={4}
|
elevation={4}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: { xs: 16, sm: 80 },
|
bottom: { xs: 0, sm: 80 },
|
||||||
right: { xs: 16, sm: 16 },
|
right: { xs: 0, sm: 16 },
|
||||||
left: { xs: 16, sm: 'auto' },
|
left: { xs: 0, sm: 'auto' },
|
||||||
top: { xs: 16, sm: 'auto' },
|
top: { xs: 0, sm: 'auto' },
|
||||||
width: { xs: 'calc(100vw - 32px)', sm: 450, md: 600, lg: 750 },
|
width: { xs: '100vw', sm: 450, md: 600, lg: 750 },
|
||||||
height: { xs: 'calc(100vh - 32px)', sm: 600, md: 650, lg: 700 },
|
height: { xs: '100vh', sm: 600, md: 650, lg: 700 },
|
||||||
maxWidth: { xs: 'none', sm: 450, md: 600, lg: 750 },
|
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',
|
bgcolor: 'background.paper',
|
||||||
borderRadius: 2,
|
borderRadius: { xs: 0, sm: 2 },
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
zIndex: 1300,
|
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.atDatabase ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🛢</Typography>
|
||||||
<Typography component="span" color={this.state.atWeb ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🌐</Typography>
|
<Typography component="span" color={this.state.atWeb ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🌐</Typography>
|
||||||
</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 />
|
<CloseIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -578,11 +563,13 @@ class ChatAssistant extends Component {
|
|||||||
)}
|
)}
|
||||||
<div ref={this.messagesEndRef} />
|
<div ref={this.messagesEndRef} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
p: 1,
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
borderTop: 1,
|
gap: { xs: 1, sm: 0 },
|
||||||
|
p: 1,
|
||||||
|
borderTop: 1,
|
||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
@@ -594,22 +581,22 @@ class ChatAssistant extends Component {
|
|||||||
onChange={this.handleFileChange}
|
onChange={this.handleFileChange}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoFocus
|
autoFocus
|
||||||
autoCapitalize="off"
|
autoCapitalize="off"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
placeholder={isRecording ? "Aufnahme läuft..." : "Du kannst mich nach Cannabissorten fragen..."}
|
placeholder={isRecording ? "Aufnahme läuft..." : "Du kannst mich nach Cannabissorten fragen..."}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={this.handleInputChange}
|
onChange={this.handleInputChange}
|
||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={this.handleKeyDown}
|
||||||
disabled={isRecording || inputsDisabled}
|
disabled={isRecording || inputsDisabled}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
maxLength: 300,
|
maxLength: 300,
|
||||||
endAdornment: isRecording && (
|
endAdornment: isRecording && (
|
||||||
<Typography variant="caption" color="primary" sx={{ mr: 1 }}>
|
<Typography variant="caption" color="primary" sx={{ mr: 1 }}>
|
||||||
{this.formatTime(recordingTime)}
|
{this.formatTime(recordingTime)}
|
||||||
@@ -619,42 +606,47 @@ class ChatAssistant extends Component {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isRecording ? (
|
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||||
<IconButton
|
{isRecording ? (
|
||||||
color="error"
|
<IconButton
|
||||||
onClick={this.stopRecording}
|
color="error"
|
||||||
sx={{ ml: 1 }}
|
onClick={this.stopRecording}
|
||||||
>
|
aria-label="Aufnahme stoppen"
|
||||||
<StopIcon />
|
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||||
</IconButton>
|
>
|
||||||
) : (
|
<StopIcon />
|
||||||
<IconButton
|
</IconButton>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
color="primary"
|
||||||
|
onClick={this.startRecording}
|
||||||
|
aria-label="Sprachaufnahme starten"
|
||||||
|
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||||
|
disabled={isTyping || inputsDisabled}
|
||||||
|
>
|
||||||
|
<MicIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<IconButton
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={this.startRecording}
|
onClick={this.handleImageUpload}
|
||||||
sx={{ ml: 1 }}
|
aria-label="Bild hochladen"
|
||||||
disabled={isTyping || inputsDisabled}
|
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||||
|
disabled={isTyping || isRecording || inputsDisabled}
|
||||||
>
|
>
|
||||||
<MicIcon />
|
<PhotoCameraIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
|
||||||
|
<Button
|
||||||
<IconButton
|
variant="contained"
|
||||||
color="primary"
|
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||||
onClick={this.handleImageUpload}
|
onClick={this.handleSendMessage}
|
||||||
sx={{ ml: 1 }}
|
disabled={isTyping || isRecording || inputsDisabled}
|
||||||
disabled={isTyping || isRecording || inputsDisabled}
|
>
|
||||||
>
|
Senden
|
||||||
<PhotoCameraIcon />
|
</Button>
|
||||||
</IconButton>
|
</Box>
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
sx={{ ml: 1 }}
|
|
||||||
onClick={this.handleSendMessage}
|
|
||||||
disabled={isTyping || isRecording || inputsDisabled}
|
|
||||||
>
|
|
||||||
Senden
|
|
||||||
</Button>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ class Filter extends Component {
|
|||||||
)}
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
{isXsScreen && (
|
{isXsScreen && (
|
||||||
<IconButton size="small" sx={{ p: 0 }}>
|
<IconButton size="small" aria-label={isCollapsed ? "Filter erweitern" : "Filter einklappen"} sx={{ p: 0 }}>
|
||||||
{isCollapsed ? <ExpandMoreIcon /> : <ExpandLessIcon />}
|
{isCollapsed ? <ExpandMoreIcon /> : <ExpandLessIcon />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ class Footer extends Component {
|
|||||||
<StyledRouterLink to="/widerrufsrecht">{this.props.t ? this.props.t('footer.legal.widerrufsrecht') : 'Widerrufsrecht'}</StyledRouterLink>
|
<StyledRouterLink to="/widerrufsrecht">{this.props.t ? this.props.t('footer.legal.widerrufsrecht') : 'Widerrufsrecht'}</StyledRouterLink>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Payment Methods Section */}
|
{/* Payment Methods Section
|
||||||
<Stack
|
<Stack
|
||||||
direction="column"
|
direction="column"
|
||||||
spacing={1}
|
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 } }} />
|
<Box component="img" src="/assets/images/cards.png" alt="Cash" sx={{ height: { xs: 80, md: 95 } }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
*/}
|
||||||
{/* Google Services Badge Section */}
|
{/* Google Services Badge Section */}
|
||||||
<Stack
|
<Stack
|
||||||
direction="column"
|
direction="column"
|
||||||
@@ -272,12 +272,12 @@ class Footer extends Component {
|
|||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
spacing={{ xs: 1, md: 2 }}
|
spacing={{ xs: 1, md: 2 }}
|
||||||
sx={{pb: '10px'}}
|
sx={{pt: '10px', height: { xs: 50, md: 60 }, transform: 'translateY(-3px)'}}
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
alignItems="center"
|
alignItems="flex-end"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href="https://reviewthis.biz/growheads"
|
href="https://reviewthis.biz/growheads"
|
||||||
@@ -286,17 +286,21 @@ class Footer extends Component {
|
|||||||
sx={{
|
sx={{
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
zIndex: 9999
|
zIndex: 9999,
|
||||||
|
display: 'inline-block',
|
||||||
|
height: { xs: 57, md: 67 },
|
||||||
|
lineHeight: 1
|
||||||
}}
|
}}
|
||||||
onMouseEnter={this.handleReviewsMouseEnter}
|
onMouseEnter={this.handleReviewsMouseEnter}
|
||||||
onMouseLeave={this.handleReviewsMouseLeave}
|
onMouseLeave={this.handleReviewsMouseLeave}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src="/assets/images/gg.png"
|
src="/assets/images/gg.avif"
|
||||||
alt="Google Reviews"
|
alt="Google Reviews"
|
||||||
sx={{
|
sx={{
|
||||||
height: { xs: 50, md: 60 },
|
height: { xs: 50, md: 60 },
|
||||||
|
width: { xs: 105, md: 126 },
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 2s ease',
|
transition: 'all 2s ease',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
@@ -312,17 +316,21 @@ class Footer extends Component {
|
|||||||
sx={{
|
sx={{
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
zIndex: 9999
|
zIndex: 9999,
|
||||||
|
display: 'inline-block',
|
||||||
|
height: { xs: 47, md: 67 },
|
||||||
|
lineHeight: 1
|
||||||
}}
|
}}
|
||||||
onMouseEnter={this.handleMapsMouseEnter}
|
onMouseEnter={this.handleMapsMouseEnter}
|
||||||
onMouseLeave={this.handleMapsMouseLeave}
|
onMouseLeave={this.handleMapsMouseLeave}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src="/assets/images/maps.png"
|
src="/assets/images/maps.avif"
|
||||||
alt="Google Maps"
|
alt="Google Maps"
|
||||||
sx={{
|
sx={{
|
||||||
height: { xs: 40, md: 50 },
|
height: { xs: 40, md: 50 },
|
||||||
|
width: { xs: 38, md: 49 },
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 2s ease',
|
transition: 'all 2s ease',
|
||||||
filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))',
|
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 }}>
|
<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>
|
© {new Date().getFullYear()} <StyledDomainLink href="https://growheads.de" target="_blank" rel="noopener noreferrer">GrowHeads.de</StyledDomainLink>
|
||||||
</Typography>
|
</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>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { Component } from 'react';
|
|||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import GoogleIcon from '@mui/icons-material/Google';
|
import GoogleIcon from '@mui/icons-material/Google';
|
||||||
import GoogleAuthContext from '../contexts/GoogleAuthContext.js';
|
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 {
|
class GoogleLoginButton extends Component {
|
||||||
static contextType = GoogleAuthContext;
|
static contextType = GoogleAuthContext;
|
||||||
@@ -187,17 +187,20 @@ class GoogleLoginButton extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
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 { isInitializing, isPrompting } = this.state;
|
||||||
const isLoading = isInitializing || isPrompting || (this.context && !this.context.isLoaded);
|
const isLoading = isInitializing || isPrompting || (this.context && !this.context.isLoaded);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<GoogleIcon />}
|
startIcon={<GoogleIcon />}
|
||||||
onClick={this.handleClick}
|
onClick={this.handleClick}
|
||||||
disabled={disabled || isLoading}
|
disabled={disabled || isLoading}
|
||||||
style={{ backgroundColor: '#4285F4', color: 'white', ...style }}
|
fullWidth
|
||||||
|
style={{backgroundColor: '#4285F4', color: 'white', ...style }}
|
||||||
className={className}
|
className={className}
|
||||||
>
|
>
|
||||||
{isLoading ? 'Loading...' : text}
|
{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 Container from '@mui/material/Container';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
|
||||||
import SocketContext from '../contexts/SocketContext.js';
|
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
// Import extracted components
|
// Import extracted components
|
||||||
@@ -12,7 +11,6 @@ import { Logo, SearchBar, ButtonGroupWithRouter, CategoryList } from './header/i
|
|||||||
|
|
||||||
// Main Header Component
|
// Main Header Component
|
||||||
class Header extends Component {
|
class Header extends Component {
|
||||||
static contextType = SocketContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -36,8 +34,7 @@ class Header extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
// Get socket directly from context in render method
|
|
||||||
const {socket,socketB} = this.context;
|
|
||||||
const { isHomePage, isProfilePage, isAktionenPage, isFilialePage } = this.props;
|
const { isHomePage, isProfilePage, isAktionenPage, isFilialePage } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -75,7 +72,7 @@ class Header extends Component {
|
|||||||
transform: { xs: 'translateY(4px) translateX(9px)', sm: 'none' },
|
transform: { xs: 'translateY(4px) translateX(9px)', sm: 'none' },
|
||||||
ml: { xs: 0, sm: 0 }
|
ml: { xs: 0, sm: 0 }
|
||||||
}}>
|
}}>
|
||||||
<ButtonGroupWithRouter socket={socket}/>
|
<ButtonGroupWithRouter/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -94,7 +91,7 @@ class Header extends Component {
|
|||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
</Toolbar>
|
</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>
|
</AppBar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -107,11 +104,12 @@ const HeaderWithContext = (props) => {
|
|||||||
const isProfilePage = location.pathname === '/profile';
|
const isProfilePage = location.pathname === '/profile';
|
||||||
const isAktionenPage = location.pathname === '/aktionen';
|
const isAktionenPage = location.pathname === '/aktionen';
|
||||||
const isFilialePage = location.pathname === '/filiale';
|
const isFilialePage = location.pathname === '/filiale';
|
||||||
|
const isArtikel = location.pathname.startsWith('/Artikel/');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SocketContext.Consumer>
|
|
||||||
{({socket,socketB}) => <Header {...props} socket={socket} socketB={socketB} isHomePage={isHomePage} isProfilePage={isProfilePage} isAktionenPage={isAktionenPage} isFilialePage={isFilialePage} />}
|
<Header {...props} isHomePage={isHomePage} isArtikel={isArtikel} isProfilePage={isProfilePage} isAktionenPage={isAktionenPage} isFilialePage={isFilialePage} />
|
||||||
</SocketContext.Consumer>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ import LoupeIcon from '@mui/icons-material/Loupe';
|
|||||||
class Images extends Component {
|
class Images extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { mainPic:0,pics:[]};
|
this.state = { mainPic:0,pics:[] };
|
||||||
|
|
||||||
console.log('Images constructor',props);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
@@ -25,6 +23,9 @@ class Images extends Component {
|
|||||||
this.updatePics();
|
this.updatePics();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
componentWillUnmount() {
|
||||||
|
window.productImageUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
updatePics = (newMainPic = this.state.mainPic) => {
|
updatePics = (newMainPic = this.state.mainPic) => {
|
||||||
if (!window.tinyPicCache) window.tinyPicCache = {};
|
if (!window.tinyPicCache) window.tinyPicCache = {};
|
||||||
@@ -41,6 +42,7 @@ class Images extends Component {
|
|||||||
|
|
||||||
for(const bildId of bildIds){
|
for(const bildId of bildIds){
|
||||||
if(bildId == mainPicId){
|
if(bildId == mainPicId){
|
||||||
|
if(window.productImageUrl) continue;
|
||||||
|
|
||||||
if(window.largePicCache[bildId]){
|
if(window.largePicCache[bildId]){
|
||||||
pics.push(window.largePicCache[bildId]);
|
pics.push(window.largePicCache[bildId]);
|
||||||
@@ -51,10 +53,10 @@ class Images extends Component {
|
|||||||
pics.push(window.smallPicCache[bildId]);
|
pics.push(window.smallPicCache[bildId]);
|
||||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||||
}else if(window.tinyPicCache[bildId]){
|
}else if(window.tinyPicCache[bildId]){
|
||||||
pics.push(bildId);
|
pics.push(window.tinyPicCache[bildId]);
|
||||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||||
}else{
|
}else{
|
||||||
pics.push(bildId);
|
pics.push(`/assets/images/prod${bildId}.avif`);
|
||||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||||
}
|
}
|
||||||
}else{
|
}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 });
|
this.setState({ pics, mainPic: newMainPic });
|
||||||
}else{
|
}else{
|
||||||
if(this.state.pics.length > 0) this.setState({ pics:[], mainPic: newMainPic });
|
if(this.state.pics.length > 0) this.setState({ pics:[], mainPic: newMainPic });
|
||||||
@@ -77,9 +80,11 @@ class Images extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadPic = (size,bildId,index) => {
|
loadPic = (size,bildId,index) => {
|
||||||
this.props.socketB.emit('getPic', { bildId, size }, (res) => {
|
|
||||||
|
|
||||||
|
window.socketManager.emit('getPic', { bildId, size }, (res) => {
|
||||||
if(res.success){
|
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 === 'medium') window.mediumPicCache[bildId] = url;
|
||||||
if(size === 'small') window.smallPicCache[bildId] = url;
|
if(size === 'small') window.smallPicCache[bildId] = url;
|
||||||
@@ -101,43 +106,68 @@ class Images extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{this.state.pics[this.state.mainPic] && (
|
<Box sx={{ position: 'relative', display: 'inline-block', width: '499px', maxWidth: '100%' }}>
|
||||||
<Box sx={{ position: 'relative', display: 'inline-block' }}>
|
<CardMedia
|
||||||
<CardMedia
|
component="img"
|
||||||
component="img"
|
height="400"
|
||||||
height="400"
|
fetchPriority="high"
|
||||||
sx={{
|
loading="eager"
|
||||||
objectFit: 'contain',
|
alt={this.props.productName || 'Produktbild'}
|
||||||
cursor: 'pointer',
|
onError={(e) => {
|
||||||
transition: 'transform 0.2s ease-in-out',
|
// Ensure alt text is always present even on error
|
||||||
'&:hover': {
|
if (!e.target.alt) {
|
||||||
transform: 'scale(1.02)'
|
e.target.alt = this.props.productName || 'Produktbild';
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
image={this.state.pics[this.state.mainPic]}
|
sx={{
|
||||||
onClick={this.props.onOpenFullscreen}
|
objectFit: 'contain',
|
||||||
/>
|
cursor: 'pointer',
|
||||||
<IconButton
|
transition: 'transform 0.2s ease-in-out',
|
||||||
size="small"
|
width: '499px',
|
||||||
disableRipple
|
maxWidth: '100%',
|
||||||
sx={{
|
'&:hover': {
|
||||||
position: 'absolute',
|
transform: 'scale(1.02)'
|
||||||
top: 8,
|
}
|
||||||
right: 8,
|
}}
|
||||||
color: 'white',
|
image={getImageSrc()}
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
onClick={this.props.onOpenFullscreen}
|
||||||
pointerEvents: 'none',
|
/>
|
||||||
'&:hover': {
|
<IconButton
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.6)'
|
size="small"
|
||||||
}
|
disableRipple
|
||||||
}}
|
aria-label="Zoom-Symbol"
|
||||||
>
|
sx={{
|
||||||
<LoupeIcon fontSize="small" />
|
position: 'absolute',
|
||||||
</IconButton>
|
top: 8,
|
||||||
</Box>
|
right: 8,
|
||||||
)}
|
color: 'white',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.6)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LoupeIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
<Stack direction="row" spacing={2} sx={{ justifyContent: 'flex-start', mt: 1,mb: 1 }}>
|
<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) => {
|
{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
|
// Find the original index in the full pics array
|
||||||
@@ -169,6 +199,13 @@ class Images extends Component {
|
|||||||
<CardMedia
|
<CardMedia
|
||||||
component="img"
|
component="img"
|
||||||
height="80"
|
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={{
|
sx={{
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -223,6 +260,7 @@ class Images extends Component {
|
|||||||
{/* Close Button */}
|
{/* Close Button */}
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={this.props.onCloseFullscreen}
|
onClick={this.props.onCloseFullscreen}
|
||||||
|
aria-label="Vollbild schließen"
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 16,
|
top: 16,
|
||||||
@@ -241,6 +279,13 @@ class Images extends Component {
|
|||||||
{this.state.pics[this.state.mainPic] && (
|
{this.state.pics[this.state.mainPic] && (
|
||||||
<CardMedia
|
<CardMedia
|
||||||
component="img"
|
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={{
|
sx={{
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
width: '90vw',
|
width: '90vw',
|
||||||
@@ -294,6 +339,13 @@ class Images extends Component {
|
|||||||
<CardMedia
|
<CardMedia
|
||||||
component="img"
|
component="img"
|
||||||
height="60"
|
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={{
|
sx={{
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
|||||||
@@ -24,10 +24,14 @@ class LanguageSwitcher extends Component {
|
|||||||
this.setState({ anchorEl: null });
|
this.setState({ anchorEl: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
handleLanguageChange = (language) => {
|
handleLanguageChange = async (language) => {
|
||||||
const { languageContext } = this.props;
|
const { languageContext } = this.props;
|
||||||
if (languageContext) {
|
if (languageContext) {
|
||||||
languageContext.changeLanguage(language);
|
try {
|
||||||
|
await languageContext.changeLanguage(language);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to change language:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.handleClose();
|
this.handleClose();
|
||||||
};
|
};
|
||||||
@@ -56,6 +60,7 @@ class LanguageSwitcher extends Component {
|
|||||||
'ru': () => import('country-flag-icons/react/3x2').then(m => m.RU),
|
'ru': () => import('country-flag-icons/react/3x2').then(m => m.RU),
|
||||||
'sk': () => import('country-flag-icons/react/3x2').then(m => m.SK),
|
'sk': () => import('country-flag-icons/react/3x2').then(m => m.SK),
|
||||||
'sl': () => import('country-flag-icons/react/3x2').then(m => m.SI),
|
'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),
|
'sr': () => import('country-flag-icons/react/3x2').then(m => m.RS),
|
||||||
'sv': () => import('country-flag-icons/react/3x2').then(m => m.SE),
|
'sv': () => import('country-flag-icons/react/3x2').then(m => m.SE),
|
||||||
'tr': () => import('country-flag-icons/react/3x2').then(m => m.TR),
|
'tr': () => import('country-flag-icons/react/3x2').then(m => m.TR),
|
||||||
@@ -126,8 +131,8 @@ class LanguageSwitcher extends Component {
|
|||||||
const { languageContext } = this.props;
|
const { languageContext } = this.props;
|
||||||
|
|
||||||
if (anchorEl && !prevState.anchorEl && languageContext) {
|
if (anchorEl && !prevState.anchorEl && languageContext) {
|
||||||
// Menu just opened, lazy load all flags
|
// Menu just opened, lazy load flags for all languages (not just available ones)
|
||||||
languageContext.availableLanguages.forEach(lang => {
|
languageContext.allLanguages.forEach(lang => {
|
||||||
if (!this.state.loadedFlags[lang]) {
|
if (!this.state.loadedFlags[lang]) {
|
||||||
this.loadFlagComponent(lang);
|
this.loadFlagComponent(lang);
|
||||||
}
|
}
|
||||||
@@ -153,6 +158,7 @@ class LanguageSwitcher extends Component {
|
|||||||
'ru': 'RU',
|
'ru': 'RU',
|
||||||
'sk': 'SK',
|
'sk': 'SK',
|
||||||
'sl': 'SI',
|
'sl': 'SI',
|
||||||
|
'sq': 'AL',
|
||||||
'sr': 'RS',
|
'sr': 'RS',
|
||||||
'sv': 'SE',
|
'sv': 'SE',
|
||||||
'tr': 'TR',
|
'tr': 'TR',
|
||||||
@@ -180,6 +186,7 @@ class LanguageSwitcher extends Component {
|
|||||||
'ru': 'Русский',
|
'ru': 'Русский',
|
||||||
'sk': 'Slovenčina',
|
'sk': 'Slovenčina',
|
||||||
'sl': 'Slovenščina',
|
'sl': 'Slovenščina',
|
||||||
|
'sq': 'Shqip',
|
||||||
'sr': 'Српски',
|
'sr': 'Српски',
|
||||||
'sv': 'Svenska',
|
'sv': 'Svenska',
|
||||||
'tr': 'Türkçe',
|
'tr': 'Türkçe',
|
||||||
@@ -197,7 +204,7 @@ class LanguageSwitcher extends Component {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { currentLanguage, availableLanguages } = languageContext;
|
const { currentLanguage, allLanguages } = languageContext;
|
||||||
const open = Boolean(anchorEl);
|
const open = Boolean(anchorEl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -237,29 +244,31 @@ class LanguageSwitcher extends Component {
|
|||||||
horizontal: 'right',
|
horizontal: 'right',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{availableLanguages.map((language) => (
|
{allLanguages.map((language) => {
|
||||||
<MenuItem
|
return (
|
||||||
key={language}
|
<MenuItem
|
||||||
onClick={() => this.handleLanguageChange(language)}
|
key={language}
|
||||||
selected={language === currentLanguage}
|
onClick={() => this.handleLanguageChange(language)}
|
||||||
sx={{
|
selected={language === currentLanguage}
|
||||||
minWidth: 160,
|
sx={{
|
||||||
display: 'flex',
|
minWidth: 160,
|
||||||
justifyContent: 'space-between',
|
display: 'flex',
|
||||||
gap: 2
|
justifyContent: 'space-between',
|
||||||
}}
|
gap: 2
|
||||||
>
|
}}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
>
|
||||||
{this.getLanguageFlag(language)}
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Typography variant="body2">
|
{this.getLanguageFlag(language)}
|
||||||
{this.getLanguageName(language)}
|
<Typography variant="body2">
|
||||||
|
{this.getLanguageName(language)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
|
||||||
|
{this.getLanguageLabel(language)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</MenuItem>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
|
);
|
||||||
{this.getLanguageLabel(language)}
|
})}
|
||||||
</Typography>
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Menu>
|
</Menu>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import CartSyncDialog from './CartSyncDialog.js';
|
|||||||
import { localAndArchiveServer, mergeCarts } from '../utils/cartUtils.js';
|
import { localAndArchiveServer, mergeCarts } from '../utils/cartUtils.js';
|
||||||
import config from '../config.js';
|
import config from '../config.js';
|
||||||
import { withI18n } from '../i18n/withTranslation.js';
|
import { withI18n } from '../i18n/withTranslation.js';
|
||||||
|
import GoogleIcon from '@mui/icons-material/Google';
|
||||||
|
|
||||||
// Lazy load GoogleAuthProvider
|
// Lazy load GoogleAuthProvider
|
||||||
const GoogleAuthProvider = lazy(() => import('../providers/GoogleAuthProvider.js'));
|
const GoogleAuthProvider = lazy(() => import('../providers/GoogleAuthProvider.js'));
|
||||||
@@ -171,30 +172,22 @@ export class LoginComponent extends Component {
|
|||||||
|
|
||||||
handleLogin = () => {
|
handleLogin = () => {
|
||||||
const { email, password } = this.state;
|
const { email, password } = this.state;
|
||||||
const { socket, location, navigate } = this.props;
|
const { location, navigate } = this.props;
|
||||||
|
|
||||||
if (!email || !password) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.validateEmail(email)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ loading: true, error: '' });
|
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);
|
console.log('LoginComponent: verifyUser', response);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
sessionStorage.setItem('user', JSON.stringify(response.user));
|
sessionStorage.setItem('user', JSON.stringify(response.user));
|
||||||
@@ -216,9 +209,9 @@ export class LoginComponent extends Component {
|
|||||||
const serverCartArr = newCart ? Object.values(newCart) : [];
|
const serverCartArr = newCart ? Object.values(newCart) : [];
|
||||||
|
|
||||||
if (serverCartArr.length === 0) {
|
if (serverCartArr.length === 0) {
|
||||||
if (socket && socket.connected) {
|
|
||||||
socket.emit('updateCart', window.cart);
|
window.socketManager.emit('updateCart', window.cart);
|
||||||
}
|
|
||||||
this.handleClose();
|
this.handleClose();
|
||||||
dispatchLoginEvent();
|
dispatchLoginEvent();
|
||||||
} else if (localCartArr.length === 0 && serverCartArr.length > 0) {
|
} else if (localCartArr.length === 0 && serverCartArr.length > 0) {
|
||||||
@@ -245,7 +238,7 @@ export class LoginComponent extends Component {
|
|||||||
} else {
|
} else {
|
||||||
this.setState({
|
this.setState({
|
||||||
loading: false,
|
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 = () => {
|
handleRegister = () => {
|
||||||
const { email, password, confirmPassword } = this.state;
|
const { email, password, confirmPassword } = this.state;
|
||||||
const { socket } = this.props;
|
|
||||||
|
|
||||||
if (!email || !password || !confirmPassword) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.validateEmail(email)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 8) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ loading: true, error: '' });
|
this.setState({ loading: true, error: '' });
|
||||||
|
|
||||||
|
|
||||||
// Call createUser socket endpoint
|
window.socketManager.emit('createUser', { email, password }, (response) => {
|
||||||
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) => {
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
this.setState({
|
this.setState({
|
||||||
loading: false,
|
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
|
tabValue: 0 // Switch to login tab
|
||||||
});
|
});
|
||||||
} else {
|
} 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({
|
this.setState({
|
||||||
loading: false,
|
loading: false,
|
||||||
error: response.message || 'Registrierung fehlgeschlagen'
|
error: errorMessage
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -311,22 +303,7 @@ export class LoginComponent extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleLogout = () => {
|
handleLogout = () => {
|
||||||
if (!this.props.socket || !this.props.socket.connected) {
|
window.socketManager.emit('logout', (response) => {
|
||||||
// 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) => {
|
|
||||||
if(response.success){
|
if(response.success){
|
||||||
sessionStorage.removeItem('user');
|
sessionStorage.removeItem('user');
|
||||||
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
||||||
@@ -343,22 +320,21 @@ export class LoginComponent extends Component {
|
|||||||
|
|
||||||
handleForgotPassword = () => {
|
handleForgotPassword = () => {
|
||||||
const { email } = this.state;
|
const { email } = this.state;
|
||||||
const { socket } = this.props;
|
|
||||||
|
|
||||||
if (!email) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.validateEmail(email)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ loading: true, error: '' });
|
this.setState({ loading: true, error: '' });
|
||||||
|
|
||||||
// Call resetPassword socket endpoint
|
|
||||||
socket.emit('resetPassword', {
|
window.socketManager.emit('resetPassword', {
|
||||||
email,
|
email,
|
||||||
domain: window.location.origin
|
domain: window.location.origin
|
||||||
}, (response) => {
|
}, (response) => {
|
||||||
@@ -366,12 +342,12 @@ export class LoginComponent extends Component {
|
|||||||
if (response.success) {
|
if (response.success) {
|
||||||
this.setState({
|
this.setState({
|
||||||
loading: false,
|
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 {
|
} else {
|
||||||
this.setState({
|
this.setState({
|
||||||
loading: false,
|
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
|
// Google login functionality
|
||||||
handleGoogleLoginSuccess = (credentialResponse) => {
|
handleGoogleLoginSuccess = (credentialResponse) => {
|
||||||
const { socket, location, navigate } = this.props;
|
const { location, navigate } = this.props;
|
||||||
this.setState({ loading: true, error: '' });
|
this.setState({ loading: true, error: '' });
|
||||||
console.log('beforeG',credentialResponse)
|
console.log('beforeG',credentialResponse)
|
||||||
|
|
||||||
|
window.socketManager.emit('verifyGoogleUser', { credential: credentialResponse.credential }, (response) => {
|
||||||
|
|
||||||
socket.emit('verifyGoogleUser', { credential: credentialResponse.credential }, (response) => {
|
|
||||||
console.log('google respo',response);
|
console.log('google respo',response);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
sessionStorage.setItem('user', JSON.stringify(response.user));
|
sessionStorage.setItem('user', JSON.stringify(response.user));
|
||||||
@@ -407,7 +381,7 @@ export class LoginComponent extends Component {
|
|||||||
const serverCartArr = newCart ? Object.values(newCart) : [];
|
const serverCartArr = newCart ? Object.values(newCart) : [];
|
||||||
|
|
||||||
if (serverCartArr.length === 0) {
|
if (serverCartArr.length === 0) {
|
||||||
socket.emit('updateCart', window.cart);
|
window.socketManager.emit('updateCart', window.cart);
|
||||||
this.handleClose();
|
this.handleClose();
|
||||||
dispatchLoginEvent();
|
dispatchLoginEvent();
|
||||||
} else if (localCartArr.length === 0 && serverCartArr.length > 0) {
|
} else if (localCartArr.length === 0 && serverCartArr.length > 0) {
|
||||||
@@ -434,7 +408,7 @@ export class LoginComponent extends Component {
|
|||||||
} else {
|
} else {
|
||||||
this.setState({
|
this.setState({
|
||||||
loading: false,
|
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
|
showGoogleAuth: false // Reset Google auth state on failed login
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -444,7 +418,7 @@ export class LoginComponent extends Component {
|
|||||||
handleGoogleLoginError = (error) => {
|
handleGoogleLoginError = (error) => {
|
||||||
console.error('Google Login Error:', error);
|
console.error('Google Login Error:', error);
|
||||||
this.setState({
|
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
|
showGoogleAuth: false, // Reset Google auth state on error
|
||||||
loading: false
|
loading: false
|
||||||
});
|
});
|
||||||
@@ -457,7 +431,7 @@ export class LoginComponent extends Component {
|
|||||||
localAndArchiveServer(localCartSync, serverCartSync);
|
localAndArchiveServer(localCartSync, serverCartSync);
|
||||||
break;
|
break;
|
||||||
case 'deleteServer':
|
case 'deleteServer':
|
||||||
this.props.socket.emit('updateCart', window.cart)
|
window.socketManager.emit('updateCart', window.cart)
|
||||||
break;
|
break;
|
||||||
case 'useServer':
|
case 'useServer':
|
||||||
window.cart = serverCartSync;
|
window.cart = serverCartSync;
|
||||||
@@ -641,25 +615,26 @@ export class LoginComponent extends Component {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showGoogleAuth && (
|
{showGoogleAuth && (
|
||||||
<Suspense fallback={
|
<Suspense fallback={
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<PersonIcon />}
|
startIcon={<GoogleIcon />}
|
||||||
sx={{ width: '100%', backgroundColor: '#4285F4', color: 'white' }}
|
disabled
|
||||||
>
|
fullWidth
|
||||||
Mit Google anmelden
|
style={{backgroundColor: '#4285F4', color: 'white' }}
|
||||||
</Button>
|
>
|
||||||
}>
|
Loading...
|
||||||
<GoogleAuthProvider clientId={config.googleClientId}>
|
</Button>
|
||||||
<GoogleLoginButton
|
}>
|
||||||
onSuccess={this.handleGoogleLoginSuccess}
|
<GoogleAuthProvider clientId={config.googleClientId}>
|
||||||
onError={this.handleGoogleLoginError}
|
<GoogleLoginButton
|
||||||
text="Mit Google anmelden"
|
onSuccess={this.handleGoogleLoginSuccess}
|
||||||
style={{ width: '100%', backgroundColor: '#4285F4' }}
|
onError={this.handleGoogleLoginError}
|
||||||
autoInitiate={true}
|
style={{ width: '100%', backgroundColor: '#4285F4' }}
|
||||||
/>
|
autoInitiate={true}
|
||||||
</GoogleAuthProvider>
|
/>
|
||||||
</Suspense>
|
</GoogleAuthProvider>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -722,7 +697,7 @@ export class LoginComponent extends Component {
|
|||||||
{tabValue === 1 && (
|
{tabValue === 1 && (
|
||||||
<TextField
|
<TextField
|
||||||
margin="dense"
|
margin="dense"
|
||||||
label="Passwort bestätigen"
|
label={this.props.t ? this.props.t('auth.confirmPassword') : 'Passwort bestätigen'}
|
||||||
type="password"
|
type="password"
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@@ -12,90 +12,170 @@ import SharedCarousel from "./SharedCarousel.js";
|
|||||||
import { getCombinedAnimatedBorderStyles } from "../utils/animatedBorderStyles.js";
|
import { getCombinedAnimatedBorderStyles } from "../utils/animatedBorderStyles.js";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
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' }}>
|
||||||
|
{index === 0 && pageType === "filiale" && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-55px',
|
||||||
|
left: '-45px',
|
||||||
|
width: '180px',
|
||||||
|
height: '180px',
|
||||||
|
zIndex: 999,
|
||||||
|
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' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{index === 1 && pageType === "filiale" && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '-45px',
|
||||||
|
right: '-65px',
|
||||||
|
width: '180px',
|
||||||
|
height: '180px',
|
||||||
|
zIndex: 999,
|
||||||
|
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' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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}
|
||||||
|
to={box.link}
|
||||||
|
sx={{
|
||||||
|
p: 0,
|
||||||
|
textDecoration: "none",
|
||||||
|
color: "text.primary",
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
height: { xs: 150, sm: 200, md: 300 },
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
boxShadow: 10,
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
"&: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, 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 MainPageLayout = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const currentPath = location.pathname;
|
const currentPath = location.pathname;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [starHovered, setStarHovered] = React.useState(false);
|
||||||
|
const translatedContent = {
|
||||||
|
showUsPhoto: t('sections.showUsPhoto'),
|
||||||
|
selectSeedRate: t('sections.selectSeedRate'),
|
||||||
|
indoorSeason: t('sections.indoorSeason')
|
||||||
|
};
|
||||||
|
|
||||||
// Determine which page we're on
|
|
||||||
const isHome = currentPath === "/";
|
const isHome = currentPath === "/";
|
||||||
const isAktionen = currentPath === "/aktionen";
|
const isAktionen = currentPath === "/aktionen";
|
||||||
const isFiliale = currentPath === "/filiale";
|
const isFiliale = currentPath === "/filiale";
|
||||||
|
|
||||||
// Get navigation config based on current page
|
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 = () => {
|
const getNavigationConfig = () => {
|
||||||
if (isHome) {
|
if (isHome) return { leftNav: { text: t('navigation.aktionen'), link: "/aktionen" }, rightNav: { text: t('navigation.filiale'), link: "/filiale" } };
|
||||||
return {
|
if (isAktionen) return { leftNav: { text: t('navigation.filiale'), link: "/filiale" }, rightNav: { text: t('navigation.home'), link: "/" } };
|
||||||
leftNav: { text: t('navigation.aktionen'), link: "/aktionen" },
|
if (isFiliale) return { leftNav: { text: t('navigation.home'), link: "/" }, rightNav: { 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 };
|
return { leftNav: null, rightNav: null };
|
||||||
};
|
};
|
||||||
|
|
||||||
const allTitles = {
|
const allTitles = {
|
||||||
home: t('titles.home'),
|
home: t('titles.home'),
|
||||||
aktionen: t('titles.aktionen'),
|
aktionen: t('titles.aktionen'),
|
||||||
filiale: t('titles.filiale')
|
filiale: t('titles.filiale')
|
||||||
};
|
};
|
||||||
|
|
||||||
// Define all content boxes for layered rendering
|
|
||||||
const allContentBoxes = {
|
const allContentBoxes = {
|
||||||
home: [
|
home: [
|
||||||
{
|
{ title: t('sections.seeds'), image: "/assets/images/seeds.avif", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" },
|
||||||
title: t('sections.seeds'),
|
{ title: t('sections.konfigurator'), image: "/assets/images/konfigurator.avif", bgcolor: "#e8f5d6", link: "/Konfigurator" }
|
||||||
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: [
|
aktionen: [
|
||||||
{
|
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/presseverleih" },
|
||||||
title: t('sections.oilPress'),
|
{ title: t('sections.thcTest'), image: "/assets/images/purpl.jpg", bgcolor: "#e8f5d6", link: "/thc-test" }
|
||||||
image: "/assets/images/presse.jpg",
|
|
||||||
bgcolor: "#e1f0d3",
|
|
||||||
link: "/presseverleih"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('sections.thcTest'),
|
|
||||||
image: "/assets/images/purpl.jpg",
|
|
||||||
bgcolor: "#e8f5d6",
|
|
||||||
link: "/thc-test"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
filiale: [
|
filiale: [
|
||||||
{
|
{ title: t('sections.address1'), image: "/assets/images/filiale1.jpg", bgcolor: "#e1f0d3", link: "/filiale" },
|
||||||
title: t('sections.address1'),
|
{ title: t('sections.address2'), image: "/assets/images/filiale2.jpg", bgcolor: "#e8f5d6", link: "/filiale" }
|
||||||
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) => {
|
const getOpacity = (pageType) => {
|
||||||
if (pageType === "home" && isHome) return 1;
|
if (pageType === "home" && isHome) return 1;
|
||||||
if (pageType === "aktionen" && isAktionen) return 1;
|
if (pageType === "aktionen" && isAktionen) return 1;
|
||||||
@@ -104,8 +184,6 @@ const MainPageLayout = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navConfig = getNavigationConfig();
|
const navConfig = getNavigationConfig();
|
||||||
|
|
||||||
// Navigation text mapping for translation
|
|
||||||
const navTexts = [
|
const navTexts = [
|
||||||
{ key: 'aktionen', text: t('navigation.aktionen'), link: '/aktionen' },
|
{ key: 'aktionen', text: t('navigation.aktionen'), link: '/aktionen' },
|
||||||
{ key: 'filiale', text: t('navigation.filiale'), link: '/filiale' },
|
{ key: 'filiale', text: t('navigation.filiale'), link: '/filiale' },
|
||||||
@@ -115,201 +193,61 @@ const MainPageLayout = () => {
|
|||||||
return (
|
return (
|
||||||
<Container maxWidth="lg" sx={{ py: 2 }}>
|
<Container maxWidth="lg" sx={{ py: 2 }}>
|
||||||
<style>{getCombinedAnimatedBorderStyles(['seeds', 'cutlings'])}</style>
|
<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" } }}>
|
||||||
{/* Main Navigation Header */}
|
<Box sx={{ display: { xs: "block", sm: "none" }, mb: { xs: 2, sm: 0 }, width: "100%", textAlign: "center", position: "relative" }}>
|
||||||
<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]) => (
|
{Object.entries(allTitles).map(([pageType, title]) => (
|
||||||
<Typography
|
<Typography key={pageType} variant="h3" component="h1" sx={{
|
||||||
key={pageType}
|
fontFamily: "SwashingtonCP", fontSize: { xs: "2.125rem", sm: "2.125rem", md: "3rem" }, textAlign: "center", color: "primary.main",
|
||||||
variant="h3"
|
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)", transition: "opacity 0.5s ease-in-out", opacity: getOpacity(pageType),
|
||||||
component="h1"
|
position: pageType === "home" ? "relative" : "absolute", top: pageType !== "home" ? 0 : "auto", left: pageType !== "home" ? 0 : "auto",
|
||||||
sx={{
|
transform: "none", width: "100%", pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none",
|
||||||
fontFamily: "SwashingtonCP",
|
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, wordWrap: "break-word", hyphens: "auto"
|
||||||
fontSize: { xs: "2.125rem", sm: "2.125rem", md: "3rem" },
|
}}>{title}</Typography>
|
||||||
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>
|
||||||
|
<Box sx={{ display: { xs: "flex", sm: "contents" }, width: { xs: "100%", sm: "auto" }, justifyContent: { xs: "space-between", sm: "initial" }, alignItems: "center" }}>
|
||||||
{/* Navigation container for portrait phones */}
|
<Box sx={{ display: "flex", alignItems: "center", flexShrink: 0, justifyContent: "flex-start", position: "relative", mr: { xs: 0, sm: 2 } }}>
|
||||||
<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) => {
|
{navTexts.map((navItem, index) => {
|
||||||
const isActive = navConfig.leftNav && navConfig.leftNav.text === navItem.text;
|
const isActive = navConfig.leftNav && navConfig.leftNav.text === navItem.text;
|
||||||
const link = navItem.link;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box key={navItem.key} component={Link} to={navItem.link} sx={{
|
||||||
key={navItem.key}
|
display: "flex", alignItems: "center", textDecoration: "none", color: "inherit", transition: "all 0.3s ease",
|
||||||
component={Link}
|
opacity: isActive ? 1 : 0, position: index === 0 ? "relative" : "absolute", left: index !== 0 ? 0 : "auto",
|
||||||
to={link}
|
pointerEvents: isActive ? "auto" : "none", "&:hover": { transform: "translateX(-5px)", color: "primary.main" }
|
||||||
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 }} />
|
<ChevronLeft sx={{ fontSize: "2rem", mr: 1 }} />
|
||||||
<Typography
|
<Typography sx={{
|
||||||
sx={{
|
fontFamily: "SwashingtonCP", fontSize: { xs: "1.25rem", sm: "1.25rem", md: "2.125rem" },
|
||||||
fontFamily: "SwashingtonCP",
|
textShadow: "2px 2px 4px rgba(0, 0, 0, 0.3)", lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, whiteSpace: "nowrap"
|
||||||
fontSize: { xs: "1.25rem", sm: "1.25rem", md: "2.125rem" },
|
}}>{navItem.text}</Typography>
|
||||||
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>
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, display: { xs: "none", sm: "flex" }, justifyContent: "center", alignItems: "center", px: 0, position: "relative", minWidth: 0 }}>
|
||||||
{/* 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]) => (
|
{Object.entries(allTitles).map(([pageType, title]) => (
|
||||||
<Typography
|
<Typography key={pageType} variant="h3" component="h1" sx={{
|
||||||
key={pageType}
|
fontFamily: "SwashingtonCP", fontSize: { xs: "2.125rem", sm: "2.125rem", md: "3rem" }, textAlign: "center", color: "primary.main",
|
||||||
variant="h3"
|
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)", transition: "opacity 0.5s ease-in-out", opacity: getOpacity(pageType),
|
||||||
component="h1"
|
position: pageType === "home" ? "relative" : "absolute", top: pageType !== "home" ? "50%" : "auto", left: pageType !== "home" ? "50%" : "auto",
|
||||||
sx={{
|
transform: pageType !== "home" ? "translate(-50%, -50%)" : "none", width: "100%", pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none",
|
||||||
fontFamily: "SwashingtonCP",
|
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, wordWrap: "break-word", hyphens: "auto"
|
||||||
fontSize: { xs: "2.125rem", sm: "2.125rem", md: "3rem" },
|
}}>{title}</Typography>
|
||||||
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>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", flexShrink: 0, justifyContent: "flex-end", position: "relative", ml: { xs: 0, sm: 2 } }}>
|
||||||
{/* 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) => {
|
{navTexts.map((navItem, index) => {
|
||||||
const isActive = navConfig.rightNav && navConfig.rightNav.text === navItem.text;
|
const isActive = navConfig.rightNav && navConfig.rightNav.text === navItem.text;
|
||||||
const link = navItem.link;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box key={navItem.key} component={Link} to={navItem.link} sx={{
|
||||||
key={navItem.key}
|
display: "flex", alignItems: "center", textDecoration: "none", color: "inherit", transition: "all 0.3s ease",
|
||||||
component={Link}
|
opacity: isActive ? 1 : 0, position: index === 0 ? "relative" : "absolute", right: index !== 0 ? 0 : "auto",
|
||||||
to={link}
|
pointerEvents: isActive ? "auto" : "none", "&:hover": { transform: "translateX(5px)", color: "primary.main" }
|
||||||
sx={{
|
}}>
|
||||||
display: "flex",
|
<Typography sx={{
|
||||||
alignItems: "center",
|
fontFamily: "SwashingtonCP", fontSize: { xs: "1.25rem", sm: "1.25rem", md: "2.125rem" },
|
||||||
textDecoration: "none",
|
textShadow: "2px 2px 4px rgba(0, 0, 0, 0.3)", lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, whiteSpace: "nowrap"
|
||||||
color: "inherit",
|
}}>{navItem.text}</Typography>
|
||||||
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 }} />
|
<ChevronRight sx={{ fontSize: "2rem", ml: 1 }} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -317,91 +255,30 @@ const MainPageLayout = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Content Boxes - Layered rendering */}
|
|
||||||
<Box sx={{ position: "relative", mb: 4 }}>
|
<Box sx={{ position: "relative", mb: 4 }}>
|
||||||
{Object.entries(allContentBoxes).map(([pageType, contentBoxes]) => (
|
{Object.entries(allContentBoxes).map(([pageType, contentBoxes]) => (
|
||||||
<Grid
|
<Grid key={pageType} container spacing={0} sx={{
|
||||||
key={pageType}
|
transition: "opacity 0.5s ease-in-out", opacity: getOpacity(pageType),
|
||||||
container
|
position: pageType === "home" ? "relative" : "absolute", top: 0, left: 0, width: "100%", pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none"
|
||||||
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) => (
|
{contentBoxes.map((box, index) => (
|
||||||
<Grid key={`${pageType}-${index}`} item xs={12} sm={6} sx={{ p: 2, width: "50%" }}>
|
<ContentBox
|
||||||
<div className={`animated-border-card ${index === 0 ? 'seeds-card' : 'cutlings-card'}`}>
|
key={`${pageType}-${index}`}
|
||||||
<Paper
|
box={box}
|
||||||
component={Link}
|
index={index}
|
||||||
to={box.link}
|
pageType={pageType}
|
||||||
sx={{
|
starHovered={starHovered}
|
||||||
p: 0,
|
setStarHovered={setStarHovered}
|
||||||
textDecoration: "none",
|
opacity={getOpacity(pageType)}
|
||||||
color: "text.primary",
|
translatedContent={translatedContent}
|
||||||
borderRadius: 2,
|
/>
|
||||||
overflow: "hidden",
|
|
||||||
height: { xs: 150, sm: 200, md: 300 },
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
boxShadow: 10,
|
|
||||||
transition: "all 0.3s ease",
|
|
||||||
"&:hover": {
|
|
||||||
transform: "translateY(-5px)",
|
|
||||||
boxShadow: 20,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
</div>
|
|
||||||
</Grid>
|
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Shared Carousel */}
|
|
||||||
<SharedCarousel />
|
<SharedCarousel />
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MainPageLayout;
|
export default MainPageLayout;
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||||
import SocketContext from '../contexts/SocketContext.js';
|
|
||||||
|
|
||||||
class PaymentSuccess extends Component {
|
class PaymentSuccess extends Component {
|
||||||
static contextType = SocketContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -73,19 +72,10 @@ class PaymentSuccess extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
checkMolliePaymentStatus = (paymentId) => {
|
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) {
|
if (response.success) {
|
||||||
console.log('Payment Status:', response.payment.status);
|
console.log('Payment Status:', response.payment.status);
|
||||||
console.log('Is Paid:', response.payment.isPaid);
|
console.log('Is Paid:', response.payment.isPaid);
|
||||||
@@ -108,8 +98,8 @@ class PaymentSuccess extends Component {
|
|||||||
profileUrl.searchParams.set('mollie_order_id', response.order.orderId.toString());
|
profileUrl.searchParams.set('mollie_order_id', response.order.orderId.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set hash to cart tab
|
// Set hash based on payment success: orders for successful payments, cart for failed payments
|
||||||
profileUrl.hash = '#cart';
|
profileUrl.hash = response.payment.isPaid ? '#orders' : '#cart';
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
redirectUrl: profileUrl.pathname + profileUrl.search + profileUrl.hash,
|
redirectUrl: profileUrl.pathname + profileUrl.search + profileUrl.hash,
|
||||||
|
|||||||
286
src/components/PhotoUpload.js
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Grid,
|
||||||
|
Alert
|
||||||
|
} from '@mui/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) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
files: [],
|
||||||
|
previews: [],
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
this.fileInputRef = React.createRef();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileSelect = (event) => {
|
||||||
|
const selectedFiles = Array.from(event.target.files);
|
||||||
|
const maxFiles = this.props.maxFiles || 5;
|
||||||
|
const maxSize = this.props.maxSize || 50 * 1024 * 1024; // 50MB default - will be compressed
|
||||||
|
|
||||||
|
// Validate file count
|
||||||
|
if (this.state.files.length + selectedFiles.length > maxFiles) {
|
||||||
|
this.setState({
|
||||||
|
error: this.props.t("productDialogs.photoUploadErrorMaxFiles", { max: maxFiles })
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file types and sizes
|
||||||
|
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
const validFiles = [];
|
||||||
|
const newPreviews = [];
|
||||||
|
|
||||||
|
for (const file of selectedFiles) {
|
||||||
|
if (!validTypes.includes(file.type)) {
|
||||||
|
this.setState({
|
||||||
|
error: this.props.t("productDialogs.photoUploadErrorFileType")
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
this.setState({
|
||||||
|
error: this.props.t("productDialogs.photoUploadErrorFileSize", { maxSize: Math.round(maxSize / (1024 * 1024)) })
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
validFiles.push(file);
|
||||||
|
|
||||||
|
// Create preview and compress image
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
// Compress the image
|
||||||
|
this.compressImage(e.target.result, file.name, (compressedFile) => {
|
||||||
|
newPreviews.push({
|
||||||
|
file: compressedFile,
|
||||||
|
preview: e.target.result,
|
||||||
|
name: file.name,
|
||||||
|
originalSize: file.size,
|
||||||
|
compressedSize: compressedFile.size
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newPreviews.length === validFiles.length) {
|
||||||
|
const compressedFiles = newPreviews.map(p => p.file);
|
||||||
|
this.setState(prevState => ({
|
||||||
|
files: [...prevState.files, ...compressedFiles],
|
||||||
|
previews: [...prevState.previews, ...newPreviews],
|
||||||
|
error: null
|
||||||
|
}), () => {
|
||||||
|
// Notify parent component
|
||||||
|
if (this.props.onChange) {
|
||||||
|
this.props.onChange(this.state.files);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset input
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
handleRemoveFile = (index) => {
|
||||||
|
this.setState(prevState => {
|
||||||
|
const newFiles = prevState.files.filter((_, i) => i !== index);
|
||||||
|
const newPreviews = prevState.previews.filter((_, i) => i !== index);
|
||||||
|
|
||||||
|
// Notify parent component
|
||||||
|
if (this.props.onChange) {
|
||||||
|
this.props.onChange(newFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
files: newFiles,
|
||||||
|
previews: newPreviews
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
compressImage = (dataURL, fileName, callback) => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
// Calculate new dimensions (max 1920x1080 for submission)
|
||||||
|
const maxWidth = 1920;
|
||||||
|
const maxHeight = 1080;
|
||||||
|
let { width, height } = img;
|
||||||
|
|
||||||
|
if (width > height) {
|
||||||
|
if (width > maxWidth) {
|
||||||
|
height = (height * maxWidth) / width;
|
||||||
|
width = maxWidth;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (height > maxHeight) {
|
||||||
|
width = (width * maxHeight) / height;
|
||||||
|
height = maxHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
// Draw and compress
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
// Convert to blob with compression
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
const compressedFile = new File([blob], fileName, {
|
||||||
|
type: 'image/jpeg',
|
||||||
|
lastModified: Date.now()
|
||||||
|
});
|
||||||
|
callback(compressedFile);
|
||||||
|
}, 'image/jpeg', 0.8); // 80% quality
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = dataURL;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method to reset the component
|
||||||
|
reset = () => {
|
||||||
|
this.setState({
|
||||||
|
files: [],
|
||||||
|
previews: [],
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also reset the file input
|
||||||
|
if (this.fileInputRef.current) {
|
||||||
|
this.fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { files, previews, error } = this.state;
|
||||||
|
const { disabled, label, t } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
|
||||||
|
{label || t("productDialogs.photoUploadLabelDefault")}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={this.fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={this.handleFileSelect}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<CloudUpload />}
|
||||||
|
onClick={() => this.fileInputRef.current?.click()}
|
||||||
|
disabled={disabled}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
{t("productDialogs.photoUploadSelect")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previews.length > 0 && (
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{previews.map((preview, index) => (
|
||||||
|
<Grid item xs={6} sm={4} md={3} key={index}>
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={preview.preview}
|
||||||
|
alt={preview.name}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100px',
|
||||||
|
objectFit: 'cover',
|
||||||
|
borderRadius: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => this.handleRemoveFile(index)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={t("productDialogs.photoUploadRemove")}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||||
|
color: 'white',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.9)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Delete fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 4,
|
||||||
|
left: 4,
|
||||||
|
right: 4,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||||
|
color: 'white',
|
||||||
|
p: 0.5,
|
||||||
|
borderRadius: 0.5,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{preview.name}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||||
|
{t("productDialogs.photoUploadSelectedFiles", { count: files.length })}
|
||||||
|
{previews.length > 0 && previews.some(p => p.originalSize && p.compressedSize) && (
|
||||||
|
<span style={{ marginLeft: '8px' }}>
|
||||||
|
{t("productDialogs.photoUploadCompressed")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(PhotoUpload);
|
||||||
@@ -7,9 +7,67 @@ import Typography from '@mui/material/Typography';
|
|||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import AddToCartButton from './AddToCartButton.js';
|
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';
|
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 {
|
class Product extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -26,26 +84,8 @@ class Product extends Component {
|
|||||||
this.state = {image:window.smallPicCache[bildId],loading:false, error: false}
|
this.state = {image:window.smallPicCache[bildId],loading:false, error: false}
|
||||||
}else{
|
}else{
|
||||||
this.state = {image: null, loading: true, error: false};
|
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);
|
||||||
if(res.success){
|
|
||||||
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
|
||||||
if (this._isMounted) {
|
|
||||||
this.setState({image: window.smallPicCache[bildId], loading: false});
|
|
||||||
} else {
|
|
||||||
this.state.image = window.smallPicCache[bildId];
|
|
||||||
this.state.loading = false;
|
|
||||||
}
|
|
||||||
}else{
|
|
||||||
console.log('Fehler beim Laden des Bildes:', res);
|
|
||||||
if (this._isMounted) {
|
|
||||||
this.setState({error: true, loading: false});
|
|
||||||
} else {
|
|
||||||
this.state.error = true;
|
|
||||||
this.state.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}else{
|
}else{
|
||||||
this.state = {image: null, loading: false, error: false};
|
this.state = {image: null, loading: false, error: false};
|
||||||
@@ -56,6 +96,31 @@ class Product extends Component {
|
|||||||
this._isMounted = true;
|
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/avif' }));
|
||||||
|
if (this._isMounted) {
|
||||||
|
this.setState({image: window.smallPicCache[bildId], loading: false});
|
||||||
|
} else {
|
||||||
|
this.state.image = window.smallPicCache[bildId];
|
||||||
|
this.state.loading = false;
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
console.log('Fehler beim Laden des Bildes:', res);
|
||||||
|
if (this._isMounted) {
|
||||||
|
this.setState({error: true, loading: false});
|
||||||
|
} else {
|
||||||
|
this.state.error = true;
|
||||||
|
this.state.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this._isMounted = false;
|
this._isMounted = false;
|
||||||
}
|
}
|
||||||
@@ -65,8 +130,25 @@ class Product extends Component {
|
|||||||
// In a real app, this would update a cart state in a parent component or Redux store
|
// 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() {
|
render() {
|
||||||
const {
|
const {
|
||||||
id, name, price, available, manufacturer, seoName,
|
id, name, price, available, manufacturer, seoName,
|
||||||
currency, vat, cGrundEinheit, fGrundPreis, thc,
|
currency, vat, cGrundEinheit, fGrundPreis, thc,
|
||||||
floweringWeeks,incoming, neu, weight, versandklasse, availableSupplier, komponenten
|
floweringWeeks,incoming, neu, weight, versandklasse, availableSupplier, komponenten
|
||||||
@@ -173,7 +255,7 @@ class Product extends Component {
|
|||||||
zIndex: 1000
|
zIndex: 1000
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
NEU
|
{this.props.t ? this.props.t('product.new') : 'NEU'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -240,20 +322,20 @@ class Product extends Component {
|
|||||||
transformOrigin: 'top left'
|
transformOrigin: 'top left'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{floweringWeeks} Wochen
|
{floweringWeeks} {this.props.t ? this.props.t('product.weeks') : 'Wochen'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
component={Link}
|
onClick={this.handleProductClick}
|
||||||
to={`/Artikel/${seoName}`}
|
sx={{
|
||||||
sx={{
|
flexGrow: 1,
|
||||||
flexGrow: 1,
|
display: 'flex',
|
||||||
display: 'flex',
|
flexDirection: 'column',
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'stretch',
|
alignItems: 'stretch',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
color: 'inherit'
|
color: 'inherit',
|
||||||
|
cursor: 'pointer'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
@@ -275,6 +357,14 @@ class Product extends Component {
|
|||||||
height={ window.innerWidth < 600 ? "240" : "180" }
|
height={ window.innerWidth < 600 ? "240" : "180" }
|
||||||
image="/assets/images/nopicture.jpg"
|
image="/assets/images/nopicture.jpg"
|
||||||
alt={name}
|
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={{
|
sx={{
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
borderTopLeftRadius: '8px',
|
borderTopLeftRadius: '8px',
|
||||||
@@ -288,6 +378,14 @@ class Product extends Component {
|
|||||||
height={ window.innerWidth < 600 ? "240" : "180" }
|
height={ window.innerWidth < 600 ? "240" : "180" }
|
||||||
image={this.state.image}
|
image={this.state.image}
|
||||||
alt={name}
|
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={{
|
sx={{
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
borderTopLeftRadius: '8px',
|
borderTopLeftRadius: '8px',
|
||||||
@@ -329,21 +427,50 @@ class Product extends Component {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<div style={{padding:'0px',margin:'0px',minHeight:'3.8em'}}>
|
<div style={{padding:'0px',margin:'0px'}}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h6"
|
variant="h6"
|
||||||
color="primary"
|
color="primary"
|
||||||
sx={{ mt: 'auto', fontWeight: 'bold', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
|
sx={{
|
||||||
>
|
fontWeight: 'bold',
|
||||||
<span>{new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price)}</span>
|
display: 'flex',
|
||||||
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>(incl. {vat}% USt.,*)</small>
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
</Typography>
|
>
|
||||||
{cGrundEinheit && fGrundPreis && fGrundPreis != price && (<Typography variant="body2" color="text.secondary" sx={{ m: 0,p: 0 }}>
|
<Box sx={{ position: 'relative', display: 'inline-block' }}>
|
||||||
({new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(fGrundPreis)}/{cGrundEinheit})
|
{this.props.rebate && this.props.rebate > 0 && (
|
||||||
</Typography> )}
|
<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> )}
|
||||||
</div>
|
</div>
|
||||||
{/*incoming*/}
|
{/*incoming*/}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -354,6 +481,7 @@ class Product extends Component {
|
|||||||
component={Link}
|
component={Link}
|
||||||
to={`/Artikel/${seoName}`}
|
to={`/Artikel/${seoName}`}
|
||||||
size="small"
|
size="small"
|
||||||
|
aria-label="Produktdetails anzeigen"
|
||||||
sx={{ mr: 1, color: 'text.secondary' }}
|
sx={{ mr: 1, color: 'text.secondary' }}
|
||||||
>
|
>
|
||||||
<ZoomInIcon />
|
<ZoomInIcon />
|
||||||
@@ -366,4 +494,10 @@ class Product extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default 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,19 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import SocketContext from '../contexts/SocketContext.js';
|
|
||||||
import ProductDetailPage from './ProductDetailPage.js';
|
import ProductDetailPage from './ProductDetailPage.js';
|
||||||
|
import { useProduct } from '../context/ProductContext.js';
|
||||||
|
|
||||||
// Wrapper component for individual product detail page with socket
|
|
||||||
const ProductDetailWithSocket = () => {
|
const ProductDetailWithSocket = () => {
|
||||||
const { seoName } = useParams();
|
const { seoName } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { setCurrentProduct } = useProduct();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SocketContext.Consumer>
|
<ProductDetailPage
|
||||||
{({socket,socketB}) => <ProductDetailPage seoName={seoName} navigate={navigate} location={location} socket={socket} socketB={socketB} />}
|
seoName={seoName}
|
||||||
</SocketContext.Consumer>
|
navigate={navigate}
|
||||||
|
location={location}
|
||||||
|
setCurrentProduct={setCurrentProduct}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProductDetailWithSocket;
|
export default ProductDetailWithSocket;
|
||||||
|
|||||||
@@ -47,6 +47,41 @@ class ProductFilters extends Component {
|
|||||||
window.addEventListener('resize', this.adjustPaperHeight);
|
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() {
|
componentWillUnmount() {
|
||||||
// Remove event listener when component unmounts
|
// Remove event listener when component unmounts
|
||||||
window.removeEventListener('resize', this.adjustPaperHeight);
|
window.removeEventListener('resize', this.adjustPaperHeight);
|
||||||
@@ -116,19 +151,6 @@ class ProductFilters extends Component {
|
|||||||
return attributeGroups;
|
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 = () => {
|
generateAttributeFilters = () => {
|
||||||
const filters = [];
|
const filters = [];
|
||||||
const sortedAttributeGroups = Object.values(this.state.attributeGroups)
|
const sortedAttributeGroups = Object.values(this.state.attributeGroups)
|
||||||
@@ -187,7 +209,7 @@ class ProductFilters extends Component {
|
|||||||
color: 'primary.main'
|
color: 'primary.main'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{this.props.dataParam}
|
{this.props.categoryName}
|
||||||
</Typography>
|
</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;
|
||||||
@@ -201,14 +201,14 @@ class ProductList extends Component {
|
|||||||
|
|
||||||
if (!isFiltered) {
|
if (!isFiltered) {
|
||||||
// No filters applied
|
// No filters applied
|
||||||
if (filteredCount === 0) return "0 Produkte";
|
if (filteredCount === 0) return this.props.t ? this.props.t('product.countDisplay.noProducts') : "0 Produkte";
|
||||||
if (filteredCount === 1) return "1 Produkt";
|
if (filteredCount === 1) return this.props.t ? this.props.t('product.countDisplay.oneProduct') : "1 Produkt";
|
||||||
return `${filteredCount} Produkte`;
|
return this.props.t ? this.props.t('product.countDisplay.multipleProducts', { count: filteredCount }) : `${filteredCount} Produkte`;
|
||||||
} else {
|
} else {
|
||||||
// Filters applied
|
// Filters applied
|
||||||
if (totalCount === 0) return "0 Produkte";
|
if (totalCount === 0) return this.props.t ? this.props.t('product.countDisplay.noProducts') : "0 Produkte";
|
||||||
if (totalCount === 1) return `${filteredCount} von 1 Produkt`;
|
if (totalCount === 1) return this.props.t ? this.props.t('product.countDisplay.filteredOneProduct', { filtered: filteredCount }) : `${filteredCount} von 1 Produkt`;
|
||||||
return `${filteredCount} von ${totalCount} Produkten`;
|
return this.props.t ? this.props.t('product.countDisplay.filteredProducts', { filtered: filteredCount, total: totalCount }) : `${filteredCount} von ${totalCount} Produkten`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,7 +399,7 @@ class ProductList extends Component {
|
|||||||
>
|
>
|
||||||
<MenuItem value={20}>20</MenuItem>
|
<MenuItem value={20}>20</MenuItem>
|
||||||
<MenuItem value={50}>50</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>
|
</Select>
|
||||||
</FormControl>
|
</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 } }}>
|
<Stack direction="row" spacing={2} sx={{ display: { xs: 'none', sm: 'flex' }, px: { xs: 1, sm: 0 } }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
{/*this.props.dataType == 'category' && (<>Kategorie: {this.props.dataParam}</>)}*/}
|
{/*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>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
|
||||||
{this.getProductCountText()}
|
{this.getProductCountText()}
|
||||||
@@ -454,7 +454,7 @@ class ProductList extends Component {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Product
|
<Product
|
||||||
id={product.id}
|
id={product.id}
|
||||||
name={product.name}
|
name={product.name}
|
||||||
seoName={product.seoName}
|
seoName={product.seoName}
|
||||||
@@ -471,11 +471,13 @@ class ProductList extends Component {
|
|||||||
floweringWeeks={product.floweringWeeks}
|
floweringWeeks={product.floweringWeeks}
|
||||||
versandklasse={product.versandklasse}
|
versandklasse={product.versandklasse}
|
||||||
weight={product.weight}
|
weight={product.weight}
|
||||||
socket={this.props.socket}
|
|
||||||
socketB={this.props.socketB}
|
|
||||||
pictureList={product.pictureList}
|
pictureList={product.pictureList}
|
||||||
availableSupplier={product.availableSupplier}
|
availableSupplier={product.availableSupplier}
|
||||||
komponenten={product.komponenten}
|
komponenten={product.komponenten}
|
||||||
|
rebate={product.rebate}
|
||||||
|
categoryId={product.kategorien ? product.kategorien.split(',')[0] : undefined}
|
||||||
|
priority={index < 6 ? 'high' : 'auto'}
|
||||||
|
t={this.props.t}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,231 +1,427 @@
|
|||||||
import React, { useContext, useEffect, useState } from "react";
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import ChevronLeft from "@mui/icons-material/ChevronLeft";
|
import ChevronLeft from "@mui/icons-material/ChevronLeft";
|
||||||
import ChevronRight from "@mui/icons-material/ChevronRight";
|
import ChevronRight from "@mui/icons-material/ChevronRight";
|
||||||
import CategoryBox from "./CategoryBox.js";
|
import CategoryBox from "./CategoryBox.js";
|
||||||
import SocketContext from "../contexts/SocketContext.js";
|
import ProductCarousel from "./ProductCarousel.js";
|
||||||
import { useCarousel } from "../contexts/CarouselContext.js";
|
import { withTranslation } from 'react-i18next';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { withLanguage } from '../i18n/withTranslation.js';
|
||||||
|
|
||||||
// Helper to process and set categories
|
const ITEM_WIDTH = 130 + 16; // 130px width + 16px gap
|
||||||
const processCategoryTree = (categoryTree) => {
|
const AUTO_SCROLL_SPEED = 0.5; // px per frame (~60fps, so ~30px/sec)
|
||||||
if (
|
const AUTOSCROLL_RESTART_DELAY = 5000; // 5 seconds of inactivity before restarting autoscroll
|
||||||
categoryTree &&
|
const SCROLLBAR_FLASH_DURATION = 3000; // 3 seconds to show the virtual scrollbar
|
||||||
categoryTree.id === 209 &&
|
|
||||||
Array.isArray(categoryTree.children)
|
class SharedCarousel extends React.Component {
|
||||||
) {
|
_isMounted = false;
|
||||||
return categoryTree.children;
|
categories = [];
|
||||||
} else {
|
originalCategories = [];
|
||||||
return [];
|
animationFrame = null;
|
||||||
|
autoScrollActive = true;
|
||||||
|
translateX = 0;
|
||||||
|
inactivityTimer = null;
|
||||||
|
scrollbarTimer = null;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const { i18n } = props;
|
||||||
|
|
||||||
|
// Don't load categories in constructor - will be loaded in componentDidMount with correct language
|
||||||
|
this.state = {
|
||||||
|
categories: [],
|
||||||
|
currentLanguage: (i18n && i18n.language) || 'de',
|
||||||
|
showScrollbar: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.carouselTrackRef = React.createRef();
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Check for cached data
|
componentDidMount() {
|
||||||
const getProductCache = () => {
|
this._isMounted = true;
|
||||||
if (typeof window !== "undefined" && window.productCache) {
|
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
|
||||||
return window.productCache;
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
typeof global !== "undefined" &&
|
|
||||||
global.window &&
|
|
||||||
global.window.productCache
|
|
||||||
) {
|
|
||||||
return global.window.productCache;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize categories
|
componentDidUpdate(prevProps) {
|
||||||
const initializeCategories = () => {
|
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
|
||||||
const productCache = getProductCache();
|
if (prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
|
||||||
|
this.setState({ categories: [] }, () => {
|
||||||
if (productCache && productCache["categoryTree_209"]) {
|
window.categoryService.get(209, this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
|
||||||
const cached = productCache["categoryTree_209"];
|
console.log("response", response);
|
||||||
if (cached.categoryTree) {
|
if (response.children && response.children.length > 0) {
|
||||||
return processCategoryTree(cached.categoryTree);
|
this.originalCategories = response.children;
|
||||||
}
|
this.categories = [...response.children, ...response.children];
|
||||||
}
|
this.setState({ categories: this.categories });
|
||||||
return [];
|
this.startAutoScroll();
|
||||||
};
|
|
||||||
|
|
||||||
const SharedCarousel = () => {
|
|
||||||
const { carouselRef, filteredCategories, setFilteredCategories, moveCarousel } = useCarousel();
|
|
||||||
const context = useContext(SocketContext);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [rootCategories, setRootCategories] = useState([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const initialCategories = initializeCategories();
|
|
||||||
setRootCategories(initialCategories);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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 }, (response) => {
|
|
||||||
if (response && response.categoryTree) {
|
|
||||||
// Store in cache
|
|
||||||
try {
|
|
||||||
if (!window.productCache) window.productCache = {};
|
|
||||||
window.productCache["categoryTree_209"] = {
|
|
||||||
categoryTree: response.categoryTree,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
}
|
||||||
setRootCategories(response.categoryTree.children || []);
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [context, context?.socket?.connected, rootCategories.length]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const filtered = rootCategories.filter(
|
|
||||||
(cat) => cat.id !== 689 && cat.id !== 706
|
|
||||||
);
|
|
||||||
setFilteredCategories(filtered);
|
|
||||||
}, [rootCategories, setFilteredCategories]);
|
|
||||||
|
|
||||||
// Create duplicated array for seamless scrolling
|
|
||||||
const displayCategories = [...filteredCategories, ...filteredCategories];
|
|
||||||
|
|
||||||
if (filteredCategories.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
componentWillUnmount() {
|
||||||
<Box sx={{ mt: 3 }}>
|
this._isMounted = false;
|
||||||
<Typography
|
this.stopAutoScroll();
|
||||||
variant="h4"
|
this.clearInactivityTimer();
|
||||||
component="h1"
|
this.clearScrollbarTimer();
|
||||||
sx={{
|
}
|
||||||
mb: 2,
|
|
||||||
fontFamily: "SwashingtonCP",
|
|
||||||
color: "primary.main",
|
|
||||||
textAlign: "center",
|
|
||||||
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('navigation.categories')}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<div
|
startAutoScroll = () => {
|
||||||
className="carousel-wrapper"
|
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={{
|
style={{
|
||||||
position: 'relative',
|
position: 'absolute',
|
||||||
overflow: 'hidden',
|
bottom: '5px',
|
||||||
width: '100%',
|
left: '50%',
|
||||||
maxWidth: '1200px',
|
transform: 'translateX(-50%)',
|
||||||
margin: '0 auto',
|
width: '200px',
|
||||||
padding: '0 20px',
|
height: '4px',
|
||||||
boxSizing: 'border-box'
|
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||||
|
borderRadius: '2px',
|
||||||
|
zIndex: 1000,
|
||||||
|
opacity: this.state.showScrollbar ? 1 : 0,
|
||||||
|
transition: 'opacity 0.3s ease-in-out'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left Arrow */}
|
<div
|
||||||
<IconButton
|
className="scrollbar-thumb"
|
||||||
onClick={() => moveCarousel("left")}
|
|
||||||
aria-label="Vorherige Kategorien anzeigen"
|
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '50%',
|
top: '0',
|
||||||
left: '8px',
|
left: `${thumbPosition}%`,
|
||||||
transform: 'translateY(-50%)',
|
width: '20px',
|
||||||
zIndex: 1200,
|
height: '4px',
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
borderRadius: '2px',
|
||||||
width: '48px',
|
transform: 'translateX(-50%)',
|
||||||
height: '48px',
|
transition: 'left 0.2s ease-out'
|
||||||
borderRadius: '50%'
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { t } = this.props;
|
||||||
|
const { categories } = this.state;
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChevronLeft />
|
<Typography
|
||||||
</IconButton>
|
variant="h4"
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
fontFamily: "SwashingtonCP",
|
||||||
|
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('navigation.categories')}
|
||||||
|
</Typography>
|
||||||
|
<ChevronRight sx={{ fontSize: "2.5rem", ml: 1 }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Right Arrow */}
|
<div
|
||||||
<IconButton
|
className="carousel-wrapper"
|
||||||
onClick={() => moveCarousel("right")}
|
|
||||||
aria-label="Nächste Kategorien anzeigen"
|
|
||||||
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="carousel-container"
|
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
padding: '20px 0',
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: '1080px',
|
maxWidth: '1200px',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
zIndex: 1,
|
padding: '0 20px',
|
||||||
boxSizing: 'border-box'
|
boxSizing: 'border-box'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
{/* Left Arrow */}
|
||||||
className="home-carousel-track"
|
<IconButton
|
||||||
ref={carouselRef}
|
aria-label="Vorherige Kategorien anzeigen"
|
||||||
|
onClick={this.handleLeftClick}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
position: 'absolute',
|
||||||
gap: '16px',
|
top: '50%',
|
||||||
transition: 'none',
|
left: '8px',
|
||||||
alignItems: 'flex-start',
|
transform: 'translateY(-50%)',
|
||||||
width: 'fit-content',
|
zIndex: 1200,
|
||||||
overflow: 'visible',
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
position: 'relative',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||||
transform: 'translateX(0px)',
|
width: '48px',
|
||||||
margin: '0 auto'
|
height: '48px',
|
||||||
|
borderRadius: '50%'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{displayCategories.map((category, index) => (
|
<ChevronLeft />
|
||||||
<div
|
</IconButton>
|
||||||
key={`${category.id}-${index}`}
|
|
||||||
className="carousel-item"
|
{/* Right Arrow */}
|
||||||
style={{
|
<IconButton
|
||||||
flex: '0 0 130px',
|
aria-label="Nächste Kategorien anzeigen"
|
||||||
width: '130px',
|
onClick={this.handleRightClick}
|
||||||
maxWidth: '130px',
|
style={{
|
||||||
minWidth: '130px',
|
position: 'absolute',
|
||||||
height: '130px',
|
top: '50%',
|
||||||
maxHeight: '130px',
|
right: '8px',
|
||||||
minHeight: '130px',
|
transform: 'translateY(-50%)',
|
||||||
boxSizing: 'border-box',
|
zIndex: 1200,
|
||||||
position: 'relative'
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
}}
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||||
>
|
width: '48px',
|
||||||
<CategoryBox
|
height: '48px',
|
||||||
id={category.id}
|
borderRadius: '50%'
|
||||||
name={category.name}
|
}}
|
||||||
seoName={category.seoName}
|
>
|
||||||
image={category.image}
|
<ChevronRight />
|
||||||
bgcolor={category.bgcolor}
|
</IconButton>
|
||||||
/>
|
|
||||||
</div>
|
<div
|
||||||
))}
|
className="carousel-container"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
padding: '20px 0',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '1080px',
|
||||||
|
margin: '0 auto',
|
||||||
|
zIndex: 1,
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="home-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'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{categories.map((category, index) => (
|
||||||
|
<div
|
||||||
|
key={`${category.id}-${index}`}
|
||||||
|
className="carousel-item"
|
||||||
|
style={{
|
||||||
|
flex: '0 0 130px',
|
||||||
|
width: '130px',
|
||||||
|
maxWidth: '130px',
|
||||||
|
minWidth: '130px',
|
||||||
|
height: '130px',
|
||||||
|
maxHeight: '130px',
|
||||||
|
minHeight: '130px',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CategoryBox
|
||||||
|
id={category.id}
|
||||||
|
name={category.name}
|
||||||
|
seoName={category.seoName}
|
||||||
|
image={category.image}
|
||||||
|
bgcolor={category.bgcolor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Virtual Scrollbar */}
|
||||||
|
{this.renderVirtualScrollbar()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SharedCarousel;
|
{/* Product Carousel for "neu" category */}
|
||||||
|
<ProductCarousel categoryId="neu" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 React, { Component } from 'react';
|
||||||
import Grid from '@mui/material/Grid';
|
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 CardMedia from '@mui/material/CardMedia';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Checkbox from '@mui/material/Checkbox';
|
import Stack from '@mui/material/Stack';
|
||||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
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 {
|
class ExtrasSelector extends Component {
|
||||||
formatPrice(price) {
|
formatPrice(price) {
|
||||||
@@ -16,127 +17,178 @@ class ExtrasSelector extends Component {
|
|||||||
}).format(price);
|
}).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) {
|
renderExtraCard(extra) {
|
||||||
const { selectedExtras, onExtraToggle, showImage = true } = this.props;
|
const { selectedExtras, onExtraToggle, showImage = true } = this.props;
|
||||||
const isSelected = selectedExtras.includes(extra.id);
|
const isSelected = selectedExtras.includes(extra.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Box sx={{
|
||||||
key={extra.id}
|
width: { xs: '100%', sm: '250px' },
|
||||||
sx={{
|
height: '100%',
|
||||||
height: '100%',
|
display: 'flex',
|
||||||
border: '2px solid',
|
flexDirection: 'column',
|
||||||
borderColor: isSelected ? '#2e7d32' : '#e0e0e0',
|
borderRadius: '8px',
|
||||||
backgroundColor: isSelected ? '#f1f8e9' : '#ffffff',
|
overflow: 'hidden',
|
||||||
'&:hover': {
|
cursor: 'pointer',
|
||||||
boxShadow: 5,
|
border: '2px solid',
|
||||||
borderColor: isSelected ? '#2e7d32' : '#90caf9'
|
borderColor: isSelected ? '#2e7d32' : '#e0e0e0',
|
||||||
},
|
backgroundColor: isSelected ? '#f1f8e9' : '#ffffff',
|
||||||
transition: 'all 0.3s ease',
|
'&:hover': {
|
||||||
cursor: 'pointer'
|
boxShadow: 6,
|
||||||
}}
|
borderColor: isSelected ? '#2e7d32' : '#90caf9'
|
||||||
onClick={() => onExtraToggle(extra.id)}
|
},
|
||||||
>
|
transition: 'all 0.3s ease'
|
||||||
|
}}
|
||||||
|
onClick={() => onExtraToggle(extra.id)}>
|
||||||
|
{/* Image */}
|
||||||
{showImage && (
|
{showImage && (
|
||||||
<CardMedia
|
<Box sx={{
|
||||||
component="img"
|
height: { xs: '240px', sm: '180px' },
|
||||||
height="160"
|
display: 'flex',
|
||||||
image={extra.image}
|
alignItems: 'center',
|
||||||
alt={extra.name}
|
justifyContent: 'center',
|
||||||
sx={{ objectFit: 'cover' }}
|
backgroundColor: '#ffffff'
|
||||||
/>
|
}}>
|
||||||
)}
|
{this.renderProductImage(extra)}
|
||||||
<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>
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Box sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* Name */}
|
||||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||||
{extra.name}
|
{extra.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{extra.description}
|
<Typography gutterBottom>
|
||||||
|
{extra.kurzBeschreibung}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Selection Indicator - Separate line */}
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<Box sx={{ mt: 2, textAlign: 'center' }}>
|
<Typography variant="body2" sx={{
|
||||||
<Typography variant="body2" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
color: '#2e7d32',
|
||||||
✓ Hinzugefügt
|
fontWeight: 'bold',
|
||||||
</Typography>
|
mt: 1,
|
||||||
</Box>
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
✓ Ausgewählt
|
||||||
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
<Stack direction="row" spacing={1} justifyContent="center">
|
||||||
</Card>
|
<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() {
|
render() {
|
||||||
const { extras, title, subtitle, groupByCategory = true, gridSize = { xs: 12, sm: 6, md: 4 } } = this.props;
|
const { extras, title, subtitle, 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;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
|
if (!extras || !Array.isArray(extras)) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mb: 4 }}>
|
<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}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{subtitle && (
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
Keine Extras verfügbar
|
||||||
{subtitle}
|
</Typography>
|
||||||
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render without category grouping
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mb: 4 }}>
|
<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}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import CardMedia from '@mui/material/CardMedia';
|
|||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Chip from '@mui/material/Chip';
|
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 {
|
class ProductSelector extends Component {
|
||||||
formatPrice(price) {
|
formatPrice(price) {
|
||||||
@@ -65,6 +69,19 @@ class ProductSelector extends Component {
|
|||||||
✓ Ausgewählt
|
✓ Ausgewählt
|
||||||
</Typography>
|
</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>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -147,7 +164,7 @@ class ProductSelector extends Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mb: 4 }}>
|
<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}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import CardContent from '@mui/material/CardContent';
|
|||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Chip from '@mui/material/Chip';
|
import Chip from '@mui/material/Chip';
|
||||||
|
import { withI18n } from '../../i18n/withTranslation.js';
|
||||||
|
|
||||||
class TentShapeSelector extends Component {
|
class TentShapeSelector extends Component {
|
||||||
// Generate plant layout based on tent shape
|
// Generate plant layout based on tent shape
|
||||||
@@ -90,7 +91,7 @@ class TentShapeSelector extends Component {
|
|||||||
onClick={() => onShapeSelect(shape.id)}
|
onClick={() => onShapeSelect(shape.id)}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', p: 3 }}>
|
<CardContent sx={{ textAlign: 'center', p: 3 }}>
|
||||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 'bold' }}>
|
<Typography variant="h4" component="h4" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||||
{shape.name}
|
{shape.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
@@ -180,12 +181,20 @@ class TentShapeSelector extends Component {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
{shape.description}
|
{this.props.t && shape.descriptionKey ? this.props.t(shape.descriptionKey) : shape.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<Chip
|
<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"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: isSelected ? '#2e7d32' : '#f0f0f0',
|
bgcolor: isSelected ? '#2e7d32' : '#f0f0f0',
|
||||||
@@ -205,7 +214,7 @@ class TentShapeSelector extends Component {
|
|||||||
transition: 'opacity 0.3s ease'
|
transition: 'opacity 0.3s ease'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
✓ Ausgewählt
|
{this.props.t ? this.props.t("kitConfig.selected") : "✓ Ausgewählt"}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -218,7 +227,7 @@ class TentShapeSelector extends Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mb: 4 }}>
|
<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}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{subtitle && (
|
{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 Box from '@mui/material/Box';
|
||||||
import Badge from '@mui/material/Badge';
|
import Badge from '@mui/material/Badge';
|
||||||
import Drawer from '@mui/material/Drawer';
|
import Drawer from '@mui/material/Drawer';
|
||||||
@@ -8,7 +8,14 @@ import Typography from '@mui/material/Typography';
|
|||||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 CartDropdown from '../CartDropdown.js';
|
||||||
import LanguageSwitcher from '../LanguageSwitcher.js';
|
import LanguageSwitcher from '../LanguageSwitcher.js';
|
||||||
import { isUserLoggedIn } from '../LoginComponent.js';
|
import { isUserLoggedIn } from '../LoginComponent.js';
|
||||||
@@ -34,9 +41,8 @@ class ButtonGroup extends Component {
|
|||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.cart = () => {
|
this.cart = () => {
|
||||||
// @note Only emit if socket exists, is connected, AND the update didn't come from socket
|
if (!this.isUpdatingFromSocket) {
|
||||||
if (this.props.socket && this.props.socket.connected && !this.isUpdatingFromSocket) {
|
window.socketManager.emit('updateCart', window.cart);
|
||||||
this.props.socket.emit('updateCart', window.cart);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -53,19 +59,6 @@ class ButtonGroup extends Component {
|
|||||||
this.addSocketListeners();
|
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() {
|
componentWillUnmount() {
|
||||||
window.removeEventListener('cart', this.cart);
|
window.removeEventListener('cart', this.cart);
|
||||||
@@ -74,16 +67,17 @@ class ButtonGroup extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addSocketListeners = () => {
|
addSocketListeners = () => {
|
||||||
if (this.props.socket && this.props.socket.connected) {
|
// Remove existing listeners first to avoid duplicates
|
||||||
// Remove existing listeners first to avoid duplicates
|
this.removeSocketListeners();
|
||||||
this.removeSocketListeners();
|
|
||||||
this.props.socket.on('cartUpdated', this.handleCartUpdated);
|
if (window.socketManager) {
|
||||||
|
window.socketManager.on('cartUpdated', this.handleCartUpdated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
removeSocketListeners = () => {
|
removeSocketListeners = () => {
|
||||||
if (this.props.socket) {
|
if (window.socketManager) {
|
||||||
this.props.socket.off('cartUpdated', this.handleCartUpdated);
|
window.socketManager.off('cartUpdated', this.handleCartUpdated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +112,7 @@ class ButtonGroup extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { socket, navigate, t } = this.props;
|
const { navigate, t } = this.props;
|
||||||
const { isCartOpen } = this.state;
|
const { isCartOpen } = this.state;
|
||||||
const cartItems = Array.isArray(window.cart) ? window.cart : [];
|
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 } }}>
|
<Box sx={{ display: 'flex', gap: { xs: 0.5, sm: 1 } }}>
|
||||||
|
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<LoginComponent socket={socket} />
|
<Suspense fallback={<CircularProgress size={20} />}>
|
||||||
|
<LoginComponent/>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={this.toggleCart}
|
onClick={this.toggleCart}
|
||||||
|
aria-label="Warenkorb öffnen"
|
||||||
sx={{ ml: 1 }}
|
sx={{ ml: 1 }}
|
||||||
>
|
>
|
||||||
<Badge badgeContent={this.state.badgeNumber} color="error">
|
<Badge badgeContent={this.state.badgeNumber} color="error">
|
||||||
@@ -156,6 +153,7 @@ class ButtonGroup extends Component {
|
|||||||
<IconButton
|
<IconButton
|
||||||
onClick={this.toggleCart}
|
onClick={this.toggleCart}
|
||||||
size="small"
|
size="small"
|
||||||
|
aria-label="Warenkorb schließen"
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: 'primary.main',
|
bgcolor: 'primary.main',
|
||||||
color: 'primary.contrastText',
|
color: 'primary.contrastText',
|
||||||
@@ -170,7 +168,7 @@ class ButtonGroup extends Component {
|
|||||||
</Box>
|
</Box>
|
||||||
<Divider sx={{ mb: 2 }} />
|
<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 */
|
/*open the Drawer inside <LoginComponent */
|
||||||
|
|
||||||
if (isUserLoggedIn().isLoggedIn) {
|
if (isUserLoggedIn().isLoggedIn) {
|
||||||
|
|||||||
@@ -6,317 +6,137 @@ import Typography from "@mui/material/Typography";
|
|||||||
import Collapse from "@mui/material/Collapse";
|
import Collapse from "@mui/material/Collapse";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import HomeIcon from "@mui/icons-material/Home";
|
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 MenuIcon from "@mui/icons-material/Menu";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import { withI18n } from "../../i18n/withTranslation.js";
|
import { withI18n } from "../../i18n/withTranslation.js";
|
||||||
|
|
||||||
class CategoryList extends Component {
|
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) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
//const { i18n } = props;
|
||||||
|
const categories = window.categoryService.getSync(209);
|
||||||
|
|
||||||
// Check for cached data during SSR/initial render
|
this.state = {
|
||||||
let initialState = {
|
categories: categories && categories.children && categories.children.length > 0 ? categories.children : [],
|
||||||
categoryTree: null,
|
mobileMenuOpen: false,
|
||||||
level1Categories: [], // Children of category 209 (Home) - always shown
|
activeCategoryId: null // Will be set properly after categories are loaded
|
||||||
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
|
|
||||||
};
|
};
|
||||||
|
this.productCategoryCheckInterval = null;
|
||||||
// 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";
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
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');
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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";
|
|
||||||
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";
|
|
||||||
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 }, (response) => {
|
|
||||||
if (response && response.categoryTree) {
|
|
||||||
|
|
||||||
// Store in global cache with timestamp
|
|
||||||
try {
|
|
||||||
const cacheKey = "categoryTree_209";
|
|
||||||
if (windowObj && windowObj.productCache) {
|
|
||||||
windowObj.productCache[cacheKey] = {
|
|
||||||
categoryTree: response.categoryTree,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
fetching: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error writing to cache:", err);
|
|
||||||
}
|
|
||||||
this.processCategoryTree(response.categoryTree);
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const cacheKey = "categoryTree_209";
|
|
||||||
if (windowObj && windowObj.productCache) {
|
|
||||||
windowObj.productCache[cacheKey] = {
|
|
||||||
categoryTree: null,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error writing to cache:", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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({
|
this.setState({
|
||||||
categoryTree: null,
|
categories: response.children,
|
||||||
level1Categories: [],
|
activeCategoryId: this.setLevel1CategoryId(this.props.activeCategoryId)
|
||||||
level2Categories: [],
|
|
||||||
level3Categories: [],
|
|
||||||
activePath: [],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
processCategoryTree = (categoryTree) => {
|
componentDidUpdate(prevProps) {
|
||||||
// Level 1 categories are always the children of category 209 (Home)
|
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
|
||||||
const level1Categories =
|
if (prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
|
||||||
categoryTree && categoryTree.id === 209
|
this.setState({
|
||||||
? categoryTree.children || []
|
categories: [],
|
||||||
: [];
|
activeCategoryId: null
|
||||||
|
}, () => {
|
||||||
|
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({
|
||||||
|
categories: response.children,
|
||||||
|
activeCategoryId: this.setLevel1CategoryId(this.props.activeCategoryId)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (prevProps.activeCategoryId !== this.props.activeCategoryId) {
|
||||||
|
this.setLevel1CategoryId(this.props.activeCategoryId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build the navigation path and determine what to show at each level
|
|
||||||
let level2Categories = [];
|
|
||||||
let level3Categories = [];
|
|
||||||
let activePath = [];
|
|
||||||
|
|
||||||
if (this.props.activeCategoryId) {
|
setLevel1CategoryId = (input) => {
|
||||||
const activeCategory = this.findCategoryById(
|
if (input) {
|
||||||
categoryTree,
|
const language = this.props.languageContext?.currentLanguage || this.props.i18n.language;
|
||||||
this.props.activeCategoryId
|
const categoryTreeCache = window.categoryService.getSync(209, language);
|
||||||
);
|
|
||||||
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 (categoryTreeCache && categoryTreeCache.children) {
|
||||||
if (activePath.length >= 1) {
|
let level1CategoryId = null;
|
||||||
// Show children of the level 1 category
|
|
||||||
const level1Category = activePath[0];
|
// Check if input is already a numeric level 1 category ID
|
||||||
level2Categories = level1Category.children || [];
|
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 {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activePath.length >= 2) {
|
this.setState({
|
||||||
// Show children of the level 2 category
|
activeCategoryId: level1CategoryId
|
||||||
const level2Category = activePath[1];
|
});
|
||||||
level3Categories = level2Category.children || [];
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.setState({ activeCategoryId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
categoryTree,
|
|
||||||
level1Categories,
|
|
||||||
level2Categories,
|
|
||||||
level3Categories,
|
|
||||||
activePath,
|
|
||||||
fetchedCategories: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMobileMenuToggle = () => {
|
handleMobileMenuToggle = () => {
|
||||||
this.setState(prevState => ({
|
this.setState(prevState => ({
|
||||||
@@ -331,113 +151,168 @@ class CategoryList extends Component {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
componentWillUnmount() {
|
||||||
const { level1Categories, activePath, mobileMenuOpen } =
|
if (this.productCategoryCheckInterval) {
|
||||||
this.state;
|
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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "flex-start",
|
justifyContent: "flex-start",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
flexWrap: isMobile ? "wrap" : "nowrap",
|
flexWrap: "wrap",
|
||||||
overflowX: isMobile ? "visible" : "auto",
|
overflowX: "visible",
|
||||||
flexDirection: isMobile ? "column" : "row",
|
flexDirection: isMobile ? "column" : "row",
|
||||||
py: 0.5, // Add vertical padding to prevent border clipping
|
py: 0.5, // Add vertical padding to prevent border clipping
|
||||||
"&::-webkit-scrollbar": {
|
|
||||||
display: "none",
|
|
||||||
},
|
|
||||||
scrollbarWidth: "none",
|
|
||||||
msOverflowStyle: "none",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{level === 1 && (
|
<Button
|
||||||
<Button
|
component={Link}
|
||||||
component={Link}
|
to="/"
|
||||||
to="/"
|
color="inherit"
|
||||||
color="inherit"
|
size="small"
|
||||||
size="small"
|
aria-label="Zur Startseite"
|
||||||
aria-label="Zur Startseite"
|
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
|
||||||
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
|
sx={{
|
||||||
sx={{
|
fontSize: "0.75rem",
|
||||||
fontSize: "0.75rem",
|
textTransform: "none",
|
||||||
textTransform: "none",
|
whiteSpace: "nowrap",
|
||||||
whiteSpace: "nowrap",
|
opacity: 0.9,
|
||||||
opacity: 0.9,
|
mx: isMobile ? 0 : 0.5,
|
||||||
mx: isMobile ? 0 : 0.5,
|
my: 0.25,
|
||||||
my: 0.25,
|
minWidth: isMobile ? "100%" : "auto",
|
||||||
minWidth: isMobile ? "100%" : "auto",
|
borderRadius: 1,
|
||||||
borderRadius: 1,
|
justifyContent: isMobile ? "flex-start" : "center",
|
||||||
justifyContent: isMobile ? "flex-start" : "center",
|
transition: "all 0.2s ease",
|
||||||
transition: "all 0.2s ease",
|
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
|
||||||
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
|
position: "relative",
|
||||||
position: "relative",
|
...(activeCategoryId === null && {
|
||||||
...(this.props.activeCategoryId === null && {
|
bgcolor: "#fff",
|
||||||
bgcolor: "#fff",
|
textShadow: "none",
|
||||||
textShadow: "none",
|
opacity: 1,
|
||||||
opacity: 1,
|
}),
|
||||||
}),
|
"&:hover": {
|
||||||
"&:hover": {
|
opacity: 1,
|
||||||
opacity: 1,
|
bgcolor: "#fff",
|
||||||
bgcolor: "#fff",
|
textShadow: "none",
|
||||||
textShadow: "none",
|
"& .MuiSvgIcon-root": {
|
||||||
"& .MuiSvgIcon-root": {
|
color: "#2e7d32 !important",
|
||||||
color: "#2e7d32 !important",
|
|
||||||
},
|
|
||||||
"& .bold-text": {
|
|
||||||
color: "#2e7d32 !important",
|
|
||||||
},
|
|
||||||
"& .thin-text": {
|
|
||||||
color: "transparent !important",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}}
|
"& .bold-text": {
|
||||||
>
|
color: "#2e7d32 !important",
|
||||||
<HomeIcon sx={{
|
},
|
||||||
fontSize: "1rem",
|
"& .thin-text": {
|
||||||
mr: isMobile ? 1 : 0,
|
color: "transparent !important",
|
||||||
color: this.props.activeCategoryId === null ? "#2e7d32" : "inherit"
|
},
|
||||||
}} />
|
},
|
||||||
{isMobile && (
|
}}
|
||||||
<Box sx={{ position: "relative", display: "inline-block" }}>
|
>
|
||||||
{/* Bold text (always rendered to set width) */}
|
<HomeIcon sx={{
|
||||||
<Box
|
fontSize: "1rem",
|
||||||
className="bold-text"
|
mr: isMobile ? 1 : 0,
|
||||||
sx={{
|
color: activeCategoryId === null ? "#2e7d32" : "inherit"
|
||||||
fontWeight: "bold",
|
}} />
|
||||||
color: this.props.activeCategoryId === null ? "#2e7d32" : "transparent",
|
{isMobile && (
|
||||||
position: "relative",
|
<Box sx={{ position: "relative", display: "inline-block" }}>
|
||||||
zIndex: 2,
|
{/* Bold text (always rendered to set width) */}
|
||||||
}}
|
<Box
|
||||||
>
|
className="bold-text"
|
||||||
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
|
sx={{
|
||||||
</Box>
|
fontWeight: "bold",
|
||||||
{/* Thin text (positioned on top) */}
|
color: activeCategoryId === null ? "#2e7d32" : "transparent",
|
||||||
<Box
|
position: "relative",
|
||||||
className="thin-text"
|
zIndex: 2,
|
||||||
sx={{
|
}}
|
||||||
fontWeight: "400",
|
>
|
||||||
color: this.props.activeCategoryId === null ? "transparent" : "inherit",
|
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
zIndex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
{/* Thin text (positioned on top) */}
|
||||||
</Button>
|
<Box
|
||||||
)}
|
className="thin-text"
|
||||||
{this.state.fetchedCategories && categories.length > 0 ? (
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
|
||||||
|
{categories.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
{categories.map((category) => {
|
{categories.map((category) => {
|
||||||
// Determine if this category is active at this level
|
|
||||||
const isActiveAtThisLevel =
|
|
||||||
activePath[level - 1] &&
|
|
||||||
activePath[level - 1].id === category.id;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -460,7 +335,7 @@ class CategoryList extends Component {
|
|||||||
transition: "all 0.2s ease",
|
transition: "all 0.2s ease",
|
||||||
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
|
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
...(isActiveAtThisLevel && {
|
...(activeCategoryId === category.id && {
|
||||||
bgcolor: "#fff",
|
bgcolor: "#fff",
|
||||||
textShadow: "none",
|
textShadow: "none",
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
@@ -484,7 +359,7 @@ class CategoryList extends Component {
|
|||||||
className="bold-text"
|
className="bold-text"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: isActiveAtThisLevel ? "#2e7d32" : "transparent",
|
color: activeCategoryId === category.id ? "#2e7d32" : "transparent",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
}}
|
}}
|
||||||
@@ -496,7 +371,7 @@ class CategoryList extends Component {
|
|||||||
className="thin-text"
|
className="thin-text"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: "400",
|
fontWeight: "400",
|
||||||
color: isActiveAtThisLevel ? "transparent" : "inherit",
|
color: activeCategoryId === category.id ? "transparent" : "inherit",
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -510,24 +385,101 @@ class CategoryList extends Component {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (!isMobile && (
|
||||||
level === 1 && !isMobile && (
|
<Typography
|
||||||
<Typography
|
variant="caption"
|
||||||
variant="caption"
|
color="inherit"
|
||||||
color="inherit"
|
sx={{
|
||||||
sx={{
|
display: "inline-flex",
|
||||||
display: "inline-flex",
|
alignItems: "center",
|
||||||
alignItems: "center",
|
height: "33px", // Match small button height
|
||||||
height: "30px", // Match small button height
|
px: 1,
|
||||||
px: 1,
|
fontSize: "0.75rem",
|
||||||
fontSize: "0.75rem",
|
opacity: 0.9,
|
||||||
opacity: 0.9,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
|
||||||
|
</Typography>
|
||||||
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -550,25 +502,7 @@ class CategoryList extends Component {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Container maxWidth="lg" sx={{ px: 2 }}>
|
<Container maxWidth="lg" sx={{ px: 2 }}>
|
||||||
{/* Level 1 Categories Row - Always shown */}
|
{renderCategoryRow(categories, false)}
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
*/}
|
|
||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -582,11 +516,11 @@ class CategoryList extends Component {
|
|||||||
>
|
>
|
||||||
<Container maxWidth="lg" sx={{ px: 2 }}>
|
<Container maxWidth="lg" sx={{ px: 2 }}>
|
||||||
{/* Toggle Button */}
|
{/* Toggle Button */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
py: 1,
|
py: 1,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
@@ -596,7 +530,7 @@ class CategoryList extends Component {
|
|||||||
onClick={this.handleMobileMenuToggle}
|
onClick={this.handleMobileMenuToggle}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={this.props.t ?
|
aria-label={this.props.t ?
|
||||||
(mobileMenuOpen ? this.props.t('navigation.categoriesClose') : this.props.t('navigation.categoriesOpen')) :
|
(mobileMenuOpen ? this.props.t('navigation.categoriesClose') : this.props.t('navigation.categoriesOpen')) :
|
||||||
(mobileMenuOpen ? "Kategorien schließen" : "Kategorien öffnen")
|
(mobileMenuOpen ? "Kategorien schließen" : "Kategorien öffnen")
|
||||||
}
|
}
|
||||||
@@ -607,11 +541,11 @@ class CategoryList extends Component {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="subtitle2" color="inherit" sx={{
|
<Typography variant="subtitle2" color="inherit" sx={{
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
textShadow: "0 1px 2px rgba(0,0,0,0.3)"
|
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>
|
</Typography>
|
||||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||||
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
|
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
|
||||||
@@ -622,7 +556,7 @@ class CategoryList extends Component {
|
|||||||
<Collapse in={mobileMenuOpen}>
|
<Collapse in={mobileMenuOpen}>
|
||||||
<Box sx={{ pb: 2 }}>
|
<Box sx={{ pb: 2 }}>
|
||||||
{/* Level 1 Categories - Only level shown in mobile menu */}
|
{/* Level 1 Categories - Only level shown in mobile menu */}
|
||||||
{renderCategoryRow(level1Categories, 1, true)}
|
{renderCategoryRow(categories, true)}
|
||||||
</Box>
|
</Box>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ const Logo = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/assets/images/sh.png"
|
src="/assets/images/sh.avif"
|
||||||
alt="SH Logo"
|
alt="SH Logo"
|
||||||
style={{ height: "45px" }}
|
width="108px"
|
||||||
|
height="45px"
|
||||||
|
style={{ width: "108px", height: "45px" }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,18 +7,19 @@ import List from "@mui/material/List";
|
|||||||
import ListItem from "@mui/material/ListItem";
|
import ListItem from "@mui/material/ListItem";
|
||||||
import ListItemText from "@mui/material/ListItemText";
|
import ListItemText from "@mui/material/ListItemText";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import CircularProgress from "@mui/material/CircularProgress";
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import SearchIcon from "@mui/icons-material/Search";
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
import KeyboardReturnIcon from "@mui/icons-material/KeyboardReturn";
|
import KeyboardReturnIcon from "@mui/icons-material/KeyboardReturn";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
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 SearchBar = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const context = React.useContext(SocketContext);
|
|
||||||
const searchParams = new URLSearchParams(location.search);
|
const searchParams = new URLSearchParams(location.search);
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const languageContext = React.useContext(LanguageContext);
|
||||||
|
|
||||||
// State management
|
// State management
|
||||||
const [searchQuery, setSearchQuery] = React.useState(
|
const [searchQuery, setSearchQuery] = React.useState(
|
||||||
@@ -27,7 +28,6 @@ const SearchBar = () => {
|
|||||||
const [suggestions, setSuggestions] = React.useState([]);
|
const [suggestions, setSuggestions] = React.useState([]);
|
||||||
const [showSuggestions, setShowSuggestions] = React.useState(false);
|
const [showSuggestions, setShowSuggestions] = React.useState(false);
|
||||||
const [selectedIndex, setSelectedIndex] = React.useState(-1);
|
const [selectedIndex, setSelectedIndex] = React.useState(-1);
|
||||||
const [loadingSuggestions, setLoadingSuggestions] = React.useState(false);
|
|
||||||
|
|
||||||
// Refs for debouncing and timers
|
// Refs for debouncing and timers
|
||||||
const debounceTimerRef = React.useRef(null);
|
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
|
// @note Autocomplete function using getSearchProducts Socket.io API - returns objects with name and seoName
|
||||||
const fetchAutocomplete = React.useCallback(
|
const fetchAutocomplete = React.useCallback(
|
||||||
(query) => {
|
(query) => {
|
||||||
if (!context || !context.socket || !context.socket.connected || !query || query.length < 2) {
|
if (!query || query.length < 2) {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
setLoadingSuggestions(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoadingSuggestions(true);
|
const currentLanguage = languageContext?.currentLanguage || i18n?.language || 'de';
|
||||||
|
|
||||||
context.socket.emit(
|
window.socketManager.emit(
|
||||||
"getSearchProducts",
|
"getSearchProducts",
|
||||||
{
|
{
|
||||||
query: query.trim(),
|
query: query.trim(),
|
||||||
limit: 8,
|
limit: 8,
|
||||||
|
language: currentLanguage,
|
||||||
|
requestTranslation: currentLanguage === 'de' ? false : true,
|
||||||
},
|
},
|
||||||
(response) => {
|
(response) => {
|
||||||
setLoadingSuggestions(false);
|
|
||||||
|
|
||||||
if (response && response.products) {
|
if (response && response.products) {
|
||||||
// getSearchProducts returns response.products array
|
// 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);
|
setSuggestions(suggestions);
|
||||||
setShowSuggestions(suggestions.length > 0);
|
setShowSuggestions(suggestions.length > 0);
|
||||||
setSelectedIndex(-1); // Reset selection
|
setSelectedIndex(-1); // Reset selection
|
||||||
@@ -92,7 +91,7 @@ const SearchBar = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[context]
|
[languageContext, i18n]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSearchChange = (e) => {
|
const handleSearchChange = (e) => {
|
||||||
@@ -186,6 +185,15 @@ const SearchBar = () => {
|
|||||||
}, 200);
|
}, 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
|
// Handle enter icon click
|
||||||
const handleEnterClick = () => {
|
const handleEnterClick = () => {
|
||||||
delete window.currentSearchQuery;
|
delete window.currentSearchQuery;
|
||||||
@@ -236,7 +244,7 @@ const SearchBar = () => {
|
|||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder="Produkte suchen..."
|
placeholder={t('search.searchProducts')}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -257,12 +265,11 @@ const SearchBar = () => {
|
|||||||
),
|
),
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
{loadingSuggestions && <CircularProgress size={16} />}
|
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={handleEnterClick}
|
onClick={handleEnterClick}
|
||||||
|
aria-label="Suche starten"
|
||||||
sx={{
|
sx={{
|
||||||
ml: loadingSuggestions ? 0.5 : 0,
|
|
||||||
p: 0.5,
|
p: 0.5,
|
||||||
color: "text.secondary",
|
color: "text.secondary",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
@@ -289,8 +296,6 @@ const SearchBar = () => {
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 1300,
|
zIndex: 1300,
|
||||||
maxHeight: "300px",
|
|
||||||
overflow: "auto",
|
|
||||||
mt: 0.5,
|
mt: 0.5,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
}}
|
}}
|
||||||
@@ -298,12 +303,19 @@ const SearchBar = () => {
|
|||||||
<List disablePadding>
|
<List disablePadding>
|
||||||
{suggestions.map((suggestion, index) => (
|
{suggestions.map((suggestion, index) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={suggestion.seoName || index}
|
key={`${suggestion.seoName || 'suggestion'}-${index}`}
|
||||||
button
|
component="button"
|
||||||
selected={index === selectedIndex}
|
selected={index === selectedIndex}
|
||||||
onClick={() => handleSuggestionClick(suggestion)}
|
onClick={() => handleSuggestionClick(suggestion)}
|
||||||
sx={{
|
sx={{
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
px: 2, // Add horizontal padding back
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
backgroundColor: "action.hover",
|
backgroundColor: "action.hover",
|
||||||
},
|
},
|
||||||
@@ -318,14 +330,48 @@ const SearchBar = () => {
|
|||||||
>
|
>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={
|
primary={
|
||||||
<Typography variant="body2" noWrap>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2 }}>
|
||||||
{suggestion.name}
|
<Box sx={{ flexGrow: 1, minWidth: 0, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||||
</Typography>
|
<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>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</List>
|
</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>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Box, TextField, Typography } from "@mui/material";
|
import { Box, TextField, Typography } from "@mui/material";
|
||||||
|
import { withI18n } from "../../i18n/withTranslation.js";
|
||||||
|
|
||||||
const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
const AddressForm = ({ title, address, onChange, errors, namePrefix, t }) => {
|
||||||
// Helper function to determine if a required field should show error styling
|
// Helper function to determine if a required field should show error styling
|
||||||
const getRequiredFieldError = (fieldName, value) => {
|
const getRequiredFieldError = (fieldName, value) => {
|
||||||
const isEmpty = !value || value.trim() === "";
|
const isEmpty = !value || value.trim() === "";
|
||||||
@@ -36,7 +37,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
label="Vorname"
|
label={t ? t('checkout.addressFields.firstName') : 'Vorname'}
|
||||||
name="firstName"
|
name="firstName"
|
||||||
value={address.firstName}
|
value={address.firstName}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -49,7 +50,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Nachname"
|
label={t ? t('checkout.addressFields.lastName') : 'Nachname'}
|
||||||
name="lastName"
|
name="lastName"
|
||||||
value={address.lastName}
|
value={address.lastName}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -62,7 +63,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Adresszusatz"
|
label={t ? t('checkout.addressFields.addressSupplement') : 'Adresszusatz'}
|
||||||
name="addressAddition"
|
name="addressAddition"
|
||||||
value={address.addressAddition || ""}
|
value={address.addressAddition || ""}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -70,7 +71,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
|||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Straße"
|
label={t ? t('checkout.addressFields.street') : 'Straße'}
|
||||||
name="street"
|
name="street"
|
||||||
value={address.street}
|
value={address.street}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -83,7 +84,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Hausnummer"
|
label={t ? t('checkout.addressFields.houseNumber') : 'Hausnummer'}
|
||||||
name="houseNumber"
|
name="houseNumber"
|
||||||
value={address.houseNumber}
|
value={address.houseNumber}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -96,7 +97,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="PLZ"
|
label={t ? t('checkout.addressFields.postalCode') : 'PLZ'}
|
||||||
name="postalCode"
|
name="postalCode"
|
||||||
value={address.postalCode}
|
value={address.postalCode}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -109,7 +110,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Stadt"
|
label={t ? t('checkout.addressFields.city') : 'Stadt'}
|
||||||
name="city"
|
name="city"
|
||||||
value={address.city}
|
value={address.city}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -122,7 +123,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Land"
|
label={t ? t('checkout.addressFields.country') : 'Land'}
|
||||||
name="country"
|
name="country"
|
||||||
value={address.country}
|
value={address.country}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -135,4 +136,4 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AddressForm;
|
export default withI18n()(AddressForm);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import CheckoutForm from "./CheckoutForm.js";
|
|||||||
import PaymentConfirmationDialog from "./PaymentConfirmationDialog.js";
|
import PaymentConfirmationDialog from "./PaymentConfirmationDialog.js";
|
||||||
import OrderProcessingService from "./OrderProcessingService.js";
|
import OrderProcessingService from "./OrderProcessingService.js";
|
||||||
import CheckoutValidation from "./CheckoutValidation.js";
|
import CheckoutValidation from "./CheckoutValidation.js";
|
||||||
import SocketContext from "../../contexts/SocketContext.js";
|
|
||||||
import { withI18n } from "../../i18n/index.js";
|
import { withI18n } from "../../i18n/index.js";
|
||||||
|
|
||||||
class CartTab extends Component {
|
class CartTab extends Component {
|
||||||
@@ -68,8 +67,7 @@ class CartTab extends Component {
|
|||||||
|
|
||||||
// @note Add method to fetch and apply order template prefill data
|
// @note Add method to fetch and apply order template prefill data
|
||||||
fetchOrderTemplate = () => {
|
fetchOrderTemplate = () => {
|
||||||
if (this.context && this.context.socket && this.context.socket.connected) {
|
window.socketManager.emit('getOrderTemplate', (response) => {
|
||||||
this.context.socket.emit('getOrderTemplate', (response) => {
|
|
||||||
if (response.success && response.orderTemplate) {
|
if (response.success && response.orderTemplate) {
|
||||||
const template = response.orderTemplate;
|
const template = response.orderTemplate;
|
||||||
|
|
||||||
@@ -147,7 +145,6 @@ class CartTab extends Component {
|
|||||||
console.log("No order template available or failed to fetch");
|
console.log("No order template available or failed to fetch");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -293,7 +290,7 @@ class CartTab extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
validateAddressForm = () => {
|
validateAddressForm = () => {
|
||||||
const errors = CheckoutValidation.validateAddressForm(this.state);
|
const errors = CheckoutValidation.validateAddressForm(this.state, this.props.t);
|
||||||
this.setState({ addressFormErrors: errors });
|
this.setState({ addressFormErrors: errors });
|
||||||
return Object.keys(errors).length === 0;
|
return Object.keys(errors).length === 0;
|
||||||
};
|
};
|
||||||
@@ -323,7 +320,7 @@ class CartTab extends Component {
|
|||||||
handleCompleteOrder = () => {
|
handleCompleteOrder = () => {
|
||||||
this.setState({ completionError: null }); // Clear previous errors
|
this.setState({ completionError: null }); // Clear previous errors
|
||||||
|
|
||||||
const validationError = CheckoutValidation.getValidationErrorMessage(this.state);
|
const validationError = CheckoutValidation.getValidationErrorMessage(this.state, false, this.props.t);
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
this.setState({ completionError: validationError });
|
this.setState({ completionError: validationError });
|
||||||
this.validateAddressForm(); // To show field-specific errors
|
this.validateAddressForm(); // To show field-specific errors
|
||||||
@@ -440,7 +437,7 @@ class CartTab extends Component {
|
|||||||
const deliveryCost = this.orderService.getDeliveryCost();
|
const deliveryCost = this.orderService.getDeliveryCost();
|
||||||
const { isPickupOnly, hasStecklinge } = CheckoutValidation.getCartItemFlags(cartItems);
|
const { isPickupOnly, hasStecklinge } = CheckoutValidation.getCartItemFlags(cartItems);
|
||||||
|
|
||||||
const preSubmitError = CheckoutValidation.getValidationErrorMessage(this.state);
|
const preSubmitError = CheckoutValidation.getValidationErrorMessage(this.state, false, this.props.t);
|
||||||
const displayError = completionError || preSubmitError;
|
const displayError = completionError || preSubmitError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -468,7 +465,6 @@ class CartTab extends Component {
|
|||||||
{!showPaymentConfirmation && (
|
{!showPaymentConfirmation && (
|
||||||
<CartDropdown
|
<CartDropdown
|
||||||
cartItems={cartItems}
|
cartItems={cartItems}
|
||||||
socket={this.context.socket}
|
|
||||||
showDetailedSummary={showStripePayment}
|
showDetailedSummary={showStripePayment}
|
||||||
deliveryMethod={deliveryMethod}
|
deliveryMethod={deliveryMethod}
|
||||||
deliveryCost={deliveryCost}
|
deliveryCost={deliveryCost}
|
||||||
@@ -480,7 +476,7 @@ class CartTab extends Component {
|
|||||||
{isLoadingStripe ? (
|
{isLoadingStripe ? (
|
||||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
<Typography variant="body1">
|
<Typography variant="body1">
|
||||||
Zahlungskomponente wird geladen...
|
{this.props.t ? this.props.t('payment.loadingPaymentComponent') : 'Zahlungskomponente wird geladen...'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : showStripePayment && StripeComponent ? (
|
) : showStripePayment && StripeComponent ? (
|
||||||
@@ -539,7 +535,4 @@ class CartTab extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set static contextType to access the socket
|
|
||||||
CartTab.contextType = SocketContext;
|
|
||||||
|
|
||||||
export default withI18n()(CartTab);
|
export default withI18n()(CartTab);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import AddressForm from "./AddressForm.js";
|
|||||||
import DeliveryMethodSelector from "./DeliveryMethodSelector.js";
|
import DeliveryMethodSelector from "./DeliveryMethodSelector.js";
|
||||||
import PaymentMethodSelector from "./PaymentMethodSelector.js";
|
import PaymentMethodSelector from "./PaymentMethodSelector.js";
|
||||||
import OrderSummary from "./OrderSummary.js";
|
import OrderSummary from "./OrderSummary.js";
|
||||||
|
import { withI18n } from "../../i18n/withTranslation.js";
|
||||||
|
|
||||||
class CheckoutForm extends Component {
|
class CheckoutForm extends Component {
|
||||||
render() {
|
render() {
|
||||||
@@ -40,7 +41,7 @@ class CheckoutForm extends Component {
|
|||||||
{paymentMethod !== "cash" && (
|
{paymentMethod !== "cash" && (
|
||||||
<>
|
<>
|
||||||
<AddressForm
|
<AddressForm
|
||||||
title="Rechnungsadresse"
|
title={this.props.t ? this.props.t('checkout.invoiceAddress') : 'Rechnungsadresse'}
|
||||||
address={invoiceAddress}
|
address={invoiceAddress}
|
||||||
onChange={onInvoiceAddressChange}
|
onChange={onInvoiceAddressChange}
|
||||||
errors={addressFormErrors}
|
errors={addressFormErrors}
|
||||||
@@ -57,7 +58,7 @@ class CheckoutForm extends Component {
|
|||||||
}
|
}
|
||||||
label={
|
label={
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
Für zukünftige Bestellungen speichern
|
{this.props.t ? this.props.t('checkout.saveForFuture') : 'Für zukünftige Bestellungen speichern'}
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
sx={{ mb: 2 }}
|
sx={{ mb: 2 }}
|
||||||
@@ -70,13 +71,12 @@ class CheckoutForm extends Component {
|
|||||||
variant="body1"
|
variant="body1"
|
||||||
sx={{ mb: 2, fontWeight: "bold", color: "#2e7d32" }}
|
sx={{ mb: 2, fontWeight: "bold", color: "#2e7d32" }}
|
||||||
>
|
>
|
||||||
Für welchen Termin ist die Abholung der Stecklinge
|
{this.props.t ? this.props.t('checkout.pickupDate') : 'Für welchen Termin ist die Abholung der Stecklinge gewünscht?'}
|
||||||
gewünscht?
|
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Anmerkung"
|
label={this.props.t ? this.props.t('checkout.note') : 'Anmerkung'}
|
||||||
name="note"
|
name="note"
|
||||||
value={note}
|
value={note}
|
||||||
onChange={onNoteChange}
|
onChange={onNoteChange}
|
||||||
@@ -108,7 +108,7 @@ class CheckoutForm extends Component {
|
|||||||
}
|
}
|
||||||
label={
|
label={
|
||||||
<Typography variant="body1">
|
<Typography variant="body1">
|
||||||
Lieferadresse ist identisch mit Rechnungsadresse
|
{this.props.t ? this.props.t('checkout.sameAddress') : 'Lieferadresse ist identisch mit Rechnungsadresse'}
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
sx={{ mb: 2 }}
|
sx={{ mb: 2 }}
|
||||||
@@ -116,7 +116,7 @@ class CheckoutForm extends Component {
|
|||||||
|
|
||||||
{!useSameAddress && (
|
{!useSameAddress && (
|
||||||
<AddressForm
|
<AddressForm
|
||||||
title="Lieferadresse"
|
title={this.props.t ? this.props.t('checkout.deliveryAddress') : 'Lieferadresse'}
|
||||||
address={deliveryAddress}
|
address={deliveryAddress}
|
||||||
onChange={onDeliveryAddressChange}
|
onChange={onDeliveryAddressChange}
|
||||||
errors={addressFormErrors}
|
errors={addressFormErrors}
|
||||||
@@ -151,8 +151,7 @@ class CheckoutForm extends Component {
|
|||||||
}
|
}
|
||||||
label={
|
label={
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
Ich habe die AGBs, die Datenschutzerklärung und die
|
{this.props.t ? this.props.t('checkout.termsAccept') : 'Ich habe die AGBs, die Datenschutzerklärung und die Bestimmungen zum Widerrufsrecht gelesen'}
|
||||||
Bestimmungen zum Widerrufsrecht gelesen
|
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
sx={{ mb: 3, mt: 2 }}
|
sx={{ mb: 3, mt: 2 }}
|
||||||
@@ -175,12 +174,12 @@ class CheckoutForm extends Component {
|
|||||||
disabled={isCompletingOrder || !!preSubmitError}
|
disabled={isCompletingOrder || !!preSubmitError}
|
||||||
>
|
>
|
||||||
{isCompletingOrder
|
{isCompletingOrder
|
||||||
? "Bestellung wird verarbeitet..."
|
? (this.props.t ? this.props.t('checkout.processingOrder') : 'Bestellung wird verarbeitet...')
|
||||||
: "Bestellung abschließen"}
|
: (this.props.t ? this.props.t('checkout.completeOrder') : 'Bestellung abschließen')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CheckoutForm;
|
export default withI18n()(CheckoutForm);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
class CheckoutValidation {
|
class CheckoutValidation {
|
||||||
static validateAddressForm(state) {
|
static validateAddressForm(state, t = null) {
|
||||||
const {
|
const {
|
||||||
invoiceAddress,
|
invoiceAddress,
|
||||||
deliveryAddress,
|
deliveryAddress,
|
||||||
@@ -12,15 +12,15 @@ class CheckoutValidation {
|
|||||||
// Validate invoice address (skip if payment method is "cash")
|
// Validate invoice address (skip if payment method is "cash")
|
||||||
if (paymentMethod !== "cash") {
|
if (paymentMethod !== "cash") {
|
||||||
if (!invoiceAddress.firstName)
|
if (!invoiceAddress.firstName)
|
||||||
errors.invoiceFirstName = "Vorname erforderlich";
|
errors.invoiceFirstName = t ? t('checkout.validationErrors.firstNameRequired') : "Vorname erforderlich";
|
||||||
if (!invoiceAddress.lastName)
|
if (!invoiceAddress.lastName)
|
||||||
errors.invoiceLastName = "Nachname erforderlich";
|
errors.invoiceLastName = t ? t('checkout.validationErrors.lastNameRequired') : "Nachname erforderlich";
|
||||||
if (!invoiceAddress.street) errors.invoiceStreet = "Straße erforderlich";
|
if (!invoiceAddress.street) errors.invoiceStreet = t ? t('checkout.validationErrors.streetRequired') : "Straße erforderlich";
|
||||||
if (!invoiceAddress.houseNumber)
|
if (!invoiceAddress.houseNumber)
|
||||||
errors.invoiceHouseNumber = "Hausnummer erforderlich";
|
errors.invoiceHouseNumber = t ? t('checkout.validationErrors.houseNumberRequired') : "Hausnummer erforderlich";
|
||||||
if (!invoiceAddress.postalCode)
|
if (!invoiceAddress.postalCode)
|
||||||
errors.invoicePostalCode = "PLZ erforderlich";
|
errors.invoicePostalCode = t ? t('checkout.validationErrors.postalCodeRequired') : "PLZ erforderlich";
|
||||||
if (!invoiceAddress.city) errors.invoiceCity = "Stadt erforderlich";
|
if (!invoiceAddress.city) errors.invoiceCity = t ? t('checkout.validationErrors.cityRequired') : "Stadt erforderlich";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate delivery address for shipping methods that require it
|
// Validate delivery address for shipping methods that require it
|
||||||
@@ -29,37 +29,37 @@ class CheckoutValidation {
|
|||||||
(deliveryMethod === "DHL" || deliveryMethod === "DPD")
|
(deliveryMethod === "DHL" || deliveryMethod === "DPD")
|
||||||
) {
|
) {
|
||||||
if (!deliveryAddress.firstName)
|
if (!deliveryAddress.firstName)
|
||||||
errors.deliveryFirstName = "Vorname erforderlich";
|
errors.deliveryFirstName = t ? t('checkout.validationErrors.firstNameRequired') : "Vorname erforderlich";
|
||||||
if (!deliveryAddress.lastName)
|
if (!deliveryAddress.lastName)
|
||||||
errors.deliveryLastName = "Nachname erforderlich";
|
errors.deliveryLastName = t ? t('checkout.validationErrors.lastNameRequired') : "Nachname erforderlich";
|
||||||
if (!deliveryAddress.street)
|
if (!deliveryAddress.street)
|
||||||
errors.deliveryStreet = "Straße erforderlich";
|
errors.deliveryStreet = t ? t('checkout.validationErrors.streetRequired') : "Straße erforderlich";
|
||||||
if (!deliveryAddress.houseNumber)
|
if (!deliveryAddress.houseNumber)
|
||||||
errors.deliveryHouseNumber = "Hausnummer erforderlich";
|
errors.deliveryHouseNumber = t ? t('checkout.validationErrors.houseNumberRequired') : "Hausnummer erforderlich";
|
||||||
if (!deliveryAddress.postalCode)
|
if (!deliveryAddress.postalCode)
|
||||||
errors.deliveryPostalCode = "PLZ erforderlich";
|
errors.deliveryPostalCode = t ? t('checkout.validationErrors.postalCodeRequired') : "PLZ erforderlich";
|
||||||
if (!deliveryAddress.city) errors.deliveryCity = "Stadt erforderlich";
|
if (!deliveryAddress.city) errors.deliveryCity = t ? t('checkout.validationErrors.cityRequired') : "Stadt erforderlich";
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
static getValidationErrorMessage(state, isAddressOnly = false) {
|
static getValidationErrorMessage(state, isAddressOnly = false, t = null) {
|
||||||
const { termsAccepted } = state;
|
const { termsAccepted } = state;
|
||||||
|
|
||||||
const addressErrors = this.validateAddressForm(state);
|
const addressErrors = this.validateAddressForm(state, t);
|
||||||
|
|
||||||
if (isAddressOnly) {
|
if (isAddressOnly) {
|
||||||
return addressErrors;
|
return addressErrors;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(addressErrors).length > 0) {
|
if (Object.keys(addressErrors).length > 0) {
|
||||||
return "Bitte überprüfen Sie Ihre Eingaben in den Adressfeldern.";
|
return t ? t('checkout.addressValidationError') : "Bitte überprüfen Sie Ihre Eingaben in den Adressfeldern.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate terms acceptance
|
// Validate terms acceptance
|
||||||
if (!termsAccepted) {
|
if (!termsAccepted) {
|
||||||
return "Bitte akzeptieren Sie die AGBs, Datenschutzerklärung und Widerrufsrecht, um fortzufahren.";
|
return t ? t('checkout.termsValidationError') : "Bitte akzeptieren Sie die AGBs, Datenschutzerklärung und Widerrufsrecht, um fortzufahren.";
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -82,7 +82,7 @@ class CheckoutValidation {
|
|||||||
|
|
||||||
// Prefer stripe when available and meets minimum amount
|
// Prefer stripe when available and meets minimum amount
|
||||||
if (deliveryMethod === "DHL" || deliveryMethod === "DPD" || deliveryMethod === "Abholung") {
|
if (deliveryMethod === "DHL" || deliveryMethod === "DPD" || deliveryMethod === "Abholung") {
|
||||||
return "mollie";/*stripe*/
|
return "wire";/*stripe*/
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to wire transfer
|
// Fall back to wire transfer
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import Box from '@mui/material/Box';
|
|||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import Radio from '@mui/material/Radio';
|
import Radio from '@mui/material/Radio';
|
||||||
import Checkbox from '@mui/material/Checkbox';
|
import Checkbox from '@mui/material/Checkbox';
|
||||||
|
import { withI18n } from '../../i18n/withTranslation.js';
|
||||||
|
|
||||||
const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartItems = [] }) => {
|
const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartItems = [], t }) => {
|
||||||
// Calculate cart value for free shipping threshold
|
// Calculate cart value for free shipping threshold
|
||||||
const cartValue = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
|
const cartValue = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
|
||||||
const isFreeShipping = cartValue >= 100;
|
const isFreeShipping = cartValue >= 100;
|
||||||
@@ -14,30 +15,30 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartIt
|
|||||||
{
|
{
|
||||||
id: 'DHL',
|
id: 'DHL',
|
||||||
name: 'DHL',
|
name: 'DHL',
|
||||||
description: isPickupOnly ? "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können" :
|
description: isPickupOnly ? (t ? t('delivery.descriptions.notAvailable') : "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können") :
|
||||||
isFreeShipping ? 'Standardversand - KOSTENLOS ab 100€ Warenwert!' : 'Standardversand',
|
isFreeShipping ? (t ? t('delivery.descriptions.standardFree') : 'Standardversand - KOSTENLOS ab 100€ Warenwert!') : (t ? t('delivery.descriptions.standard') : 'Standardversand'),
|
||||||
price: isFreeShipping ? 'kostenlos' : '6,99 €',
|
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dhl') : '5,90 €'),
|
||||||
disabled: isPickupOnly
|
disabled: isPickupOnly
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'DPD',
|
id: 'DPD',
|
||||||
name: 'DPD',
|
name: 'DPD',
|
||||||
description: isPickupOnly ? "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können" :
|
description: isPickupOnly ? (t ? t('delivery.descriptions.notAvailable') : "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können") :
|
||||||
isFreeShipping ? 'Standardversand - KOSTENLOS ab 100€ Warenwert!' : 'Standardversand',
|
isFreeShipping ? (t ? t('delivery.descriptions.standardFree') : 'Standardversand - KOSTENLOS ab 100€ Warenwert!') : (t ? t('delivery.descriptions.standard') : 'Standardversand'),
|
||||||
price: isFreeShipping ? 'kostenlos' : '4,90 €',
|
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dpd') : '4,90 €'),
|
||||||
disabled: isPickupOnly
|
disabled: isPickupOnly
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'Sperrgut',
|
id: 'Sperrgut',
|
||||||
name: 'Sperrgut',
|
name: t ? t('delivery.methods.sperrgutName') : 'Sperrgut',
|
||||||
description: 'Für große und schwere Artikel',
|
description: t ? t('delivery.descriptions.bulky') : 'Für große und schwere Artikel',
|
||||||
price: '28,99 €',
|
price: t ? t('delivery.prices.sperrgut') : '28,99 €',
|
||||||
disabled: true,
|
disabled: true,
|
||||||
isCheckbox: true
|
isCheckbox: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'Abholung',
|
id: 'Abholung',
|
||||||
name: 'Abholung in der Filiale',
|
name: t ? t('delivery.methods.pickup') : 'Abholung in der Filiale',
|
||||||
description: '',
|
description: '',
|
||||||
price: ''
|
price: ''
|
||||||
}
|
}
|
||||||
@@ -46,7 +47,7 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartIt
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
Versandart wählen
|
{t ? t('delivery.selector.title') : 'Versandart wählen'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ mb: 3 }}>
|
<Box sx={{ mb: 3 }}>
|
||||||
@@ -132,10 +133,10 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartIt
|
|||||||
border: '1px solid #2196f3'
|
border: '1px solid #2196f3'
|
||||||
}}>
|
}}>
|
||||||
<Typography variant="body2" color="primary" sx={{ fontWeight: 'medium' }}>
|
<Typography variant="body2" color="primary" sx={{ fontWeight: 'medium' }}>
|
||||||
💡 Versandkostenfrei ab 100€ Warenwert!
|
{t ? t('delivery.selector.freeShippingInfo') : '💡 Versandkostenfrei ab 100€ Warenwert!'}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Noch {remainingForFreeShipping.toFixed(2).replace('.', ',')}€ für kostenlosen Versand hinzufügen.
|
{t ? t('delivery.selector.remainingForFree', { amount: remainingForFreeShipping.toFixed(2).replace('.', ',') }) : `Noch ${remainingForFreeShipping.toFixed(2).replace('.', ',')}€ für kostenlosen Versand hinzufügen.`}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -149,10 +150,10 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartIt
|
|||||||
border: '1px solid #2e7d32'
|
border: '1px solid #2e7d32'
|
||||||
}}>
|
}}>
|
||||||
<Typography variant="body2" color="success.main" sx={{ fontWeight: 'medium' }}>
|
<Typography variant="body2" color="success.main" sx={{ fontWeight: 'medium' }}>
|
||||||
🎉 Glückwunsch! Sie erhalten kostenlosen Versand!
|
{t ? t('delivery.selector.congratsFreeShipping') : '🎉 Glückwunsch! Sie erhalten kostenlosen Versand!'}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Ihr Warenkorb von {cartValue.toFixed(2).replace('.', ',')}€ qualifiziert sich für kostenlosen Versand.
|
{t ? t('delivery.selector.cartQualifiesFree', { amount: cartValue.toFixed(2).replace('.', ',') }) : `Ihr Warenkorb von ${cartValue.toFixed(2).replace('.', ',')}€ qualifiziert sich für kostenlosen Versand.`}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -161,4 +162,4 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartIt
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DeliveryMethodSelector;
|
export default withI18n()(DeliveryMethodSelector);
|
||||||
@@ -26,14 +26,25 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
|||||||
|
|
||||||
const currencyFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
|
const currencyFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
|
||||||
|
|
||||||
|
// Helper function to translate payment methods
|
||||||
|
const getPaymentMethodDisplay = (paymentMethod) => {
|
||||||
|
if (!paymentMethod) return t('orders.details.notSpecified');
|
||||||
|
|
||||||
|
switch (paymentMethod.toLowerCase()) {
|
||||||
|
case 'wire':
|
||||||
|
return t('payment.methods.bankTransfer');
|
||||||
|
default:
|
||||||
|
return paymentMethod;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCancelOrder = () => {
|
const handleCancelOrder = () => {
|
||||||
// Implement order cancellation logic here
|
// Implement order cancellation logic here
|
||||||
console.log(`Cancel order: ${order.orderId}`);
|
console.log(`Cancel order: ${order.orderId}`);
|
||||||
onClose(); // Close the dialog after action
|
onClose(); // Close the dialog after action
|
||||||
};
|
};
|
||||||
|
|
||||||
const subtotal = order.items.reduce((acc, item) => acc + item.price * item.quantity_ordered, 0);
|
const total = order.items.reduce((acc, item) => acc + item.price * item.quantity_ordered, 0);
|
||||||
const total = subtotal + order.delivery_cost;
|
|
||||||
|
|
||||||
// Calculate VAT breakdown similar to CartDropdown
|
// Calculate VAT breakdown similar to CartDropdown
|
||||||
const vatCalculations = order.items.reduce((acc, item) => {
|
const vatCalculations = order.items.reduce((acc, item) => {
|
||||||
@@ -55,10 +66,10 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||||
<DialogTitle>Bestelldetails: {order.orderId}</DialogTitle>
|
<DialogTitle>{t('orders.details.title', { orderId: order.orderId })}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Typography variant="h6">Lieferadresse</Typography>
|
<Typography variant="h6">{t('orders.details.deliveryAddress')}</Typography>
|
||||||
<Typography>{order.shipping_address_name}</Typography>
|
<Typography>{order.shipping_address_name}</Typography>
|
||||||
<Typography>{order.shipping_address_street} {order.shipping_address_house_number}</Typography>
|
<Typography>{order.shipping_address_street} {order.shipping_address_house_number}</Typography>
|
||||||
<Typography>{order.shipping_address_postal_code} {order.shipping_address_city}</Typography>
|
<Typography>{order.shipping_address_postal_code} {order.shipping_address_city}</Typography>
|
||||||
@@ -66,7 +77,7 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Typography variant="h6">Rechnungsadresse</Typography>
|
<Typography variant="h6">{t('orders.details.invoiceAddress')}</Typography>
|
||||||
<Typography>{order.invoice_address_name}</Typography>
|
<Typography>{order.invoice_address_name}</Typography>
|
||||||
<Typography>{order.invoice_address_street} {order.invoice_address_house_number}</Typography>
|
<Typography>{order.invoice_address_street} {order.invoice_address_house_number}</Typography>
|
||||||
<Typography>{order.invoice_address_postal_code} {order.invoice_address_city}</Typography>
|
<Typography>{order.invoice_address_postal_code} {order.invoice_address_city}</Typography>
|
||||||
@@ -75,28 +86,29 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
|||||||
|
|
||||||
{/* Order Details Section */}
|
{/* Order Details Section */}
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Typography variant="h6" gutterBottom>Bestelldetails</Typography>
|
<Typography variant="h6" gutterBottom>{t('orders.details.orderDetails')}</Typography>
|
||||||
<Box sx={{ display: 'flex', gap: 4 }}>
|
<Box sx={{ display: 'flex', gap: 4 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body2" color="text.secondary">Lieferart:</Typography>
|
<Typography variant="body2" color="text.secondary">{t('orders.details.deliveryMethod')}</Typography>
|
||||||
<Typography variant="body1">{order.deliveryMethod || order.delivery_method || 'Nicht angegeben'}</Typography>
|
<Typography variant="body1">{order.deliveryMethod || order.delivery_method || t('orders.details.notSpecified')}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body2" color="text.secondary">Zahlungsart:</Typography>
|
<Typography variant="body2" color="text.secondary">{t('orders.details.paymentMethod')}</Typography>
|
||||||
<Typography variant="body1">{order.paymentMethod || order.payment_method || 'Nicht angegeben'}</Typography>
|
<Typography variant="body1">{getPaymentMethodDisplay(order.paymentMethod || order.payment_method)}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="h6" gutterBottom>Bestellte Artikel</Typography>
|
<Typography variant="h6" gutterBottom>{t('orders.details.orderedItems')}</Typography>
|
||||||
<TableContainer component={Paper}>
|
<TableContainer component={Paper}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Artikel</TableCell>
|
<TableCell>{t('orders.details.item')}</TableCell>
|
||||||
<TableCell align="right">Menge</TableCell>
|
<TableCell align="right">{t('orders.details.quantity')}</TableCell>
|
||||||
<TableCell align="right">Preis</TableCell>
|
<TableCell align="right">{t('orders.details.price')}</TableCell>
|
||||||
<TableCell align="right">Gesamt</TableCell>
|
<TableCell align="right">{t('product.vatShort')}</TableCell>
|
||||||
|
<TableCell align="right">{t('orders.details.total')}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -105,12 +117,12 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
|||||||
<TableCell>{item.name}</TableCell>
|
<TableCell>{item.name}</TableCell>
|
||||||
<TableCell align="right">{item.quantity_ordered}</TableCell>
|
<TableCell align="right">{item.quantity_ordered}</TableCell>
|
||||||
<TableCell align="right">{currencyFormatter.format(item.price)}</TableCell>
|
<TableCell align="right">{currencyFormatter.format(item.price)}</TableCell>
|
||||||
|
<TableCell align="right">{item.vat}%</TableCell>
|
||||||
<TableCell align="right">{currencyFormatter.format(item.price * item.quantity_ordered)}</TableCell>
|
<TableCell align="right">{currencyFormatter.format(item.price * item.quantity_ordered)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={2} />
|
<TableCell colSpan={4} align="right">
|
||||||
<TableCell align="right">
|
|
||||||
<Typography fontWeight="bold">{t ? t('tax.totalNet') : 'Gesamtnettopreis'}</Typography>
|
<Typography fontWeight="bold">{t ? t('tax.totalNet') : 'Gesamtnettopreis'}</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
@@ -119,36 +131,19 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
{vatCalculations.vat7 > 0 && (
|
{vatCalculations.vat7 > 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={2} />
|
<TableCell colSpan={4} align="right">{t ? t('tax.vat7') : '7% Mehrwertsteuer'}</TableCell>
|
||||||
<TableCell align="right">{t ? t('tax.vat7') : '7% Mehrwertsteuer'}</TableCell>
|
|
||||||
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat7)}</TableCell>
|
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat7)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{vatCalculations.vat19 > 0 && (
|
{vatCalculations.vat19 > 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={2} />
|
<TableCell colSpan={4} align="right">{t ? t('tax.vat19') : '19% Mehrwertsteuer'}</TableCell>
|
||||||
<TableCell align="right">{t ? t('tax.vat19') : '19% Mehrwertsteuer'}</TableCell>
|
|
||||||
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat19)}</TableCell>
|
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat19)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={2} />
|
<TableCell colSpan={4} align="right">
|
||||||
<TableCell align="right">
|
<Typography fontWeight="bold">{t ? t('cart.summary.total') : 'Gesamtsumme'}</Typography>
|
||||||
<Typography fontWeight="bold">{t ? t('tax.subtotal') : 'Zwischensumme'}</Typography>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
<Typography fontWeight="bold">{currencyFormatter.format(subtotal)}</Typography>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={2} />
|
|
||||||
<TableCell align="right">Lieferkosten</TableCell>
|
|
||||||
<TableCell align="right">{currencyFormatter.format(order.delivery_cost)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={2} />
|
|
||||||
<TableCell align="right">
|
|
||||||
<Typography fontWeight="bold">Gesamtsumme</Typography>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
<Typography fontWeight="bold">{currencyFormatter.format(total)}</Typography>
|
<Typography fontWeight="bold">{currencyFormatter.format(total)}</Typography>
|
||||||
@@ -162,7 +157,7 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
|||||||
<DialogActions>
|
<DialogActions>
|
||||||
{order.status === 'new' && (
|
{order.status === 'new' && (
|
||||||
<Button onClick={handleCancelOrder} color="error">
|
<Button onClick={handleCancelOrder} color="error">
|
||||||
Bestellung stornieren
|
{t('orders.details.cancelOrder')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={onClose}>{t ? t('common.close') : 'Schließen'}</Button>
|
<Button onClick={onClose}>{t ? t('common.close') : 'Schließen'}</Button>
|
||||||
|
|||||||
@@ -145,26 +145,24 @@ class OrderProcessingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If socket is ready, process immediately
|
// If socket is ready, process immediately
|
||||||
const context = this.getContext();
|
|
||||||
if (context && context.socket && context.socket.connected) {
|
|
||||||
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
|
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
this.sendStripeOrder();
|
this.sendStripeOrder();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for socket to be ready
|
// Wait for socket to be ready
|
||||||
this.socketHandler = () => {
|
this.socketHandler = () => {
|
||||||
const context = this.getContext();
|
|
||||||
if (context && context.socket && context.socket.connected) {
|
|
||||||
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
|
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
|
||||||
const state = this.getState();
|
const state = this.getState();
|
||||||
|
|
||||||
if (isAuthenticated && state.showPaymentConfirmation && !state.isCompletingOrder) {
|
if (isAuthenticated && state.showPaymentConfirmation && !state.isCompletingOrder) {
|
||||||
this.sendStripeOrder();
|
this.sendStripeOrder();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Clean up
|
// Clean up
|
||||||
if (this.socketHandler) {
|
if (this.socketHandler) {
|
||||||
window.removeEventListener('cart', this.socketHandler);
|
window.removeEventListener('cart', this.socketHandler);
|
||||||
@@ -213,9 +211,8 @@ class OrderProcessingService {
|
|||||||
saveAddressForFuture,
|
saveAddressForFuture,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Emit stripe order to backend via socket.io
|
|
||||||
const context = this.getContext();
|
window.socketManager.emit("issueStripeOrder", orderData, (response) => {
|
||||||
context.socket.emit("issueStripeOrder", orderData, (response) => {
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
this.setState({
|
this.setState({
|
||||||
isCompletingOrder: false,
|
isCompletingOrder: false,
|
||||||
@@ -247,9 +244,8 @@ class OrderProcessingService {
|
|||||||
|
|
||||||
// Process regular (non-Stripe) orders
|
// Process regular (non-Stripe) orders
|
||||||
processRegularOrder(orderData) {
|
processRegularOrder(orderData) {
|
||||||
const context = this.getContext();
|
|
||||||
if (context && context.socket && context.socket.connected) {
|
window.socketManager.emit("issueOrder", orderData, (response) => {
|
||||||
context.socket.emit("issueOrder", orderData, (response) => {
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
// Clear the cart
|
// Clear the cart
|
||||||
window.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
|
// Create Stripe payment intent
|
||||||
createStripeIntent(totalAmount, loadStripeComponent) {
|
createStripeIntent(totalAmount, loadStripeComponent) {
|
||||||
const context = this.getContext();
|
|
||||||
if (context && context.socket && context.socket.connected) {
|
window.socketManager.emit(
|
||||||
context.socket.emit(
|
|
||||||
"createStripeIntent",
|
"createStripeIntent",
|
||||||
{ amount: totalAmount },
|
{ amount: totalAmount },
|
||||||
(response) => {
|
(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
|
// Create Mollie payment intent
|
||||||
createMollieIntent(mollieOrderData) {
|
createMollieIntent(mollieOrderData) {
|
||||||
const context = this.getContext();
|
window.socketManager.emit(
|
||||||
if (context && context.socket && context.socket.connected) {
|
|
||||||
context.socket.emit(
|
|
||||||
"createMollieIntent",
|
"createMollieIntent",
|
||||||
mollieOrderData,
|
mollieOrderData,
|
||||||
(response) => {
|
(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
|
// Calculate delivery cost
|
||||||
|
|||||||
@@ -44,20 +44,20 @@ const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ my: 3, p: 2, bgcolor: '#f5f5f5', borderRadius: 1 }}>
|
<Box sx={{ my: 3, p: 2, bgcolor: '#f5f5f5', borderRadius: 1 }}>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
Bestellübersicht
|
{t ? t('cart.summary.title') : 'Bestellübersicht'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Waren (netto):</TableCell>
|
<TableCell>{t ? t('cart.summary.goodsNet') : 'Waren (netto):'}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{currencyFormatter.format(cartVatCalculations.totalNet)}
|
{currencyFormatter.format(cartVatCalculations.totalNet)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{deliveryCost > 0 && (
|
{deliveryCost > 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Versandkosten (netto):</TableCell>
|
<TableCell>{t ? t('cart.summary.shippingNet') : 'Versandkosten (netto):'}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{currencyFormatter.format(shippingNetPrice)}
|
{currencyFormatter.format(shippingNetPrice)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -80,30 +80,30 @@ const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtsumme Waren:</TableCell>
|
<TableCell sx={{ fontWeight: 'bold' }}>{t ? t('cart.summary.totalGoods') : 'Gesamtsumme Waren:'}</TableCell>
|
||||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||||
{currencyFormatter.format(cartVatCalculations.totalGross)}
|
{currencyFormatter.format(cartVatCalculations.totalGross)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell sx={{ fontWeight: 'bold' }}>
|
<TableCell sx={{ fontWeight: 'bold' }}>
|
||||||
Versandkosten:
|
{t ? t('cart.summary.shippingCosts') : 'Versandkosten:'}
|
||||||
{deliveryCost === 0 && cartVatCalculations.totalGross < 100 && (
|
{deliveryCost === 0 && cartVatCalculations.totalGross < 100 && (
|
||||||
<span style={{ color: '#2e7d32', fontSize: '0.8em', marginLeft: '4px' }}>
|
<span style={{ color: '#2e7d32', fontSize: '0.8em', marginLeft: '4px' }}>
|
||||||
(kostenlos ab 100€)
|
{t ? t('cart.summary.freeFrom100') : '(kostenlos ab 100€)'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||||
{deliveryCost === 0 ? (
|
{deliveryCost === 0 ? (
|
||||||
<span style={{ color: '#2e7d32' }}>kostenlos</span>
|
<span style={{ color: '#2e7d32' }}>{t ? t('cart.summary.free') : 'kostenlos'}</span>
|
||||||
) : (
|
) : (
|
||||||
currencyFormatter.format(deliveryCost)
|
currencyFormatter.format(deliveryCost)
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow sx={{ borderTop: '1px solid #e0e0e0' }}>
|
<TableRow sx={{ borderTop: '1px solid #e0e0e0' }}>
|
||||||
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>Gesamtsumme:</TableCell>
|
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>{t ? t('cart.summary.total') : 'Gesamtsumme:'}</TableCell>
|
||||||
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
|
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
|
||||||
{currencyFormatter.format(totalGross)}
|
{currencyFormatter.format(totalGross)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect, useContext, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { withI18n } from "../../i18n/withTranslation.js";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -14,45 +15,48 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Typography,
|
Typography,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import SearchIcon from "@mui/icons-material/Search";
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
import SocketContext from "../../contexts/SocketContext.js";
|
import CancelIcon from "@mui/icons-material/Cancel";
|
||||||
import OrderDetailsDialog from "./OrderDetailsDialog.js";
|
import OrderDetailsDialog from "./OrderDetailsDialog.js";
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const statusTranslations = {
|
const getStatusTranslation = (status, t) => {
|
||||||
new: "in Bearbeitung",
|
const statusMap = {
|
||||||
pending: "Neu",
|
new: t ? t('orders.status.new') : "in Bearbeitung",
|
||||||
processing: "in Bearbeitung",
|
pending: t ? t('orders.status.pending') : "Neu",
|
||||||
cancelled: "Storniert",
|
processing: t ? t('orders.status.processing') : "in Bearbeitung",
|
||||||
shipped: "Verschickt",
|
paid: t ? t('orders.status.paid') : "Bezahlt",
|
||||||
delivered: "Geliefert",
|
cancelled: t ? t('orders.status.cancelled') : "Storniert",
|
||||||
|
shipped: t ? t('orders.status.shipped') : "Verschickt",
|
||||||
|
delivered: t ? t('orders.status.delivered') : "Geliefert",
|
||||||
|
};
|
||||||
|
return statusMap[status] || status;
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusEmojis = {
|
const statusEmojis = {
|
||||||
"in Bearbeitung": "⚙️",
|
new: "⚙️",
|
||||||
pending: "⏳",
|
pending: "⏳",
|
||||||
processing: "🔄",
|
processing: "🔄",
|
||||||
|
paid: "🏦",
|
||||||
cancelled: "❌",
|
cancelled: "❌",
|
||||||
Verschickt: "🚚",
|
shipped: "🚚",
|
||||||
Geliefert: "✅",
|
delivered: "✅",
|
||||||
Storniert: "❌",
|
|
||||||
Retoure: "↩️",
|
|
||||||
"Teil Retoure": "↪️",
|
|
||||||
"Teil geliefert": "⚡",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusColors = {
|
const statusColors = {
|
||||||
"in Bearbeitung": "#ed6c02", // orange
|
new: "#ed6c02", // orange
|
||||||
pending: "#ff9800", // orange for pending
|
pending: "#ff9800", // orange for pending
|
||||||
processing: "#2196f3", // blue for processing
|
processing: "#2196f3", // blue for processing
|
||||||
|
paid: "#2e7d32", // green
|
||||||
cancelled: "#d32f2f", // red for cancelled
|
cancelled: "#d32f2f", // red for cancelled
|
||||||
Verschickt: "#2e7d32", // green
|
shipped: "#2e7d32", // green
|
||||||
Geliefert: "#2e7d32", // green
|
delivered: "#2e7d32", // green
|
||||||
Storniert: "#d32f2f", // red
|
|
||||||
Retoure: "#9c27b0", // purple
|
|
||||||
"Teil Retoure": "#9c27b0", // purple
|
|
||||||
"Teil geliefert": "#009688", // teal
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
||||||
@@ -61,14 +65,16 @@ const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Orders Tab Content Component
|
// Orders Tab Content Component
|
||||||
const OrdersTab = ({ orderIdFromHash }) => {
|
const OrdersTab = ({ orderIdFromHash, t }) => {
|
||||||
const [orders, setOrders] = useState([]);
|
const [orders, setOrders] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [selectedOrder, setSelectedOrder] = useState(null);
|
const [selectedOrder, setSelectedOrder] = useState(null);
|
||||||
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
|
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
|
||||||
|
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
|
||||||
|
const [orderToCancel, setOrderToCancel] = useState(null);
|
||||||
|
const [isCancelling, setIsCancelling] = useState(false);
|
||||||
|
|
||||||
const {socket} = useContext(SocketContext);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleViewDetails = useCallback(
|
const handleViewDetails = useCallback(
|
||||||
@@ -77,16 +83,18 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
|||||||
if (orderToView) {
|
if (orderToView) {
|
||||||
setSelectedOrder(orderToView);
|
setSelectedOrder(orderToView);
|
||||||
setIsDetailsDialogOpen(true);
|
setIsDetailsDialogOpen(true);
|
||||||
|
// Update the hash to include the order ID
|
||||||
|
navigate(`/profile#${orderId}`, { replace: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[orders]
|
[orders, navigate]
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchOrders = useCallback(() => {
|
const fetchOrders = useCallback(() => {
|
||||||
if (socket && socket.connected) {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
socket.emit("getOrders", (response) => {
|
window.socketManager.emit("getOrders", (response) => {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setOrders(response.orders);
|
setOrders(response.orders);
|
||||||
} else {
|
} else {
|
||||||
@@ -94,25 +102,13 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
|||||||
}
|
}
|
||||||
setLoading(false);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchOrders();
|
fetchOrders();
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (orderIdFromHash && orders.length > 0) {
|
if (orderIdFromHash && orders.length > 0) {
|
||||||
handleViewDetails(orderIdFromHash);
|
handleViewDetails(orderIdFromHash);
|
||||||
@@ -120,7 +116,7 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
|||||||
}, [orderIdFromHash, orders, handleViewDetails]);
|
}, [orderIdFromHash, orders, handleViewDetails]);
|
||||||
|
|
||||||
const getStatusDisplay = (status) => {
|
const getStatusDisplay = (status) => {
|
||||||
return statusTranslations[status] || status;
|
return getStatusTranslation(status, t);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusEmoji = (status) => {
|
const getStatusEmoji = (status) => {
|
||||||
@@ -134,12 +130,53 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
|||||||
const handleCloseDetailsDialog = () => {
|
const handleCloseDetailsDialog = () => {
|
||||||
setIsDetailsDialogOpen(false);
|
setIsDetailsDialogOpen(false);
|
||||||
setSelectedOrder(null);
|
setSelectedOrder(null);
|
||||||
navigate("/profile", { replace: true });
|
navigate("/profile#orders", { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if order can be cancelled
|
||||||
|
const isOrderCancelable = (order) => {
|
||||||
|
const cancelableStatuses = ['new', 'pending', 'processing'];
|
||||||
|
return cancelableStatuses.includes(order.status);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle cancel button click
|
||||||
|
const handleCancelClick = (order) => {
|
||||||
|
setOrderToCancel(order);
|
||||||
|
setCancelConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle cancel confirmation
|
||||||
|
const handleConfirmCancel = () => {
|
||||||
|
if (!orderToCancel) return;
|
||||||
|
|
||||||
|
setIsCancelling(true);
|
||||||
|
window.socketManager.emit('cancelOrder', { orderId: orderToCancel.orderId }, (response) => {
|
||||||
|
setIsCancelling(false);
|
||||||
|
setCancelConfirmOpen(false);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
console.log('Order cancelled:', response.orderId);
|
||||||
|
// Refresh orders list
|
||||||
|
fetchOrders();
|
||||||
|
} else {
|
||||||
|
setError(response.error || 'Failed to cancel order');
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrderToCancel(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle cancel dialog close
|
||||||
|
const handleCancelDialogClose = () => {
|
||||||
|
if (!isCancelling) {
|
||||||
|
setCancelConfirmOpen(false);
|
||||||
|
setOrderToCancel(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: { xs: 1, sm: 3 }, display: "flex", justifyContent: "center" }}>
|
<Box sx={{ p: { xs: 1, sm: 3 }, display: "flex", justifyContent: "center" }}>
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -154,28 +191,27 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: { xs: 1, sm: 3 } }}>
|
<Box sx={{ p: { xs: 1, sm: 3 } }}>
|
||||||
{orders.length > 0 ? (
|
{orders.length > 0 ? (
|
||||||
<TableContainer component={Paper}>
|
<TableContainer component={Paper}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Bestellnummer</TableCell>
|
<TableCell>{t ? t('orders.table.orderNumber') : 'Bestellnummer'}</TableCell>
|
||||||
<TableCell>Datum</TableCell>
|
<TableCell>{t ? t('orders.table.date') : 'Datum'}</TableCell>
|
||||||
<TableCell>Status</TableCell>
|
<TableCell>{t ? t('orders.table.status') : 'Status'}</TableCell>
|
||||||
<TableCell>Artikel</TableCell>
|
<TableCell>{t ? t('orders.table.items') : 'Artikel'}</TableCell>
|
||||||
<TableCell align="right">Summe</TableCell>
|
<TableCell align="right">{t ? t('orders.table.total') : 'Summe'}</TableCell>
|
||||||
<TableCell align="center">Aktionen</TableCell>
|
<TableCell align="center">{t ? t('orders.table.actions') : 'Aktionen'}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{orders.map((order) => {
|
{orders.map((order) => {
|
||||||
const displayStatus = getStatusDisplay(order.status);
|
const displayStatus = getStatusDisplay(order.status);
|
||||||
const subtotal = order.items.reduce(
|
const total = order.items.reduce(
|
||||||
(acc, item) => acc + item.price * item.quantity_ordered,
|
(acc, item) => acc + item.price * item.quantity_ordered,
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
const total = subtotal + order.delivery_cost;
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={order.orderId} hover>
|
<TableRow key={order.orderId} hover>
|
||||||
<TableCell>{order.orderId}</TableCell>
|
<TableCell>{order.orderId}</TableCell>
|
||||||
@@ -188,11 +224,11 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "8px",
|
gap: "8px",
|
||||||
color: getStatusColor(displayStatus),
|
color: getStatusColor(order.status),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: "1.2rem" }}>
|
<span style={{ fontSize: "1.2rem" }}>
|
||||||
{getStatusEmoji(displayStatus)}
|
{getStatusEmoji(order.status)}
|
||||||
</span>
|
</span>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
@@ -202,26 +238,62 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
|||||||
{displayStatus}
|
{displayStatus}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{order.items.reduce(
|
{order.items
|
||||||
(acc, item) => acc + item.quantity_ordered,
|
.filter(item => {
|
||||||
0
|
// Exclude delivery items - backend uses deliveryMethod ID as item name
|
||||||
)}
|
const itemName = item.name || '';
|
||||||
|
return itemName !== 'DHL' &&
|
||||||
|
itemName !== 'DPD' &&
|
||||||
|
itemName !== 'Sperrgut' &&
|
||||||
|
itemName !== 'Abholung';
|
||||||
|
})
|
||||||
|
.reduce(
|
||||||
|
(acc, item) => acc + item.quantity_ordered,
|
||||||
|
0
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{currencyFormatter.format(total)}
|
{currencyFormatter.format(total)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="center">
|
<TableCell align="center">
|
||||||
<Tooltip title="Details anzeigen">
|
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center' }}>
|
||||||
<IconButton
|
<Tooltip title={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}>
|
||||||
size="small"
|
<IconButton
|
||||||
color="primary"
|
size="small"
|
||||||
onClick={() => handleViewDetails(order.orderId)}
|
color="primary"
|
||||||
>
|
onClick={() => handleViewDetails(order.orderId)}
|
||||||
<SearchIcon />
|
aria-label={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}
|
||||||
</IconButton>
|
>
|
||||||
</Tooltip>
|
<SearchIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
{isOrderCancelable(order) && (
|
||||||
|
<Tooltip title={t ? t('orders.tooltips.cancelOrder') : 'Bestellung stornieren'}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => handleCancelClick(order)}
|
||||||
|
aria-label={t ? t('orders.tooltips.cancelOrder') : 'Bestellung stornieren'}
|
||||||
|
>
|
||||||
|
<CancelIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
@@ -231,7 +303,7 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
|||||||
</TableContainer>
|
</TableContainer>
|
||||||
) : (
|
) : (
|
||||||
<Alert severity="info">
|
<Alert severity="info">
|
||||||
Sie haben noch keine Bestellungen aufgegeben.
|
{t ? t('orders.noOrders') : 'Sie haben noch keine Bestellungen aufgegeben.'}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<OrderDetailsDialog
|
<OrderDetailsDialog
|
||||||
@@ -239,8 +311,49 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
|||||||
onClose={handleCloseDetailsDialog}
|
onClose={handleCloseDetailsDialog}
|
||||||
order={selectedOrder}
|
order={selectedOrder}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Cancel Confirmation Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={cancelConfirmOpen}
|
||||||
|
onClose={handleCancelDialogClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
{t ? t('orders.cancelConfirm.title') : 'Bestellung stornieren'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
{t ? t('orders.cancelConfirm.message') : 'Sind Sie sicher, dass Sie diese Bestellung stornieren möchten?'}
|
||||||
|
</Typography>
|
||||||
|
{orderToCancel && (
|
||||||
|
<Typography variant="body2" sx={{ mt: 1, fontWeight: 'bold' }}>
|
||||||
|
{t ? t('orders.table.orderNumber') : 'Bestellnummer'}: {orderToCancel.orderId}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
onClick={handleCancelDialogClose}
|
||||||
|
disabled={isCancelling}
|
||||||
|
>
|
||||||
|
{t ? t('common.cancel') : 'Abbrechen'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmCancel}
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
disabled={isCancelling}
|
||||||
|
>
|
||||||
|
{isCancelling
|
||||||
|
? (t ? t('orders.cancelConfirm.cancelling') : 'Wird storniert...')
|
||||||
|
: (t ? t('orders.cancelConfirm.confirm') : 'Stornieren')
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default OrdersTab;
|
export default withI18n()(OrdersTab);
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class PaymentConfirmationDialog extends Component {
|
|||||||
|
|
||||||
{isCompletingOrder && (
|
{isCompletingOrder && (
|
||||||
<Typography variant="body2" sx={{ mt: 2, color: '#2e7d32', p: 2, bgcolor: '#e8f5e8', borderRadius: 1 }}>
|
<Typography variant="body2" sx={{ mt: 2, color: '#2e7d32', p: 2, bgcolor: '#e8f5e8', borderRadius: 1 }}>
|
||||||
Bestellung wird abgeschlossen...
|
{this.props.t ? this.props.t('orders.processing') : 'Bestellung wird abgeschlossen...'}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useEffect, useCallback } from "react";
|
import React, { useEffect, useCallback } from "react";
|
||||||
import { Box, Typography, Radio } from "@mui/material";
|
import { Box, Typography, Radio } from "@mui/material";
|
||||||
|
import { withI18n } from "../../i18n/withTranslation.js";
|
||||||
|
|
||||||
const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeliveryMethodChange, cartItems = [], deliveryCost = 0 }) => {
|
const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeliveryMethodChange, cartItems = [], deliveryCost = 0, t }) => {
|
||||||
|
|
||||||
// Calculate total amount
|
// Calculate total amount
|
||||||
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
|
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
|
||||||
@@ -24,7 +25,7 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
|
|||||||
// Handle delivery method changes - auto-switch to stripe when DHL/DPD is selected
|
// Handle delivery method changes - auto-switch to stripe when DHL/DPD is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((deliveryMethod === "DHL" || deliveryMethod === "DPD") && paymentMethod === "cash") {
|
if ((deliveryMethod === "DHL" || deliveryMethod === "DPD") && paymentMethod === "cash") {
|
||||||
handlePaymentMethodChange({ target: { value: "mollie" /*stripe*/ } });
|
handlePaymentMethodChange({ target: { value: "wire" /*stripe*/ } });
|
||||||
}
|
}
|
||||||
}, [deliveryMethod, paymentMethod, handlePaymentMethodChange]);
|
}, [deliveryMethod, paymentMethod, handlePaymentMethodChange]);
|
||||||
|
|
||||||
@@ -38,8 +39,8 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
|
|||||||
const paymentOptions = [
|
const paymentOptions = [
|
||||||
{
|
{
|
||||||
id: "wire",
|
id: "wire",
|
||||||
name: "Überweisung",
|
name: t ? t('payment.methods.bankTransfer') : "Überweisung",
|
||||||
description: "Bezahlen Sie per Banküberweisung",
|
description: t ? t('payment.methods.bankTransferDescription') : "Bezahlen Sie per Banküberweisung",
|
||||||
disabled: totalAmount === 0,
|
disabled: totalAmount === 0,
|
||||||
},
|
},
|
||||||
/*{
|
/*{
|
||||||
@@ -56,12 +57,12 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
|
|||||||
"/assets/images/visa_electron.png",
|
"/assets/images/visa_electron.png",
|
||||||
],
|
],
|
||||||
},*/
|
},*/
|
||||||
{
|
/*{
|
||||||
id: "mollie",
|
id: "mollie",
|
||||||
name: "Karte, Sofortüberweisung, Apple Pay, Google Pay, PayPal",
|
name: t ? t('payment.methods.cardPayment') : "Karte, Sofortüberweisung, Apple Pay, Google Pay, PayPal",
|
||||||
description: totalAmount < 0.50 && totalAmount > 0
|
description: totalAmount < 0.50 && totalAmount > 0
|
||||||
? "Bezahlen Sie per Karte oder Sofortüberweisung (Mindestbetrag: 0,50 €)"
|
? (t ? t('payment.methods.cardPaymentMinAmount') : "Bezahlen Sie per Karte oder Sofortüberweisung (Mindestbetrag: 0,50 €)")
|
||||||
: "Bezahlen Sie per Karte oder Sofortüberweisung",
|
: (t ? t('payment.methods.cardPaymentDescription') : "Bezahlen Sie per Karte oder Sofortüberweisung"),
|
||||||
disabled: totalAmount < 0.50 || (deliveryMethod !== "DHL" && deliveryMethod !== "DPD" && deliveryMethod !== "Abholung"),
|
disabled: totalAmount < 0.50 || (deliveryMethod !== "DHL" && deliveryMethod !== "DPD" && deliveryMethod !== "Abholung"),
|
||||||
icons: [
|
icons: [
|
||||||
"/assets/images/giropay.png",
|
"/assets/images/giropay.png",
|
||||||
@@ -69,18 +70,18 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
|
|||||||
"/assets/images/mastercard.png",
|
"/assets/images/mastercard.png",
|
||||||
"/assets/images/visa_electron.png",
|
"/assets/images/visa_electron.png",
|
||||||
],
|
],
|
||||||
},
|
},*/
|
||||||
{
|
{
|
||||||
id: "onDelivery",
|
id: "onDelivery",
|
||||||
name: "Nachnahme",
|
name: t ? t('payment.methods.cashOnDelivery') : "Nachnahme",
|
||||||
description: "Bezahlen Sie bei Lieferung (8,99 € Aufschlag)",
|
description: t ? t('payment.methods.cashOnDeliveryDescription') : "Bezahlen Sie bei Lieferung (8,99 € Aufschlag)",
|
||||||
disabled: totalAmount === 0 || deliveryMethod !== "DHL",
|
disabled: totalAmount === 0 || deliveryMethod !== "DHL",
|
||||||
icons: ["/assets/images/cash.png"],
|
icons: ["/assets/images/cash.png"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cash",
|
id: "cash",
|
||||||
name: "Zahlung in der Filiale",
|
name: t ? t('payment.methods.cashInStore') : "Zahlung in der Filiale",
|
||||||
description: "Bei Abholung bezahlen",
|
description: t ? t('payment.methods.cashInStoreDescription') : "Bei Abholung bezahlen",
|
||||||
disabled: false, // Always enabled
|
disabled: false, // Always enabled
|
||||||
icons: ["/assets/images/cash.png"],
|
icons: ["/assets/images/cash.png"],
|
||||||
},
|
},
|
||||||
@@ -89,7 +90,7 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
Zahlungsart wählen
|
{t ? t('payment.methods.selectPaymentMethod') : 'Zahlungsart wählen'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ mb: 3 }}>
|
<Box sx={{ mb: 3 }}>
|
||||||
@@ -189,4 +190,4 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PaymentMethodSelector;
|
export default withI18n()(PaymentMethodSelector);
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
Snackbar
|
Snackbar
|
||||||
} from '@mui/material';
|
} 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 {
|
class SettingsTab extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -47,7 +48,7 @@ class SettingsTab extends Component {
|
|||||||
this.setState({ newEmail: user.email || '' });
|
this.setState({ newEmail: user.email || '' });
|
||||||
|
|
||||||
// Check if user has an API key
|
// Check if user has an API key
|
||||||
this.props.socket.emit('isApiKey', (response) => {
|
window.socketManager.emit('isApiKey', (response) => {
|
||||||
if (response.success && response.hasApiKey) {
|
if (response.success && response.hasApiKey) {
|
||||||
this.setState({
|
this.setState({
|
||||||
hasApiKey: true,
|
hasApiKey: true,
|
||||||
@@ -72,38 +73,38 @@ class SettingsTab extends Component {
|
|||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!this.state.currentPassword || !this.state.newPassword || !this.state.confirmPassword) {
|
if (!this.state.currentPassword || !this.state.newPassword || !this.state.confirmPassword) {
|
||||||
this.setState({ passwordError: 'Bitte füllen Sie alle Felder aus' });
|
this.setState({ passwordError: this.props.t ? this.props.t('settings.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.newPassword !== this.state.confirmPassword) {
|
if (this.state.newPassword !== this.state.confirmPassword) {
|
||||||
this.setState({ passwordError: 'Die neuen Passwörter stimmen nicht überein' });
|
this.setState({ passwordError: this.props.t ? this.props.t('settings.errors.passwordsNotMatch') : 'Die neuen Passwörter stimmen nicht überein' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.newPassword.length < 8) {
|
if (this.state.newPassword.length < 8) {
|
||||||
this.setState({ passwordError: 'Das neue Passwort muss mindestens 8 Zeichen lang sein' });
|
this.setState({ passwordError: this.props.t ? this.props.t('settings.errors.passwordTooShort') : 'Das neue Passwort muss mindestens 8 Zeichen lang sein' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ loading: true });
|
this.setState({ loading: true });
|
||||||
|
|
||||||
// Call socket.io endpoint to update password
|
// Call socket.io endpoint to update password
|
||||||
this.props.socket.emit('updatePassword',
|
window.socketManager.emit('updatePassword',
|
||||||
{ oldPassword: this.state.currentPassword, newPassword: this.state.newPassword },
|
{ oldPassword: this.state.currentPassword, newPassword: this.state.newPassword },
|
||||||
(response) => {
|
(response) => {
|
||||||
this.setState({ loading: false });
|
this.setState({ loading: false });
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
this.setState({
|
this.setState({
|
||||||
passwordSuccess: 'Passwort erfolgreich aktualisiert',
|
passwordSuccess: this.props.t ? this.props.t('settings.success.passwordUpdated') : 'Passwort erfolgreich aktualisiert',
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
confirmPassword: ''
|
confirmPassword: ''
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.setState({
|
this.setState({
|
||||||
passwordError: response.message || 'Fehler beim Aktualisieren des Passworts'
|
passwordError: response.message || (this.props.t ? this.props.t('settings.errors.passwordUpdateError') : 'Fehler beim Aktualisieren des Passworts')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,26 +122,26 @@ class SettingsTab extends Component {
|
|||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!this.state.password || !this.state.newEmail) {
|
if (!this.state.password || !this.state.newEmail) {
|
||||||
this.setState({ emailError: 'Bitte füllen Sie alle Felder aus' });
|
this.setState({ emailError: this.props.t ? this.props.t('settings.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.state.newEmail)) {
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.state.newEmail)) {
|
||||||
this.setState({ emailError: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
this.setState({ emailError: this.props.t ? this.props.t('settings.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ loading: true });
|
this.setState({ loading: true });
|
||||||
|
|
||||||
// Call socket.io endpoint to update email
|
// Call socket.io endpoint to update email
|
||||||
this.props.socket.emit('updateEmail',
|
window.socketManager.emit('updateEmail',
|
||||||
{ password: this.state.password, email: this.state.newEmail },
|
{ password: this.state.password, email: this.state.newEmail },
|
||||||
(response) => {
|
(response) => {
|
||||||
this.setState({ loading: false });
|
this.setState({ loading: false });
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
this.setState({
|
this.setState({
|
||||||
emailSuccess: 'E-Mail-Adresse erfolgreich aktualisiert',
|
emailSuccess: this.props.t ? this.props.t('settings.success.emailUpdated') : 'E-Mail-Adresse erfolgreich aktualisiert',
|
||||||
password: ''
|
password: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,7 +158,7 @@ class SettingsTab extends Component {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.setState({
|
this.setState({
|
||||||
emailError: response.message || 'Fehler beim Aktualisieren der E-Mail-Adresse'
|
emailError: response.message || (this.props.t ? this.props.t('settings.errors.emailUpdateError') : 'Fehler beim Aktualisieren der E-Mail-Adresse')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,7 +184,7 @@ class SettingsTab extends Component {
|
|||||||
try {
|
try {
|
||||||
const user = JSON.parse(storedUser);
|
const user = JSON.parse(storedUser);
|
||||||
|
|
||||||
this.props.socket.emit('createApiKey', user.id, (response) => {
|
window.socketManager.emit('createApiKey', user.id, (response) => {
|
||||||
this.setState({ loadingApiKey: false });
|
this.setState({ loadingApiKey: false });
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
@@ -238,7 +239,7 @@ class SettingsTab extends Component {
|
|||||||
<Box sx={{ p: { xs: 1, sm: 3 } }}>
|
<Box sx={{ p: { xs: 1, sm: 3 } }}>
|
||||||
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
|
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
|
||||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||||
Passwort ändern
|
{this.props.t ? this.props.t('settings.changePassword') : 'Passwort ändern'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{this.state.passwordError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.passwordError}</Alert>}
|
{this.state.passwordError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.passwordError}</Alert>}
|
||||||
@@ -247,7 +248,7 @@ class SettingsTab extends Component {
|
|||||||
<Box component="form" onSubmit={this.handleUpdatePassword}>
|
<Box component="form" onSubmit={this.handleUpdatePassword}>
|
||||||
<TextField
|
<TextField
|
||||||
margin="normal"
|
margin="normal"
|
||||||
label="Aktuelles Passwort"
|
label={this.props.t ? this.props.t('settings.currentPassword') : 'Aktuelles Passwort'}
|
||||||
type="password"
|
type="password"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={this.state.currentPassword}
|
value={this.state.currentPassword}
|
||||||
@@ -257,7 +258,7 @@ class SettingsTab extends Component {
|
|||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
margin="normal"
|
margin="normal"
|
||||||
label="Neues Passwort"
|
label={this.props.t ? this.props.t('settings.newPassword') : 'Neues Passwort'}
|
||||||
type="password"
|
type="password"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={this.state.newPassword}
|
value={this.state.newPassword}
|
||||||
@@ -267,7 +268,7 @@ class SettingsTab extends Component {
|
|||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
margin="normal"
|
margin="normal"
|
||||||
label="Neues Passwort bestätigen"
|
label={this.props.t ? this.props.t('settings.confirmNewPassword') : 'Neues Passwort bestätigen'}
|
||||||
type="password"
|
type="password"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={this.state.confirmPassword}
|
value={this.state.confirmPassword}
|
||||||
@@ -282,7 +283,7 @@ class SettingsTab extends Component {
|
|||||||
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
||||||
disabled={this.state.loading}
|
disabled={this.state.loading}
|
||||||
>
|
>
|
||||||
{this.state.loading ? <CircularProgress size={24} /> : 'Passwort aktualisieren'}
|
{this.state.loading ? <CircularProgress size={24} /> : (this.props.t ? this.props.t('settings.updatePassword') : 'Passwort aktualisieren')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -291,7 +292,7 @@ class SettingsTab extends Component {
|
|||||||
|
|
||||||
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
|
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
|
||||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||||
E-Mail-Adresse ändern
|
{this.props.t ? this.props.t('settings.changeEmail') : 'E-Mail-Adresse ändern'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{this.state.emailError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.emailError}</Alert>}
|
{this.state.emailError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.emailError}</Alert>}
|
||||||
@@ -300,7 +301,7 @@ class SettingsTab extends Component {
|
|||||||
<Box component="form" onSubmit={this.handleUpdateEmail}>
|
<Box component="form" onSubmit={this.handleUpdateEmail}>
|
||||||
<TextField
|
<TextField
|
||||||
margin="normal"
|
margin="normal"
|
||||||
label="Passwort"
|
label={this.props.t ? this.props.t('settings.password') : 'Passwort'}
|
||||||
type="password"
|
type="password"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={this.state.password}
|
value={this.state.password}
|
||||||
@@ -310,7 +311,7 @@ class SettingsTab extends Component {
|
|||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
margin="normal"
|
margin="normal"
|
||||||
label="Neue E-Mail-Adresse"
|
label={this.props.t ? this.props.t('settings.newEmail') : 'Neue E-Mail-Adresse'}
|
||||||
type="email"
|
type="email"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={this.state.newEmail}
|
value={this.state.newEmail}
|
||||||
@@ -325,7 +326,7 @@ class SettingsTab extends Component {
|
|||||||
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
||||||
disabled={this.state.loading}
|
disabled={this.state.loading}
|
||||||
>
|
>
|
||||||
{this.state.loading ? <CircularProgress size={24} /> : 'E-Mail aktualisieren'}
|
{this.state.loading ? <CircularProgress size={24} /> : (this.props.t ? this.props.t('settings.updateEmail') : 'E-Mail aktualisieren')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -334,11 +335,11 @@ class SettingsTab extends Component {
|
|||||||
|
|
||||||
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
|
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
|
||||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||||
API-Schlüssel
|
{this.props.t ? this.props.t('settings.apiKey') : 'API-Schlüssel'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
Verwenden Sie Ihren API-Schlüssel für die Integration mit externen Anwendungen.
|
{this.props.t ? this.props.t('settings.apiKeyDescription') : 'Verwenden Sie Ihren API-Schlüssel für die Integration mit externen Anwendungen.'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{this.state.apiKeyError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.apiKeyError}</Alert>}
|
{this.state.apiKeyError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.apiKeyError}</Alert>}
|
||||||
@@ -347,14 +348,14 @@ class SettingsTab extends Component {
|
|||||||
{this.state.apiKeySuccess}
|
{this.state.apiKeySuccess}
|
||||||
{this.state.apiKey && this.state.apiKeyDisplay !== '************' && (
|
{this.state.apiKey && this.state.apiKeyDisplay !== '************' && (
|
||||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||||
Speichern Sie diesen Schlüssel sicher. Er wird aus Sicherheitsgründen in 10 Sekunden ausgeblendet.
|
{this.props.t ? this.props.t('settings.success.apiKeyWarning') : 'Speichern Sie diesen Schlüssel sicher. Er wird aus Sicherheitsgründen in 10 Sekunden ausgeblendet.'}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||||
API-Dokumentation: {' '}
|
{this.props.t ? this.props.t('settings.apiDocumentation') : 'API-Dokumentation:'} {' '}
|
||||||
<a
|
<a
|
||||||
href={`${window.location.protocol}//${window.location.host}/api/`}
|
href={`${window.location.protocol}//${window.location.host}/api/`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -367,7 +368,7 @@ class SettingsTab extends Component {
|
|||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mt: 2 }}>
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mt: 2 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="API-Schlüssel"
|
label={this.props.t ? this.props.t('settings.apiKey') : 'API-Schlüssel'}
|
||||||
value={this.state.apiKeyDisplay}
|
value={this.state.apiKeyDisplay}
|
||||||
disabled
|
disabled
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -381,11 +382,12 @@ class SettingsTab extends Component {
|
|||||||
{this.state.apiKeyDisplay !== '************' && this.state.apiKey && (
|
{this.state.apiKeyDisplay !== '************' && this.state.apiKey && (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={this.handleCopyToClipboard}
|
onClick={this.handleCopyToClipboard}
|
||||||
|
aria-label={this.props.t ? this.props.t('settings.copyToClipboard') : 'In Zwischenablage kopieren'}
|
||||||
sx={{
|
sx={{
|
||||||
color: '#2e7d32',
|
color: '#2e7d32',
|
||||||
'&:hover': { bgcolor: 'rgba(46, 125, 50, 0.1)' }
|
'&:hover': { bgcolor: 'rgba(46, 125, 50, 0.1)' }
|
||||||
}}
|
}}
|
||||||
title="In Zwischenablage kopieren"
|
title={this.props.t ? this.props.t('settings.copyToClipboard') : 'In Zwischenablage kopieren'}
|
||||||
>
|
>
|
||||||
<ContentCopy />
|
<ContentCopy />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -405,7 +407,7 @@ class SettingsTab extends Component {
|
|||||||
{this.state.loadingApiKey ? (
|
{this.state.loadingApiKey ? (
|
||||||
<CircularProgress size={24} />
|
<CircularProgress size={24} />
|
||||||
) : (
|
) : (
|
||||||
this.state.hasApiKey ? 'Regenerieren' : 'Generieren'
|
this.state.hasApiKey ? (this.props.t ? this.props.t('settings.regenerate') : 'Regenerieren') : (this.props.t ? this.props.t('settings.generate') : 'Generieren')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -415,7 +417,7 @@ class SettingsTab extends Component {
|
|||||||
open={this.state.copySnackbarOpen}
|
open={this.state.copySnackbarOpen}
|
||||||
autoHideDuration={3000}
|
autoHideDuration={3000}
|
||||||
onClose={this.handleCloseSnackbar}
|
onClose={this.handleCloseSnackbar}
|
||||||
message="API-Schlüssel in Zwischenablage kopiert"
|
message={this.props.t ? this.props.t('settings.apiKeyCopied') : 'API-Schlüssel in Zwischenablage kopiert'}
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -423,4 +425,4 @@ class SettingsTab extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SettingsTab;
|
export default withI18n()(SettingsTab);
|
||||||
@@ -200,13 +200,13 @@ const config = {
|
|||||||
|
|
||||||
// Shipping
|
// Shipping
|
||||||
shipping: {
|
shipping: {
|
||||||
defaultCost: "4.99 EUR",
|
defaultCost: "5.90 EUR",
|
||||||
defaultService: "Standard"
|
defaultService: "Standard"
|
||||||
},
|
},
|
||||||
|
|
||||||
// Images
|
// Images
|
||||||
images: {
|
images: {
|
||||||
logo: "/assets/images/sh.png",
|
logo: "/assets/images/sh.avif",
|
||||||
placeholder: "/assets/images/nopicture.jpg"
|
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
|
// @note Dummy data for grow tent configurator - no backend calls
|
||||||
|
// descriptions now keys for translation
|
||||||
export const tentShapes = [
|
export const tentShapes = [
|
||||||
{
|
{
|
||||||
id: '60x60',
|
id: '60x60',
|
||||||
name: '60x60cm',
|
name: '60x60cm',
|
||||||
description: 'Kompakt - ideal für kleine Räume',
|
descriptionKey: 'kitConfig.description60x60',
|
||||||
footprint: '60x60',
|
footprint: '60x60',
|
||||||
minPlants: 1,
|
minPlants: 1,
|
||||||
maxPlants: 2,
|
maxPlants: 2,
|
||||||
@@ -13,7 +14,7 @@ export const tentShapes = [
|
|||||||
{
|
{
|
||||||
id: '80x80',
|
id: '80x80',
|
||||||
name: '80x80cm',
|
name: '80x80cm',
|
||||||
description: 'Mittel - perfekte Balance',
|
descriptionKey: 'kitConfig.description80x80',
|
||||||
footprint: '80x80',
|
footprint: '80x80',
|
||||||
minPlants: 2,
|
minPlants: 2,
|
||||||
maxPlants: 4,
|
maxPlants: 4,
|
||||||
@@ -23,7 +24,7 @@ export const tentShapes = [
|
|||||||
{
|
{
|
||||||
id: '100x100',
|
id: '100x100',
|
||||||
name: '100x100cm',
|
name: '100x100cm',
|
||||||
description: 'Groß - für erfahrene Grower',
|
descriptionKey: 'kitConfig.description100x100',
|
||||||
footprint: '100x100',
|
footprint: '100x100',
|
||||||
minPlants: 4,
|
minPlants: 4,
|
||||||
maxPlants: 6,
|
maxPlants: 6,
|
||||||
@@ -33,7 +34,7 @@ export const tentShapes = [
|
|||||||
{
|
{
|
||||||
id: '120x60',
|
id: '120x60',
|
||||||
name: '120x60cm',
|
name: '120x60cm',
|
||||||
description: 'Rechteckig - maximale Raumnutzung',
|
descriptionKey: 'kitConfig.description120x60',
|
||||||
footprint: '120x60',
|
footprint: '120x60',
|
||||||
minPlants: 3,
|
minPlants: 3,
|
||||||
maxPlants: 6,
|
maxPlants: 6,
|
||||||
@@ -41,229 +42,3 @@ export const tentShapes = [
|
|||||||
visualDepth: 60
|
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,113 +1,197 @@
|
|||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import { initReactI18next } from 'react-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/translation.js';
|
import translationDE from './locales/de/index.js';
|
||||||
import translationEN from './locales/en/translation.js';
|
import legalAgbDeliveryDE from './locales/de/legal-agb-delivery.js';
|
||||||
import translationAR from './locales/ar/translation.js';
|
import legalAgbPaymentDE from './locales/de/legal-agb-payment.js';
|
||||||
import translationBG from './locales/bg/translation.js';
|
import legalAgbConsumerDE from './locales/de/legal-agb-consumer.js';
|
||||||
import translationCS from './locales/cs/translation.js';
|
import legalDatenschutzBasicDE from './locales/de/legal-datenschutz-basic.js';
|
||||||
import translationEL from './locales/el/translation.js';
|
import legalDatenschutzCustomerDE from './locales/de/legal-datenschutz-customer.js';
|
||||||
import translationES from './locales/es/translation.js';
|
import legalDatenschutzGoogleOrdersDE from './locales/de/legal-datenschutz-google-orders.js';
|
||||||
import translationFR from './locales/fr/translation.js';
|
import legalDatenschutzNewsletterDE from './locales/de/legal-datenschutz-newsletter.js';
|
||||||
import translationHR from './locales/hr/translation.js';
|
import legalDatenschutzChatbotDE from './locales/de/legal-datenschutz-chatbot.js';
|
||||||
import translationHU from './locales/hu/translation.js';
|
import legalDatenschutzCookiesPaymentDE from './locales/de/legal-datenschutz-cookies-payment.js';
|
||||||
import translationIT from './locales/it/translation.js';
|
import legalDatenschutzRightsDE from './locales/de/legal-datenschutz-rights.js';
|
||||||
import translationPL from './locales/pl/translation.js';
|
import legalImpressumDE from './locales/de/legal-impressum.js';
|
||||||
import translationRO from './locales/ro/translation.js';
|
import legalWiderrufDE from './locales/de/legal-widerruf.js';
|
||||||
import translationRU from './locales/ru/translation.js';
|
import legalBatterieDE from './locales/de/legal-batterie.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';
|
|
||||||
|
|
||||||
|
// Language loading cache to prevent duplicate loads
|
||||||
|
const languageCache = new Set(['de']);
|
||||||
|
const loadingPromises = new Map();
|
||||||
|
|
||||||
|
// Lazy loading function for languages
|
||||||
|
const loadLanguage = async (language) => {
|
||||||
|
if (languageCache.has(language)) {
|
||||||
|
return; // Already loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingPromises.has(language)) {
|
||||||
|
return loadingPromises.get(language); // Already loading
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingPromise = (async () => {
|
||||||
|
try {
|
||||||
|
console.log(`🌍 Lazy loading language: ${language}`);
|
||||||
|
|
||||||
|
// 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`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
loadingPromises.set(language, loadingPromise);
|
||||||
|
return loadingPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check localStorage
|
||||||
|
try {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
const localLang = localStorage.getItem('i18nextLng');
|
||||||
|
if (localLang && localLang !== 'de') {
|
||||||
|
return localLang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// LocalStorage not available
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Always default to German (don't detect browser language)
|
||||||
|
return 'de';
|
||||||
|
},
|
||||||
|
cacheUserLanguage(lng) {
|
||||||
|
// Only cache in browser environment
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {
|
const resources = {
|
||||||
de: {
|
de: {
|
||||||
translation: translationDE
|
translation: translationDE,
|
||||||
},
|
'legal-agb-delivery': legalAgbDeliveryDE,
|
||||||
en: {
|
'legal-agb-payment': legalAgbPaymentDE,
|
||||||
translation: translationEN
|
'legal-agb-consumer': legalAgbConsumerDE,
|
||||||
},
|
'legal-datenschutz-basic': legalDatenschutzBasicDE,
|
||||||
ar: {
|
'legal-datenschutz-customer': legalDatenschutzCustomerDE,
|
||||||
translation: translationAR
|
'legal-datenschutz-google-orders': legalDatenschutzGoogleOrdersDE,
|
||||||
},
|
'legal-datenschutz-newsletter': legalDatenschutzNewsletterDE,
|
||||||
bg: {
|
'legal-datenschutz-chatbot': legalDatenschutzChatbotDE,
|
||||||
translation: translationBG
|
'legal-datenschutz-cookies-payment': legalDatenschutzCookiesPaymentDE,
|
||||||
},
|
'legal-datenschutz-rights': legalDatenschutzRightsDE,
|
||||||
cs: {
|
'legal-impressum': legalImpressumDE,
|
||||||
translation: translationCS
|
'legal-widerruf': legalWiderrufDE,
|
||||||
},
|
'legal-batterie': legalBatterieDE
|
||||||
el: {
|
|
||||||
translation: translationEL
|
|
||||||
},
|
|
||||||
es: {
|
|
||||||
translation: translationES
|
|
||||||
},
|
|
||||||
fr: {
|
|
||||||
translation: translationFR
|
|
||||||
},
|
|
||||||
hr: {
|
|
||||||
translation: translationHR
|
|
||||||
},
|
|
||||||
hu: {
|
|
||||||
translation: translationHU
|
|
||||||
},
|
|
||||||
it: {
|
|
||||||
translation: translationIT
|
|
||||||
},
|
|
||||||
pl: {
|
|
||||||
translation: translationPL
|
|
||||||
},
|
|
||||||
ro: {
|
|
||||||
translation: translationRO
|
|
||||||
},
|
|
||||||
ru: {
|
|
||||||
translation: translationRU
|
|
||||||
},
|
|
||||||
sk: {
|
|
||||||
translation: translationSK
|
|
||||||
},
|
|
||||||
sl: {
|
|
||||||
translation: translationSL
|
|
||||||
},
|
|
||||||
sr: {
|
|
||||||
translation: translationSR
|
|
||||||
},
|
|
||||||
sv: {
|
|
||||||
translation: translationSV
|
|
||||||
},
|
|
||||||
tr: {
|
|
||||||
translation: translationTR
|
|
||||||
},
|
|
||||||
uk: {
|
|
||||||
translation: translationUK
|
|
||||||
},
|
|
||||||
zh: {
|
|
||||||
translation: translationZH
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(LanguageDetector)
|
.use({
|
||||||
|
type: 'languageDetector',
|
||||||
|
async: false,
|
||||||
|
detect: customDetector.lookup,
|
||||||
|
init() {},
|
||||||
|
cacheUserLanguage: customDetector.cacheUserLanguage
|
||||||
|
})
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
resources,
|
resources,
|
||||||
fallbackLng: 'de', // German as fallback since it's your primary language
|
fallbackLng: 'de',
|
||||||
lng: 'de', // Default language
|
|
||||||
debug: process.env.NODE_ENV === 'development',
|
debug: process.env.NODE_ENV === 'development',
|
||||||
|
|
||||||
// Language detection options
|
// Disable automatic language detection from browser
|
||||||
detection: {
|
detection: {
|
||||||
// Order of language detection methods
|
order: ['customDetector'],
|
||||||
order: ['localStorage', 'navigator', 'htmlTag'],
|
caches: ['localStorage', 'sessionStorage']
|
||||||
// Cache the language selection
|
|
||||||
caches: ['localStorage'],
|
|
||||||
// Check for language in localStorage
|
|
||||||
lookupLocalStorage: 'i18nextLng'
|
|
||||||
},
|
},
|
||||||
|
|
||||||
interpolation: {
|
interpolation: {
|
||||||
@@ -120,10 +204,57 @@ i18n
|
|||||||
// React-specific options
|
// React-specific options
|
||||||
react: {
|
react: {
|
||||||
useSuspense: false // Disable suspense for class components compatibility
|
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
|
// Override changeLanguage to load languages on demand
|
||||||
export { withI18n, withTranslation, withLanguage, LanguageContext, LanguageProvider } from './withTranslation.js';
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
export default i18n;
|
// 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';
|
||||||
50
src/i18n/locales/ar/auth.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
export default {
|
||||||
|
"login": "تسجيل الدخول",
|
||||||
|
"register": "تسجيل",
|
||||||
|
"logout": "تسجيل خروج",
|
||||||
|
"profile": "الملف الشخصي",
|
||||||
|
"email": "البريد الإلكتروني",
|
||||||
|
"password": "كلمة المرور",
|
||||||
|
"newPassword": "كلمة المرور الجديدة",
|
||||||
|
"confirmPassword": "تأكيد كلمة المرور",
|
||||||
|
"forgotPassword": "هل نسيت كلمة المرور؟",
|
||||||
|
"loginWithGoogle": "تسجيل الدخول باستخدام جوجل",
|
||||||
|
"or": "أو",
|
||||||
|
"privacyAccept": "بالنقر على \"تسجيل الدخول باستخدام جوجل\" أوافق على",
|
||||||
|
"privacyPolicy": "سياسة الخصوصية",
|
||||||
|
"passwordMinLength": "يجب أن تكون كلمة المرور 8 أحرف على الأقل",
|
||||||
|
"newPasswordMinLength": "يجب أن تكون كلمة المرور الجديدة 8 أحرف على الأقل",
|
||||||
|
"backToHome": "العودة إلى الصفحة الرئيسية",
|
||||||
|
"menu": {
|
||||||
|
"profile": "الملف الشخصي",
|
||||||
|
"myProfile": "ملفي الشخصي",
|
||||||
|
"checkout": "إتمام الشراء",
|
||||||
|
"orders": "الطلبات",
|
||||||
|
"settings": "الإعدادات",
|
||||||
|
"adminDashboard": "لوحة تحكم المسؤول",
|
||||||
|
"adminUsers": "مستخدمو المسؤول"
|
||||||
|
},
|
||||||
|
"resetPassword": {
|
||||||
|
"title": "إعادة تعيين كلمة المرور",
|
||||||
|
"button": "إعادة تعيين كلمة المرور",
|
||||||
|
"success": "تم إعادة تعيين كلمة المرور بنجاح! سيتم توجيهك لتسجيل الدخول قريبًا...",
|
||||||
|
"invalidToken": "لم يتم العثور على رمز صالح. يرجى استخدام الرابط من بريدك الإلكتروني.",
|
||||||
|
"error": "حدث خطأ أثناء إعادة تعيين كلمة المرور",
|
||||||
|
"emailSent": "تم إرسال رابط لإعادة تعيين كلمة المرور إلى بريدك الإلكتروني.",
|
||||||
|
"emailError": "حدث خطأ أثناء إرسال البريد الإلكتروني"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"fillAllFields": "يرجى ملء جميع الحقول",
|
||||||
|
"invalidEmail": "يرجى إدخال بريد إلكتروني صالح",
|
||||||
|
"passwordsNotMatch": "كلمات المرور غير متطابقة",
|
||||||
|
"passwordsNotMatchShort": "كلمات المرور غير متطابقة",
|
||||||
|
"enterEmail": "يرجى إدخال بريدك الإلكتروني",
|
||||||
|
"loginFailed": "فشل تسجيل الدخول",
|
||||||
|
"registerFailed": "فشل التسجيل",
|
||||||
|
"googleLoginFailed": "فشل تسجيل الدخول عبر جوجل",
|
||||||
|
"emailExists": "يوجد مستخدم بهذا البريد الإلكتروني بالفعل. يرجى استخدام بريد إلكتروني آخر أو تسجيل الدخول."
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"registerComplete": "تم التسجيل بنجاح. يمكنك الآن تسجيل الدخول."
|
||||||
|
}
|
||||||
|
};
|
||||||
39
src/i18n/locales/ar/cart.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export default {
|
||||||
|
"title": "العربة",
|
||||||
|
"empty": "فارغ",
|
||||||
|
"addToCart": "أضف إلى العربة",
|
||||||
|
"preorderCutting": "اطلب مسبقًا كقطع",
|
||||||
|
"continueShopping": "تابع التسوق",
|
||||||
|
"proceedToCheckout": "المتابعة إلى الدفع",
|
||||||
|
"productCount": "{{count}} {{count, plural, one {منتج} other {منتجات}}}",
|
||||||
|
"productSingular": "منتج",
|
||||||
|
"productPlural": "منتجات",
|
||||||
|
"removeFromCart": "إزالة من العربة",
|
||||||
|
"openCart": "افتح العربة",
|
||||||
|
"availableFrom": "متاح من {{date}}",
|
||||||
|
"backToOrder": "← العودة إلى الطلب",
|
||||||
|
"summary": {
|
||||||
|
"title": "ملخص الطلب",
|
||||||
|
"goodsNet": "البضائع (صافي):",
|
||||||
|
"shippingNet": "الشحن (صافي):",
|
||||||
|
"totalGoods": "إجمالي البضائع:",
|
||||||
|
"shippingCosts": "تكاليف الشحن:",
|
||||||
|
"total": "الإجمالي:",
|
||||||
|
"totalWeight": "الوزن الكلي: {{weight}} كجم",
|
||||||
|
"freeFrom100": "(مجاني من €100)",
|
||||||
|
"free": "مجاني"
|
||||||
|
},
|
||||||
|
"itemCount": {
|
||||||
|
"singular": "منتج",
|
||||||
|
"plural": "منتجات"
|
||||||
|
},
|
||||||
|
"sync": {
|
||||||
|
"title": "مزامنة العربة",
|
||||||
|
"description": "لديك عربة محفوظة في حسابك. يرجى اختيار كيفية المتابعة:",
|
||||||
|
"deleteServer": "حذف عربة الخادم",
|
||||||
|
"useServer": "استخدام عربة الخادم",
|
||||||
|
"merge": "دمج العربات",
|
||||||
|
"currentCart": "عربتك الحالية",
|
||||||
|
"serverCart": "العربة المحفوظة في ملفك الشخصي"
|
||||||
|
}
|
||||||
|
};
|
||||||
3
src/i18n/locales/ar/chat.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
"privacyRead": "تم القراءة والموافقة",
|
||||||
|
};
|
||||||
34
src/i18n/locales/ar/checkout.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export default {
|
||||||
|
"invoiceAddress": "عنوان الفاتورة",
|
||||||
|
"deliveryAddress": "عنوان التوصيل",
|
||||||
|
"saveForFuture": "احفظ للطلبات المستقبلية",
|
||||||
|
"pickupDate": "لمين التاريخ مطلوب استلام القصاصات؟",
|
||||||
|
"note": "ملاحظة",
|
||||||
|
"sameAddress": "عنوان التوصيل هو نفسه عنوان الفاتورة",
|
||||||
|
"termsAccept": "لقد قرأت الشروط والأحكام، سياسة الخصوصية، وأحكام حق الانسحاب",
|
||||||
|
"selectDeliveryMethod": "اختر طريقة الشحن",
|
||||||
|
"selectPaymentMethod": "اختر طريقة الدفع",
|
||||||
|
"orderSummary": "ملخص الطلب",
|
||||||
|
"addressValidationError": "يرجى التحقق من بياناتك في حقول العنوان.",
|
||||||
|
"processingOrder": "يتم معالجة الطلب...",
|
||||||
|
"completeOrder": "إتمام الطلب",
|
||||||
|
"termsValidationError": "يرجى قبول الشروط والأحكام، سياسة الخصوصية، وحق الانسحاب للمتابعة.",
|
||||||
|
"addressFields": {
|
||||||
|
"firstName": "الاسم الأول",
|
||||||
|
"lastName": "اسم العائلة",
|
||||||
|
"addressSupplement": "إضافة للعنوان",
|
||||||
|
"street": "الشارع",
|
||||||
|
"houseNumber": "رقم المنزل",
|
||||||
|
"postalCode": "الرمز البريدي",
|
||||||
|
"city": "المدينة",
|
||||||
|
"country": "البلد"
|
||||||
|
},
|
||||||
|
"validationErrors": {
|
||||||
|
"firstNameRequired": "الاسم الأول مطلوب",
|
||||||
|
"lastNameRequired": "اسم العائلة مطلوب",
|
||||||
|
"streetRequired": "الشارع مطلوب",
|
||||||
|
"houseNumberRequired": "رقم المنزل مطلوب",
|
||||||
|
"postalCodeRequired": "الرمز البريدي مطلوب",
|
||||||
|
"cityRequired": "المدينة مطلوبة"
|
||||||
|
}
|
||||||
|
};
|
||||||