Compare commits
269 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
e02b18e17f | ||
|
|
9ffbd5b84e | ||
|
|
f8dbb24823 | ||
|
|
13f1e14a3d | ||
|
|
6b7bcf4155 | ||
|
|
45258ac522 | ||
|
|
080515af68 | ||
|
|
33b229728f | ||
|
|
8d69b0566b | ||
|
|
280916224a | ||
|
|
fd77fc8f7f | ||
|
|
f5d6778def | ||
|
|
11a3522a97 | ||
|
|
51471d4a55 | ||
|
|
859a2c06d8 | ||
|
|
5c90d048fb | ||
|
|
cff9c88808 | ||
|
|
b78de53786 | ||
|
|
925667fc2c | ||
|
|
251352c660 | ||
|
|
88c757fd35 | ||
|
|
d8c802c2f1 | ||
|
|
056b63efa0 | ||
|
|
c7afad68b0 | ||
|
|
5157b7d781 | ||
|
|
9072a3c977 | ||
|
|
838e2fd786 | ||
|
|
abbb5e222d | ||
|
|
c216154bd7 | ||
|
|
9000b28ce5 | ||
|
|
8f2253f155 | ||
|
|
b33ece2875 | ||
|
|
02aff1e456 | ||
|
|
9e14827c91 | ||
|
|
8698816875 | ||
|
|
987de641e4 | ||
|
|
23e1742e40 | ||
|
|
205558d06c | ||
|
|
046979a64d | ||
|
|
161e377de4 | ||
|
|
73a88f508b |
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```
|
||||
60
.gitignore
vendored
@@ -1,61 +1,3 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.cursor/
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
/public/index.prerender.html
|
||||
/public/assets/images/prod*.jpg
|
||||
/public/assets/images/cat*.jpg
|
||||
/public/prerender.css
|
||||
/public/Artikel/*
|
||||
/public/Kategorie/*
|
||||
/public/agb
|
||||
/public/batteriegesetzhinweise
|
||||
/public/datenschutz
|
||||
/public/impressum
|
||||
/public/sitemap
|
||||
/public/widerrufsrecht
|
||||
/public/robots.txt
|
||||
/public/sitemap.xml
|
||||
/public/index.prerender.html
|
||||
/public/Konfigurator
|
||||
/public/profile
|
||||
/public/404
|
||||
|
||||
/public/products.xml
|
||||
/public/llms*
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
.hintrc
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Local configuration
|
||||
src/config.local.js
|
||||
|
||||
# Local development notes
|
||||
dev-notes.md
|
||||
dev-notes.local.md
|
||||
/logs
|
||||
66
.vscode/launch.json
vendored
@@ -3,20 +3,76 @@
|
||||
// This will install dependencies before starting the dev server
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
|
||||
{
|
||||
"type": "node-terminal",
|
||||
"name": "Start with API propxy to seedheads.de (Install Deps)",
|
||||
"request": "launch",
|
||||
"command": "npm run start:seedheads",
|
||||
"preLaunchTask": "npm: install",
|
||||
"cwd": "${workspaceFolder}"
|
||||
}, {
|
||||
"type": "node-terminal",
|
||||
"env": {
|
||||
"NODE_ENV": "development"
|
||||
},
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Start",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run start",
|
||||
"cwd": "${workspaceFolder}"
|
||||
"env": {
|
||||
"NODE_ENV": "development"
|
||||
},
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "Launch Chrome - Debug React App",
|
||||
"url": "https://dev.seedheads.de",
|
||||
"webRoot": "${workspaceFolder}/src",
|
||||
"sourceMapPathOverrides": {
|
||||
"webpack://reactshop/./src/*": "${webRoot}/*",
|
||||
"webpack://reactshop/src/*": "${webRoot}/*",
|
||||
"webpack:///src/*": "${webRoot}/*",
|
||||
"webpack:///./src/*": "${webRoot}/*",
|
||||
"webpack:///./*": "${workspaceFolder}/*",
|
||||
"webpack:///./~/*": "${workspaceFolder}/node_modules/*",
|
||||
"webpack://*": "${workspaceFolder}/*"
|
||||
},
|
||||
"smartStep": true,
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
"${workspaceFolder}/node_modules/**",
|
||||
"${workspaceFolder}/dist/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "attach",
|
||||
"name": "Attach to Chrome - Debug React App",
|
||||
"port": 9222,
|
||||
"webRoot": "${workspaceFolder}/src",
|
||||
"sourceMapPathOverrides": {
|
||||
"webpack://reactshop/./src/*": "${webRoot}/*",
|
||||
"webpack://reactshop/src/*": "${webRoot}/*",
|
||||
"webpack:///src/*": "${webRoot}/*",
|
||||
"webpack:///./src/*": "${webRoot}/*",
|
||||
"webpack:///./*": "${workspaceFolder}/*",
|
||||
"webpack:///./~/*": "${workspaceFolder}/node_modules/*",
|
||||
"webpack://*": "${workspaceFolder}/*"
|
||||
},
|
||||
"smartStep": true,
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
"${workspaceFolder}/node_modules/**",
|
||||
"${workspaceFolder}/dist/**"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
209
MULTILINGUAL_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# Multilingual Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Your website now supports multiple languages using **react-i18next**. The implementation is designed to work seamlessly with your existing class components and provides:
|
||||
|
||||
- **German (default)** and **English** support
|
||||
- Language persistence in localStorage
|
||||
- Dynamic language switching
|
||||
- SEO-friendly language attributes
|
||||
- Class component compatibility
|
||||
|
||||
## Features Implemented
|
||||
|
||||
### 1. Language Switcher
|
||||
- Located in the header next to the login/profile button
|
||||
- Shows current language (DE/EN) with flag icon
|
||||
- Dropdown menu for language selection
|
||||
- Persists selection in browser storage
|
||||
|
||||
### 2. Translated Components
|
||||
- **Header navigation**: Categories, Home links
|
||||
- **Authentication**: Login/register forms, profile menu
|
||||
- **Main pages**: Home, Actions, Store pages
|
||||
- **Cart**: Shopping cart title and sync dialog
|
||||
- **Product pages**: Basic UI elements (more can be added)
|
||||
- **Footer**: Basic elements (can be expanded)
|
||||
|
||||
### 3. Architecture
|
||||
- `src/i18n/index.js` - Main i18n configuration
|
||||
- `src/i18n/withTranslation.js` - HOCs for class components
|
||||
- `src/i18n/locales/de/translation.json` - German translations
|
||||
- `src/i18n/locales/en/translation.json` - English translations
|
||||
- `src/components/LanguageSwitcher.js` - Language selection component
|
||||
|
||||
## Usage for Developers
|
||||
|
||||
### Using Translations in Class Components
|
||||
|
||||
```javascript
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
|
||||
class MyComponent extends Component {
|
||||
render() {
|
||||
const { t } = this.props; // Translation function
|
||||
|
||||
return (
|
||||
<Typography>
|
||||
{t('navigation.home')} // Translates to "Startseite" or "Home"
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withI18n()(MyComponent);
|
||||
```
|
||||
|
||||
### Using Translations in Function Components
|
||||
|
||||
```javascript
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const MyComponent = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Typography>
|
||||
{t('navigation.home')}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Language Context Access
|
||||
|
||||
```javascript
|
||||
import { withLanguage } from '../i18n/withTranslation.js';
|
||||
|
||||
class MyComponent extends Component {
|
||||
render() {
|
||||
const { languageContext } = this.props;
|
||||
|
||||
return (
|
||||
<Button onClick={() => languageContext.changeLanguage('en')}>
|
||||
Switch to English
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withLanguage(MyComponent);
|
||||
```
|
||||
|
||||
## Adding New Translations
|
||||
|
||||
### 1. Add to German (`src/i18n/locales/de/translation.json`)
|
||||
```json
|
||||
{
|
||||
"newSection": {
|
||||
"title": "Neuer Titel",
|
||||
"description": "Neue Beschreibung"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Add to English (`src/i18n/locales/en/translation.json`)
|
||||
```json
|
||||
{
|
||||
"newSection": {
|
||||
"title": "New Title",
|
||||
"description": "New Description"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Use in Components
|
||||
```javascript
|
||||
{t('newSection.title')}
|
||||
```
|
||||
|
||||
## Adding New Languages
|
||||
|
||||
### 1. Create Translation File
|
||||
Create `src/i18n/locales/fr/translation.json` for French
|
||||
|
||||
### 2. Update i18n Configuration
|
||||
```javascript
|
||||
// src/i18n/index.js
|
||||
import translationFR from './locales/fr/translation.json';
|
||||
|
||||
const resources = {
|
||||
de: { translation: translationDE },
|
||||
en: { translation: translationEN },
|
||||
fr: { translation: translationFR } // Add new language
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Update Language Provider
|
||||
```javascript
|
||||
// src/i18n/withTranslation.js
|
||||
availableLanguages: ['de', 'en', 'fr'] // Add to available languages
|
||||
```
|
||||
|
||||
### 4. Update Language Switcher
|
||||
```javascript
|
||||
// src/components/LanguageSwitcher.js
|
||||
const names = {
|
||||
'de': 'Deutsch',
|
||||
'en': 'English',
|
||||
'fr': 'Français' // Add language name
|
||||
};
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Language Detection Order
|
||||
Currently set to: `['localStorage', 'navigator', 'htmlTag']`
|
||||
- First checks localStorage for saved preference
|
||||
- Falls back to browser language
|
||||
- Finally checks HTML lang attribute
|
||||
|
||||
### Fallback Language
|
||||
Set to German (`de`) as your primary language
|
||||
|
||||
### Debug Mode
|
||||
Enabled in development mode for easier debugging
|
||||
|
||||
## SEO Considerations
|
||||
|
||||
- HTML `lang` attribute updates automatically
|
||||
- Config object provides language-specific metadata
|
||||
- Descriptions and keywords are language-aware
|
||||
- Can be extended for hreflang tags and URL localization
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Namespace your translations** - Use nested objects for organization
|
||||
2. **Provide fallbacks** - Always have German as fallback since it's your primary market
|
||||
3. **Use interpolation** - For dynamic content: `t('welcome', { name: 'John' })`
|
||||
4. **Keep translations consistent** - Use same structure in all language files
|
||||
5. **Test thoroughly** - Verify all UI elements in both languages
|
||||
|
||||
## Current Translation Coverage
|
||||
|
||||
- ✅ Navigation and menus
|
||||
- ✅ Authentication flows
|
||||
- ✅ Basic product elements
|
||||
- ✅ Cart functionality
|
||||
- ✅ Main page content
|
||||
- ⏳ Detailed product descriptions (can be added)
|
||||
- ⏳ Legal pages content (can be added)
|
||||
- ⏳ Form validation messages (can be added)
|
||||
- ⏳ Error messages (can be added)
|
||||
|
||||
## Performance
|
||||
|
||||
- Translations are bundled and loaded immediately
|
||||
- No additional network requests
|
||||
- Lightweight implementation
|
||||
- Language switching is instant
|
||||
|
||||
## Browser Support
|
||||
|
||||
Works with all modern browsers that support:
|
||||
- ES6 modules
|
||||
- localStorage
|
||||
- React 19
|
||||
|
||||
The implementation is production-ready and can be extended based on your specific needs!
|
||||
40
README.md
@@ -11,3 +11,43 @@ Entpacken & Doppelklick auf `start-dev-seedheads.bat` - das Skript wird:
|
||||
- Abhängigkeiten automatisch installieren falls nötig
|
||||
- Entwicklungsserver mit API-Proxy zu seedheads.de starten
|
||||
- Browser öffnen auf http://localhost:9500
|
||||
|
||||
## Socket Connection Optimization
|
||||
|
||||
The application uses Socket.IO for real-time communication with the server. To improve initial loading performance, sockets are now connected lazily:
|
||||
|
||||
- Sockets are created with `autoConnect: false` and only establish a connection when:
|
||||
- The first `emit` is called on the socket
|
||||
- An explicit connection is requested via the context methods
|
||||
|
||||
### Usage
|
||||
|
||||
```jsx
|
||||
// In a component
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import SocketContext from '../contexts/SocketContext';
|
||||
import { emitAsync } from '../utils/socketUtils';
|
||||
|
||||
const MyComponent = () => {
|
||||
const { socket, socketB } = useContext(SocketContext);
|
||||
|
||||
useEffect(() => {
|
||||
// The socket will automatically connect when emit is called
|
||||
socket.emit('someEvent', { data: 'example' });
|
||||
|
||||
// Or use the utility for Promise-based responses
|
||||
emitAsync(socket, 'getData', { id: 123 })
|
||||
.then(response => console.log(response))
|
||||
.catch(error => console.error(error));
|
||||
}, [socket]);
|
||||
|
||||
return <div>My Component</div>;
|
||||
};
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
- Reduced initial page load time
|
||||
- Connections established only when needed
|
||||
- Automatic fallback to polling if WebSocket fails
|
||||
- Promise-based utilities for easier async/await usage
|
||||
|
||||
154
docs/nginx.conf
Normal file
@@ -0,0 +1,154 @@
|
||||
server {
|
||||
client_max_body_size 64M;
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name example.de;
|
||||
ssl_certificate /etc/letsencrypt/live/example.de/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/example.de/privkey.pem;
|
||||
|
||||
gzip on;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_types
|
||||
text/css
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/x-javascript
|
||||
application/json
|
||||
application/xml
|
||||
image/svg+xml;
|
||||
|
||||
index index.html;
|
||||
root /example/dist;
|
||||
|
||||
error_log logs/error.log info;
|
||||
access_log logs/access.log combined;
|
||||
|
||||
location /socket.io/ {
|
||||
proxy_pass http://localhost:9303/socket.io/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_connect_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_read_timeout 3600s;
|
||||
send_timeout 3600s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
keepalive_timeout 65;
|
||||
keepalive_requests 100;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:9303/api/;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_set_header User-Agent $http_user_agent;
|
||||
proxy_set_header Content-Type $content_type;
|
||||
proxy_set_header Content-Length $content_length;
|
||||
|
||||
proxy_set_header X-API-Key $http_x_api_key;
|
||||
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_buffering off;
|
||||
|
||||
client_max_body_size 10M;
|
||||
}
|
||||
|
||||
location ^~ /Kategorie/ {
|
||||
types {}
|
||||
default_type text/html;
|
||||
}
|
||||
|
||||
location ^~ /Artikel/ {
|
||||
types {}
|
||||
default_type text/html;
|
||||
}
|
||||
|
||||
location = /sitemap.xml {
|
||||
types {}
|
||||
default_type application/xml;
|
||||
}
|
||||
|
||||
location ~ ^/(datenschutz|impressum|batteriegesetzhinweise|widerrufsrecht|sitemap|agb|Kategorien|Konfigurator|404|profile|resetPassword|thc-test|filiale|aktionen|presseverleih|payment/success)(/|$) {
|
||||
types {}
|
||||
default_type text/html;
|
||||
}
|
||||
|
||||
location = /404 {
|
||||
error_page 404 =404 /404-big.html;
|
||||
return 404;
|
||||
}
|
||||
|
||||
location = /404-big.html {
|
||||
internal;
|
||||
alias /home/seb/src/growheads_de/dist/404;
|
||||
default_type text/html;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
|
||||
location = /404.html {
|
||||
internal;
|
||||
default_type text/html;
|
||||
return 404 '<!doctype html><html><body>
|
||||
<script>
|
||||
if (!navigator.userAgent.includes("bot")) { location.href="/404"; }
|
||||
</script>
|
||||
</body></html>';
|
||||
}
|
||||
|
||||
location ~* \.(js|css)\?.*$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Vary Accept-Encoding;
|
||||
}
|
||||
|
||||
location ~* \.(js|css)$ {
|
||||
if ($uri ~ "\.[a-f0-9]{7,}\.(js|css)$") {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
break;
|
||||
}
|
||||
expires 1d;
|
||||
add_header Cache-Control "public";
|
||||
add_header Vary Accept-Encoding;
|
||||
}
|
||||
|
||||
location ~* \.(ttf|otf|woff|woff2|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
}
|
||||
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
add_header Vary Accept-Encoding;
|
||||
}
|
||||
|
||||
location = /prerender.css {
|
||||
expires 1w;
|
||||
add_header Cache-Control "public";
|
||||
add_header Vary Accept-Encoding;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
add_header Vary Accept-Encoding;
|
||||
}
|
||||
}
|
||||
380
generate-category-descriptions.js
Normal file
@@ -0,0 +1,380 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
// Configuration
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
const DIST_DIR = './dist';
|
||||
const OUTPUT_CSV = './category-descriptions.csv';
|
||||
|
||||
// Model configuration
|
||||
const MODEL = 'gpt-5.1';
|
||||
|
||||
// Initialize OpenAI client
|
||||
const openai = new OpenAI({
|
||||
apiKey: OPENAI_API_KEY,
|
||||
});
|
||||
|
||||
// System prompt for generating SEO descriptions
|
||||
const SEO_DESCRIPTION_PROMPT = `You are given a list of products from a specific category. Create a SEO-friendly description for that category that would be suitable for a product catalog page.
|
||||
|
||||
Requirements:
|
||||
- Write in German
|
||||
- Make it SEO-optimized with relevant keywords
|
||||
|
||||
The product list format is:
|
||||
First line: categoryName,categoryId
|
||||
Subsequent lines: articleNumber,price,productName,shortDescription
|
||||
|
||||
Generate a compelling category description based on this product data.`;
|
||||
|
||||
// Function to find all *-list.txt files in dist directory
|
||||
function findListFiles() {
|
||||
try {
|
||||
const files = fs.readdirSync(DIST_DIR);
|
||||
return files.filter(file => file.endsWith('-list.txt'));
|
||||
} catch (error) {
|
||||
console.error('Error reading dist directory:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Function to read a list file and extract category info
|
||||
function readListFile(filePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
if (lines.length < 1) {
|
||||
throw new Error('File is empty');
|
||||
}
|
||||
|
||||
// Parse first line: categoryName,categoryId,[subcategoryIds]
|
||||
const firstLine = lines[0];
|
||||
const parts = firstLine.split(',');
|
||||
|
||||
if (parts.length < 2) {
|
||||
throw new Error('Invalid first line format');
|
||||
}
|
||||
|
||||
const categoryName = parts[0].replace(/^"|"$/g, '');
|
||||
const categoryId = parts[1].replace(/^"|"$/g, '');
|
||||
|
||||
// Parse subcategory IDs from array notation [id1,id2,...]
|
||||
let subcategoryIds = [];
|
||||
if (parts.length >= 3) {
|
||||
const subcatString = parts.slice(2).join(','); // Handle case where array spans multiple comma-separated values
|
||||
const match = subcatString.match(/\[(.*?)\]/);
|
||||
if (match && match[1]) {
|
||||
subcategoryIds = match[1].split(',').map(id => id.trim()).filter(id => id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!categoryName || !categoryId) {
|
||||
throw new Error('Invalid first line format');
|
||||
}
|
||||
|
||||
return {
|
||||
categoryName: categoryName,
|
||||
categoryId: categoryId,
|
||||
subcategoryIds: subcategoryIds,
|
||||
content: content
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error reading ${filePath}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to build processing order based on dependencies
|
||||
function buildProcessingOrder(categories) {
|
||||
const categoryMap = new Map();
|
||||
const processed = new Set();
|
||||
const processingOrder = [];
|
||||
|
||||
// Create a map of categoryId -> category data
|
||||
categories.forEach(cat => {
|
||||
categoryMap.set(cat.categoryId, cat);
|
||||
});
|
||||
|
||||
// Function to check if all subcategories are processed
|
||||
function canProcess(category) {
|
||||
return category.subcategoryIds.every(subId => processed.has(subId));
|
||||
}
|
||||
|
||||
// Keep processing until all categories are done
|
||||
while (processingOrder.length < categories.length) {
|
||||
const beforeLength = processingOrder.length;
|
||||
|
||||
// Find categories that can be processed now
|
||||
for (const category of categories) {
|
||||
if (!processed.has(category.categoryId) && canProcess(category)) {
|
||||
processingOrder.push(category);
|
||||
processed.add(category.categoryId);
|
||||
}
|
||||
}
|
||||
|
||||
// If no progress was made, there might be a circular dependency or missing category
|
||||
if (processingOrder.length === beforeLength) {
|
||||
console.error('⚠️ Unable to resolve all category dependencies');
|
||||
// Add remaining categories anyway
|
||||
for (const category of categories) {
|
||||
if (!processed.has(category.categoryId)) {
|
||||
console.warn(` Adding ${category.categoryName} (${category.categoryId}) despite unresolved dependencies`);
|
||||
processingOrder.push(category);
|
||||
processed.add(category.categoryId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return processingOrder;
|
||||
}
|
||||
|
||||
// Function to generate SEO description using OpenAI
|
||||
async function generateSEODescription(productListContent, categoryName, categoryId, subcategoryDescriptions = []) {
|
||||
try {
|
||||
console.log(`🔄 Generating SEO description for category: ${categoryName} (ID: ${categoryId})`);
|
||||
|
||||
// Prepend subcategory information if present
|
||||
let fullContent = productListContent;
|
||||
if (subcategoryDescriptions.length > 0) {
|
||||
const subcatInfo = 'This category has the following subcategories:\n' +
|
||||
subcategoryDescriptions.map(sub => `- "${sub.name}": ${sub.description}`).join('\n') +
|
||||
'\n\n';
|
||||
fullContent = subcatInfo + productListContent;
|
||||
}
|
||||
|
||||
const response = await openai.responses.create({
|
||||
model: "gpt-5.1",
|
||||
input: [
|
||||
{
|
||||
"role": "developer",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": SEO_DESCRIPTION_PROMPT
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": fullContent
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
text: {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "descriptions",
|
||||
"strict": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"seo_description": {
|
||||
"type": "string",
|
||||
"description": "A concise description intended for SEO purposes. 155 characters"
|
||||
},
|
||||
"long_description": {
|
||||
"type": "string",
|
||||
"description": "A comprehensive description, 2-5 Sentences"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"seo_description",
|
||||
"long_description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"verbosity": "medium"
|
||||
},
|
||||
reasoning: {
|
||||
"effort": "none"
|
||||
}
|
||||
});
|
||||
|
||||
const description = response.output_text;
|
||||
console.log(`✅ Generated description for ${categoryName}`);
|
||||
return description;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error generating description for ${categoryName}:`, error.message);
|
||||
return `Error generating description: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to write CSV file
|
||||
function writeCSV(results) {
|
||||
try {
|
||||
const csvHeader = 'categoryId,listFileName,seoDescription\n';
|
||||
const csvRows = results.map(result =>
|
||||
`"${result.categoryId}","${result.listFileName}","${result.description.replace(/"/g, '""')}"`
|
||||
).join('\n');
|
||||
|
||||
const csvContent = csvHeader + csvRows;
|
||||
fs.writeFileSync(OUTPUT_CSV, csvContent, 'utf8');
|
||||
console.log(`✅ CSV file written: ${OUTPUT_CSV}`);
|
||||
console.log(`📊 Processed ${results.length} categories`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error writing CSV file:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution function
|
||||
async function main() {
|
||||
console.log('🚀 Starting category description generation...');
|
||||
|
||||
// Check if OpenAI API key is set
|
||||
if (!OPENAI_API_KEY) {
|
||||
console.error('❌ OPENAI_API_KEY environment variable is not set');
|
||||
console.log('Please set your OpenAI API key: export OPENAI_API_KEY="your-api-key-here"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if dist directory exists
|
||||
if (!fs.existsSync(DIST_DIR)) {
|
||||
console.error(`❌ Dist directory not found: ${DIST_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find all list files
|
||||
const listFiles = findListFiles();
|
||||
if (listFiles.length === 0) {
|
||||
console.log('⚠️ No *-list.txt files found in dist directory');
|
||||
console.log('💡 Make sure to run the prerender script first to generate the list files');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`📂 Found ${listFiles.length} list files to process`);
|
||||
|
||||
// Step 1: Read all list files and extract category information
|
||||
console.log('📖 Reading all category files...');
|
||||
const categories = [];
|
||||
const fileDataMap = new Map(); // Map categoryId -> fileData
|
||||
|
||||
for (const listFile of listFiles) {
|
||||
const filePath = path.join(DIST_DIR, listFile);
|
||||
const fileData = readListFile(filePath);
|
||||
|
||||
if (!fileData) {
|
||||
console.log(`⚠️ Skipping ${listFile} due to read error`);
|
||||
continue;
|
||||
}
|
||||
|
||||
categories.push({
|
||||
categoryId: fileData.categoryId,
|
||||
categoryName: fileData.categoryName,
|
||||
subcategoryIds: fileData.subcategoryIds,
|
||||
listFileName: listFile
|
||||
});
|
||||
|
||||
fileDataMap.set(fileData.categoryId, {
|
||||
...fileData,
|
||||
listFileName: listFile
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✅ Read ${categories.length} categories`);
|
||||
|
||||
// Step 2: Build processing order based on dependencies
|
||||
console.log('🔨 Building processing order based on category hierarchy...');
|
||||
const processingOrder = buildProcessingOrder(categories);
|
||||
|
||||
const leafCategories = processingOrder.filter(cat => cat.subcategoryIds.length === 0);
|
||||
const parentCategories = processingOrder.filter(cat => cat.subcategoryIds.length > 0);
|
||||
|
||||
console.log(` 📄 ${leafCategories.length} leaf categories (no subcategories)`);
|
||||
console.log(` 📁 ${parentCategories.length} parent categories (with subcategories)`);
|
||||
|
||||
// Step 3: Process categories in order
|
||||
const results = [];
|
||||
const generatedDescriptions = new Map(); // Map categoryId -> {seo_description, long_description}
|
||||
|
||||
for (const category of processingOrder) {
|
||||
const fileData = fileDataMap.get(category.categoryId);
|
||||
|
||||
if (!fileData) {
|
||||
console.log(`⚠️ Skipping ${category.categoryName} - no file data found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gather subcategory descriptions
|
||||
const subcategoryDescriptions = [];
|
||||
for (const subId of category.subcategoryIds) {
|
||||
const subDesc = generatedDescriptions.get(subId);
|
||||
const subCategory = categories.find(cat => cat.categoryId === subId);
|
||||
|
||||
if (subDesc && subCategory) {
|
||||
subcategoryDescriptions.push({
|
||||
name: subCategory.categoryName,
|
||||
description: subDesc.long_description || subDesc.seo_description
|
||||
});
|
||||
} else if (subCategory) {
|
||||
console.warn(` ⚠️ Subcategory ${subCategory.categoryName} (${subId}) not yet processed`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate SEO description
|
||||
const descriptionJSON = await generateSEODescription(
|
||||
fileData.content,
|
||||
fileData.categoryName,
|
||||
fileData.categoryId,
|
||||
subcategoryDescriptions
|
||||
);
|
||||
|
||||
// Parse the JSON response
|
||||
let parsedDescription;
|
||||
try {
|
||||
parsedDescription = JSON.parse(descriptionJSON);
|
||||
generatedDescriptions.set(category.categoryId, parsedDescription);
|
||||
} catch (error) {
|
||||
console.error(` ❌ Failed to parse JSON for ${category.categoryName}:`, error.message);
|
||||
parsedDescription = { seo_description: descriptionJSON, long_description: descriptionJSON };
|
||||
generatedDescriptions.set(category.categoryId, parsedDescription);
|
||||
}
|
||||
|
||||
// Store result
|
||||
results.push({
|
||||
categoryId: category.categoryId,
|
||||
listFileName: fileData.listFileName,
|
||||
description: parsedDescription.seo_description || descriptionJSON
|
||||
});
|
||||
|
||||
// Add delay to avoid rate limiting
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// Write CSV output
|
||||
if (results.length > 0) {
|
||||
writeCSV(results);
|
||||
console.log('🎉 Category description generation completed successfully!');
|
||||
} else {
|
||||
console.error('❌ No results to write - all files failed processing');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch(error => {
|
||||
console.error('❌ Script failed:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
findListFiles,
|
||||
readListFile,
|
||||
buildProcessingOrder,
|
||||
generateSEODescription,
|
||||
writeCSV
|
||||
};
|
||||
1818
package-lock.json
generated
22
package.json
@@ -7,13 +7,20 @@
|
||||
"start": "cross-env NODE_OPTIONS=\"--no-deprecation\" webpack serve --progress --mode development --no-open",
|
||||
"start:seedheads": "cross-env PROXY_TARGET=https://seedheads.de NODE_OPTIONS=\"--no-deprecation\" webpack serve --progress --mode development --no-open",
|
||||
"prod": "webpack serve --progress --mode production --no-client-overlay --no-client --no-web-socket-server --no-open --no-live-reload --no-hot --compress --no-devtool",
|
||||
"build:client": "cross-env NODE_ENV=production webpack --progress --mode production && shx cp dist/index.html dist/index_template.html",
|
||||
"build:client": "node scripts/convert-images-to-avif.cjs && cross-env NODE_ENV=production webpack --progress --mode production && shx cp dist/index.html dist/index_template.html",
|
||||
"build": "npm run build:client",
|
||||
"analyze": "cross-env ANALYZE=true NODE_ENV=production webpack --progress --mode production",
|
||||
"lint": "eslint src/**/*.{js,jsx}",
|
||||
"prerender": "node prerender.cjs",
|
||||
"prerender:prod": "cross-env NODE_ENV=production node prerender.cjs",
|
||||
"build:prerender": "npm run build:client && npm run prerender:prod"
|
||||
"prerender:product": "node prerender-single-product.cjs",
|
||||
"prerender:product:prod": "cross-env NODE_ENV=production node prerender-single-product.cjs",
|
||||
"build:prerender": "npm run build:client && npm run prerender:prod",
|
||||
"translate": "node translate-i18n.js",
|
||||
"translate:english": "node translate-i18n.js --only-english",
|
||||
"translate:skip-english": "node translate-i18n.js --skip-english",
|
||||
"translate:others": "node translate-i18n.js --skip-english",
|
||||
"validate:products": "node scripts/validate-products-xml.cjs"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -26,12 +33,19 @@
|
||||
"@mui/material": "^7.1.1",
|
||||
"@stripe/react-stripe-js": "^3.7.0",
|
||||
"@stripe/stripe-js": "^7.3.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"country-flag-icons": "^1.5.19",
|
||||
"html-react-parser": "^5.2.5",
|
||||
"i18next": "^25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"openai": "^4.0.0",
|
||||
"react": "^19.1.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.6.0",
|
||||
"react-router-dom": "^7.6.2",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sharp": "^0.34.2",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
@@ -64,6 +78,8 @@
|
||||
"webpack-bundle-analyzer": "^4.10.2",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.2",
|
||||
"webpack-node-externals": "^3.0.0"
|
||||
"webpack-node-externals": "^3.0.0",
|
||||
"xmldom": "^0.6.0",
|
||||
"xpath": "^0.0.34"
|
||||
}
|
||||
}
|
||||
|
||||
208
prerender-single-product.cjs
Normal file
@@ -0,0 +1,208 @@
|
||||
require("@babel/register")({
|
||||
presets: [
|
||||
["@babel/preset-env", { targets: { node: "current" } }],
|
||||
"@babel/preset-react",
|
||||
],
|
||||
extensions: [".js", ".jsx"],
|
||||
ignore: [/node_modules/],
|
||||
});
|
||||
|
||||
// Minimal globals for socket.io-client only - no JSDOM to avoid interference
|
||||
global.window = {}; // Minimal window object for productCache
|
||||
global.navigator = { userAgent: "node.js" };
|
||||
global.URL = require("url").URL;
|
||||
global.Blob = class MockBlob {
|
||||
constructor(data, options) {
|
||||
this.data = data;
|
||||
this.type = options?.type || "";
|
||||
}
|
||||
};
|
||||
|
||||
// Import modules
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const React = require("react");
|
||||
const io = require("socket.io-client");
|
||||
|
||||
// Initialize i18n for prerendering with German as default
|
||||
const i18n = require("i18next");
|
||||
const { initReactI18next } = require("react-i18next");
|
||||
|
||||
// Import translation (just German for testing)
|
||||
const translationDE = require("./src/i18n/locales/de/translation.js").default;
|
||||
|
||||
// Initialize i18n
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
de: { translation: translationDE }
|
||||
},
|
||||
lng: 'de',
|
||||
fallbackLng: 'de',
|
||||
debug: false,
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
},
|
||||
react: {
|
||||
useSuspense: false
|
||||
}
|
||||
});
|
||||
|
||||
// Make i18n available globally
|
||||
global.i18n = i18n;
|
||||
|
||||
// Import prerender modules
|
||||
const config = require("./prerender/config.cjs");
|
||||
const shopConfig = require("./src/config.js").default;
|
||||
const { renderPage } = require("./prerender/renderer.cjs");
|
||||
const { generateProductMetaTags, generateProductJsonLd } = require("./prerender/seo.cjs");
|
||||
const { fetchProductDetails, saveProductImages } = require("./prerender/data-fetching.cjs");
|
||||
|
||||
// Import product component
|
||||
const PrerenderProduct = require("./src/PrerenderProduct.js").default;
|
||||
|
||||
const renderSingleProduct = async (productSeoName) => {
|
||||
const socketUrl = "http://127.0.0.1:9303";
|
||||
console.log(`🔌 Connecting to socket at ${socketUrl}...`);
|
||||
|
||||
const socket = io(socketUrl, {
|
||||
path: "/socket.io/",
|
||||
transports: ["websocket"],
|
||||
reconnection: false,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.error("❌ Timeout: Could not connect to backend after 15 seconds");
|
||||
socket.disconnect();
|
||||
reject(new Error("Connection timeout"));
|
||||
}, 15000);
|
||||
|
||||
socket.on("connect", async () => {
|
||||
console.log(`✅ Socket connected. Fetching product: ${productSeoName}`);
|
||||
|
||||
try {
|
||||
// Fetch product details
|
||||
const productDetails = await fetchProductDetails(socket, productSeoName);
|
||||
console.log(`📦 Product found: ${productDetails.product.name}`);
|
||||
|
||||
// Save product image to static files
|
||||
if (productDetails.product) {
|
||||
console.log(`📷 Saving product image...`);
|
||||
await saveProductImages(socket, [productDetails.product], "Single Product", config.outputDir);
|
||||
}
|
||||
|
||||
// Set up minimal global cache (empty for single product test)
|
||||
global.window.productCache = {};
|
||||
global.productCache = {};
|
||||
|
||||
// Create product component
|
||||
const productComponent = React.createElement(PrerenderProduct, {
|
||||
productData: productDetails,
|
||||
t: global.i18n.t.bind(global.i18n),
|
||||
});
|
||||
|
||||
// Generate metadata
|
||||
const actualSeoName = productDetails.product.seoName || productSeoName;
|
||||
const filename = `Artikel/${actualSeoName}`;
|
||||
const location = `/Artikel/${actualSeoName}`;
|
||||
const description = `Product "${productDetails.product.name}" (seoName: ${productSeoName})`;
|
||||
|
||||
const metaTags = generateProductMetaTags({
|
||||
...productDetails.product,
|
||||
seoName: actualSeoName,
|
||||
}, shopConfig.baseUrl, shopConfig);
|
||||
|
||||
const jsonLdScript = generateProductJsonLd({
|
||||
...productDetails.product,
|
||||
seoName: actualSeoName,
|
||||
}, shopConfig.baseUrl, shopConfig);
|
||||
|
||||
const combinedMetaTags = metaTags + "\n" + jsonLdScript;
|
||||
|
||||
// Render the page
|
||||
console.log(`🎨 Rendering product page...`);
|
||||
const success = renderPage(
|
||||
productComponent,
|
||||
location,
|
||||
filename,
|
||||
description,
|
||||
combinedMetaTags,
|
||||
true, // needsRouter
|
||||
config,
|
||||
false, // suppressLogs
|
||||
productDetails // productData for cache
|
||||
);
|
||||
|
||||
if (success) {
|
||||
const outputPath = path.resolve(__dirname, config.outputDir, `${filename}.html`);
|
||||
console.log(`✅ Product page rendered successfully!`);
|
||||
console.log(`📄 Output file: ${outputPath}`);
|
||||
console.log(`🌐 Test URL: http://localhost:3000/Artikel/${actualSeoName}`);
|
||||
|
||||
// Show file size
|
||||
if (fs.existsSync(outputPath)) {
|
||||
const stats = fs.statSync(outputPath);
|
||||
console.log(`📊 File size: ${Math.round(stats.size / 1024)}KB`);
|
||||
}
|
||||
} else {
|
||||
console.log(`❌ Failed to render product page`);
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
socket.disconnect();
|
||||
resolve(success);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error fetching/rendering product: ${error.message}`);
|
||||
clearTimeout(timeout);
|
||||
socket.disconnect();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("connect_error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error("❌ Socket connection error:", err);
|
||||
console.log("💡 Make sure the backend server is running on http://127.0.0.1:9303");
|
||||
reject(err);
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error("❌ Socket error:", err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Get product seoName from command line arguments
|
||||
const productSeoName = process.argv[2];
|
||||
|
||||
if (!productSeoName) {
|
||||
console.log("❌ Usage: node prerender-single-product.cjs <product-seo-name>");
|
||||
console.log("📝 Example: node prerender-single-product.cjs led-grow-light-600w");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`🚀 Starting single product prerender test...`);
|
||||
console.log(`🎯 Product SEO name: ${productSeoName}`);
|
||||
console.log(`🔧 Mode: ${config.isProduction ? 'PRODUCTION' : 'DEVELOPMENT'}`);
|
||||
console.log(`📁 Output directory: ${config.outputDir}`);
|
||||
|
||||
renderSingleProduct(productSeoName)
|
||||
.then((success) => {
|
||||
if (success) {
|
||||
console.log(`\n🎉 Single product prerender completed successfully!`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(`\n💥 Single product prerender failed!`);
|
||||
process.exit(1);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`\n💥 Single product prerender failed:`, error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
163
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
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
@@ -27,6 +45,74 @@ const io = require("socket.io-client");
|
||||
const os = require("os");
|
||||
const { Worker, isMainThread, parentPort, workerData } = require("worker_threads");
|
||||
|
||||
// Initialize i18n for prerendering with German as default
|
||||
const i18n = require("i18next");
|
||||
const { initReactI18next } = require("react-i18next");
|
||||
|
||||
// Import all translation files
|
||||
const translationDE = require("./src/i18n/locales/de/translation.js").default;
|
||||
const translationEN = require("./src/i18n/locales/en/translation.js").default;
|
||||
const translationAR = require("./src/i18n/locales/ar/translation.js").default;
|
||||
const translationBG = require("./src/i18n/locales/bg/translation.js").default;
|
||||
const translationCS = require("./src/i18n/locales/cs/translation.js").default;
|
||||
const translationEL = require("./src/i18n/locales/el/translation.js").default;
|
||||
const translationES = require("./src/i18n/locales/es/translation.js").default;
|
||||
const translationFR = require("./src/i18n/locales/fr/translation.js").default;
|
||||
const translationHR = require("./src/i18n/locales/hr/translation.js").default;
|
||||
const translationHU = require("./src/i18n/locales/hu/translation.js").default;
|
||||
const translationIT = require("./src/i18n/locales/it/translation.js").default;
|
||||
const translationPL = require("./src/i18n/locales/pl/translation.js").default;
|
||||
const translationRO = require("./src/i18n/locales/ro/translation.js").default;
|
||||
const translationRU = require("./src/i18n/locales/ru/translation.js").default;
|
||||
const translationSK = require("./src/i18n/locales/sk/translation.js").default;
|
||||
const translationSL = require("./src/i18n/locales/sl/translation.js").default;
|
||||
const translationSR = require("./src/i18n/locales/sr/translation.js").default;
|
||||
const translationSV = require("./src/i18n/locales/sv/translation.js").default;
|
||||
const translationTR = require("./src/i18n/locales/tr/translation.js").default;
|
||||
const translationUK = require("./src/i18n/locales/uk/translation.js").default;
|
||||
const translationZH = require("./src/i18n/locales/zh/translation.js").default;
|
||||
|
||||
// Initialize i18n for prerendering
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
de: { translation: translationDE },
|
||||
en: { translation: translationEN },
|
||||
ar: { translation: translationAR },
|
||||
bg: { translation: translationBG },
|
||||
cs: { translation: translationCS },
|
||||
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 }
|
||||
},
|
||||
lng: 'de', // Default to German for prerendering
|
||||
fallbackLng: 'de',
|
||||
debug: false,
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
},
|
||||
react: {
|
||||
useSuspense: false
|
||||
}
|
||||
});
|
||||
|
||||
// Make i18n available globally for components
|
||||
global.i18n = i18n;
|
||||
|
||||
// Import split modules
|
||||
const config = require("./prerender/config.cjs");
|
||||
|
||||
@@ -35,7 +121,6 @@ const shopConfig = require("./src/config.js").default;
|
||||
const { renderPage } = require("./prerender/renderer.cjs");
|
||||
const {
|
||||
collectAllCategories,
|
||||
writeCombinedCssFile,
|
||||
} = require("./prerender/utils.cjs");
|
||||
const {
|
||||
generateProductMetaTags,
|
||||
@@ -51,6 +136,7 @@ const {
|
||||
generateLlmsTxt,
|
||||
generateCategoryLlmsTxt,
|
||||
generateAllCategoryLlmsPages,
|
||||
generateCategoryProductList,
|
||||
} = require("./prerender/seo.cjs");
|
||||
const {
|
||||
fetchCategoryProducts,
|
||||
@@ -73,6 +159,7 @@ const Batteriegesetzhinweise =
|
||||
const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default;
|
||||
const Sitemap = require("./src/pages/Sitemap.js").default;
|
||||
const PrerenderSitemap = require("./src/PrerenderSitemap.js").default;
|
||||
const PrerenderCategoriesPage = require("./src/PrerenderCategoriesPage.js").default;
|
||||
const AGB = require("./src/pages/AGB.js").default;
|
||||
const NotFound404 = require("./src/pages/NotFound404.js").default;
|
||||
|
||||
@@ -81,7 +168,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
||||
const socketUrl = "http://127.0.0.1:9303";
|
||||
const workerSocket = io(socketUrl, {
|
||||
path: "/socket.io/",
|
||||
transports: ["polling", "websocket"],
|
||||
transports: ["websocket"],
|
||||
reconnection: false,
|
||||
timeout: 10000,
|
||||
});
|
||||
@@ -107,6 +194,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
||||
const actualSeoName = productDetails.product.seoName || productSeoName;
|
||||
const productComponent = React.createElement(PrerenderProduct, {
|
||||
productData: productDetails,
|
||||
t: global.i18n.t.bind(global.i18n),
|
||||
});
|
||||
|
||||
const filename = `Artikel/${actualSeoName}`;
|
||||
@@ -133,7 +221,8 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
||||
combinedMetaTags,
|
||||
true,
|
||||
config,
|
||||
true // Suppress logs during parallel rendering to avoid interfering with progress bar
|
||||
true, // Suppress logs during parallel rendering to avoid interfering with progress bar
|
||||
productDetails // Pass product data for cache population
|
||||
);
|
||||
|
||||
if (success) {
|
||||
@@ -281,14 +370,14 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
|
||||
|
||||
const renderApp = async (categoryData, socket) => {
|
||||
if (categoryData) {
|
||||
global.window.productCache = {
|
||||
categoryTree_209: { categoryTree: categoryData, timestamp: Date.now() },
|
||||
global.window.categoryCache = {
|
||||
"209_de": categoryData,
|
||||
};
|
||||
// @note Make cache available to components during rendering
|
||||
global.productCache = global.window.productCache;
|
||||
global.categoryCache = global.window.categoryCache;
|
||||
} else {
|
||||
global.window.productCache = {};
|
||||
global.productCache = {};
|
||||
global.window.categoryCache = {};
|
||||
global.categoryCache = {};
|
||||
}
|
||||
|
||||
// Helper to call renderPage with config
|
||||
@@ -334,6 +423,14 @@ const renderApp = async (categoryData, socket) => {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Copy index.html to resetPassword (no file extension) for SPA routing
|
||||
if (config.isProduction) {
|
||||
const indexPath = path.resolve(__dirname, config.outputDir, "index.html");
|
||||
const resetPasswordPath = path.resolve(__dirname, config.outputDir, "resetPassword");
|
||||
fs.copyFileSync(indexPath, resetPasswordPath);
|
||||
console.log(`✅ Copied index.html to ${resetPasswordPath}`);
|
||||
}
|
||||
|
||||
// Render static pages
|
||||
console.log("\n📄 Rendering static pages...");
|
||||
|
||||
@@ -369,6 +466,13 @@ const renderApp = async (categoryData, socket) => {
|
||||
description: "Sitemap page",
|
||||
needsCategoryData: true,
|
||||
},
|
||||
{
|
||||
component: PrerenderCategoriesPage,
|
||||
path: "/Kategorien",
|
||||
filename: "Kategorien",
|
||||
description: "Categories page",
|
||||
needsCategoryData: true,
|
||||
},
|
||||
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
|
||||
{ component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" },
|
||||
{
|
||||
@@ -438,7 +542,14 @@ const renderApp = async (categoryData, socket) => {
|
||||
let categoryPagesRendered = 0;
|
||||
let categoriesWithProducts = 0;
|
||||
|
||||
for (const category of allCategories) {
|
||||
const allCategoriesPlusNeu = [...allCategories, {
|
||||
id: "neu",
|
||||
name: "Neuheiten",
|
||||
seoName: "neu",
|
||||
parentId: 209
|
||||
}];
|
||||
|
||||
for (const category of allCategoriesPlusNeu) {
|
||||
// Skip categories without seoName
|
||||
if (!category.seoName) {
|
||||
console.log(
|
||||
@@ -456,8 +567,7 @@ const renderApp = async (categoryData, socket) => {
|
||||
try {
|
||||
productData = await fetchCategoryProducts(socket, category.id);
|
||||
console.log(
|
||||
` ✅ Found ${
|
||||
productData.products ? productData.products.length : 0
|
||||
` ✅ Found ${productData.products ? productData.products.length : 0
|
||||
} products`
|
||||
);
|
||||
|
||||
@@ -572,8 +682,7 @@ const renderApp = async (categoryData, socket) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Write the combined CSS file after all pages are rendered
|
||||
writeCombinedCssFile(config.globalCssCollection, config.outputDir);
|
||||
// No longer writing combined CSS file - each page has its own embedded CSS
|
||||
|
||||
// Generate XML sitemap with all rendered pages
|
||||
console.log("\n🗺️ Generating XML sitemap...");
|
||||
@@ -630,6 +739,26 @@ const renderApp = async (categoryData, socket) => {
|
||||
console.log(` - File verification: ⚠️ ${verifyError.message}`);
|
||||
}
|
||||
|
||||
// Validate XML against Google Shopping schema
|
||||
try {
|
||||
const ProductsXmlValidator = require('./scripts/validate-products-xml.cjs');
|
||||
const validator = new ProductsXmlValidator(productsXmlPath);
|
||||
const validationResults = await validator.validate();
|
||||
|
||||
if (validationResults.valid) {
|
||||
console.log(` - Schema validation: ✅ Valid Google Shopping RSS 2.0`);
|
||||
} else {
|
||||
console.log(` - Schema validation: ⚠️ ${validationResults.summary.errorCount} errors, ${validationResults.summary.warningCount} warnings`);
|
||||
|
||||
// Show first few errors for quick debugging
|
||||
if (validationResults.errors.length > 0) {
|
||||
console.log(` - First error: ${validationResults.errors[0].message}`);
|
||||
}
|
||||
}
|
||||
} catch (validationError) {
|
||||
console.log(` - Schema validation: ⚠️ Validation failed: ${validationError.message}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error generating products.xml: ${error.message}`);
|
||||
console.log("\n⚠️ Skipping products.xml generation due to errors");
|
||||
@@ -682,10 +811,16 @@ const renderApp = async (categoryData, socket) => {
|
||||
totalPaginatedFiles++;
|
||||
}
|
||||
|
||||
// Generate and write the product list file for this category
|
||||
const productList = generateCategoryProductList(category, categoryProducts);
|
||||
const listPath = path.resolve(__dirname, config.outputDir, productList.fileName);
|
||||
fs.writeFileSync(listPath, productList.content, { encoding: 'utf8' });
|
||||
|
||||
const pageCount = categoryPages.length;
|
||||
const totalSize = categoryPages.reduce((sum, page) => sum + page.content.length, 0);
|
||||
|
||||
console.log(` ✅ llms-${categorySlug}-page-*.txt - ${categoryProducts.length} products across ${pageCount} pages (${Math.round(totalSize / 1024)}KB total)`);
|
||||
console.log(` 📋 ${productList.fileName} - ${productList.productCount} products (${Math.round(productList.content.length / 1024)}KB)`);
|
||||
|
||||
categoryFilesGenerated++;
|
||||
totalCategoryProducts += categoryProducts.length;
|
||||
@@ -721,7 +856,7 @@ const fetchCategoryDataAndRender = () => {
|
||||
|
||||
const socket = io(socketUrl, {
|
||||
path: "/socket.io/",
|
||||
transports: ["polling", "websocket"], // Using polling first is more robust
|
||||
transports: ["websocket"],
|
||||
reconnection: false,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
@@ -50,10 +50,18 @@ const getWebpackEntrypoints = () => {
|
||||
return entrypoints;
|
||||
};
|
||||
|
||||
// Read global CSS styles and fix font paths for prerender
|
||||
let globalCss = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'index.css'), 'utf8');
|
||||
// Fix relative font paths for prerendered HTML (remove ../public to make them relative to public root)
|
||||
globalCss = globalCss.replace(/url\('\.\.\/public/g, "url('");
|
||||
// Read global CSS styles - use webpack processed CSS in production, raw CSS in development
|
||||
let globalCss = '';
|
||||
if (isProduction) {
|
||||
// In production, webpack has already processed fonts and inlined CSS
|
||||
// Don't read raw src/index.css as it has unprocessed font paths
|
||||
globalCss = ''; // CSS will be handled by webpack's inlined CSS
|
||||
} else {
|
||||
// In development, read raw CSS and fix font paths for prerender
|
||||
globalCss = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'index.css'), 'utf8');
|
||||
// Fix relative font paths for prerendered HTML (remove ../public to make them relative to public root)
|
||||
globalCss = globalCss.replace(/url\('\.\.\/public/g, "url('");
|
||||
}
|
||||
|
||||
// Global CSS collection
|
||||
const globalCssCollection = new Set();
|
||||
|
||||
@@ -37,9 +37,15 @@ const fetchCategoryProducts = (socket, categoryId) => {
|
||||
reject(new Error(`Timeout fetching products for category ${categoryId}`));
|
||||
}, 5000);
|
||||
|
||||
// Prerender system fetches German version by default
|
||||
socket.emit(
|
||||
"getCategoryProducts",
|
||||
{ full:true, categoryId: parseInt(categoryId) },
|
||||
{
|
||||
full: true,
|
||||
categoryId: categoryId === "neu" ? "neu" : parseInt(categoryId),
|
||||
language: 'de',
|
||||
requestTranslation: false
|
||||
},
|
||||
(response) => {
|
||||
clearTimeout(timeout);
|
||||
if (response && response.products !== undefined) {
|
||||
@@ -68,7 +74,13 @@ const fetchProductDetails = (socket, productSeoName) => {
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
socket.emit("getProductView", { seoName: productSeoName, nocount: true }, (response) => {
|
||||
// Prerender system fetches German version by default
|
||||
socket.emit("getProductView", {
|
||||
seoName: productSeoName,
|
||||
nocount: true,
|
||||
language: 'de',
|
||||
requestTranslation: false
|
||||
}, (response) => {
|
||||
clearTimeout(timeout);
|
||||
if (response && response.product) {
|
||||
response.product.seoName = productSeoName;
|
||||
@@ -140,7 +152,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
||||
"public",
|
||||
"assets",
|
||||
"images",
|
||||
"sh.png"
|
||||
"sh.avif"
|
||||
);
|
||||
|
||||
// Ensure assets/images directory exists
|
||||
@@ -173,7 +185,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
||||
if (imageIds.length > 0) {
|
||||
// Process first image for each product
|
||||
const bildId = parseInt(imageIds[0]);
|
||||
const estimatedFilename = `prod${bildId}.jpg`; // We'll generate a filename based on the ID
|
||||
const estimatedFilename = `prod${bildId}.avif`; // We'll generate a filename based on the ID
|
||||
|
||||
const imagePath = path.join(assetsPath, estimatedFilename);
|
||||
|
||||
@@ -187,7 +199,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
||||
const imageBuffer = await fetchProductImage(socket, bildId);
|
||||
|
||||
// If overlay exists, apply it to the image
|
||||
if (fs.existsSync(overlayPath)) {
|
||||
if (false && fs.existsSync(overlayPath)) {
|
||||
try {
|
||||
// Get image dimensions to center the overlay
|
||||
const baseImage = sharp(Buffer.from(imageBuffer));
|
||||
@@ -219,12 +231,12 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
||||
opacity: 0.3,
|
||||
},
|
||||
])
|
||||
.jpeg() // Ensure output is JPEG
|
||||
.avif() // Ensure output is AVIF
|
||||
.toBuffer();
|
||||
|
||||
fs.writeFileSync(imagePath, processedImageBuffer);
|
||||
console.log(
|
||||
` ✅ Applied centered inverted sh.png overlay to ${estimatedFilename}`
|
||||
` ✅ Applied centered inverted sh.avif overlay to ${estimatedFilename}`
|
||||
);
|
||||
} catch (overlayError) {
|
||||
console.log(
|
||||
@@ -269,7 +281,7 @@ const saveCategoryImages = async (socket, categories, outputDir) => {
|
||||
// Debug: Log categories that will be processed
|
||||
console.log(" 🔍 Categories to process:");
|
||||
categories.forEach((cat, index) => {
|
||||
console.log(` ${index + 1}. "${cat.name}" (ID: ${cat.id}) -> cat${cat.id}.jpg`);
|
||||
console.log(` ${index + 1}. "${cat.name}" (ID: ${cat.id}) -> cat${cat.id}.avif`);
|
||||
});
|
||||
|
||||
const assetsPath = path.resolve(
|
||||
@@ -296,7 +308,7 @@ const saveCategoryImages = async (socket, categories, outputDir) => {
|
||||
for (const category of categories) {
|
||||
categoriesProcessed++;
|
||||
|
||||
const estimatedFilename = `cat${category.id}.jpg`; // Use 'cat' prefix with category ID
|
||||
const estimatedFilename = `cat${category.id}.avif`; // Use 'cat' prefix with category ID
|
||||
const imagePath = path.join(assetsPath, estimatedFilename);
|
||||
|
||||
// Skip if image already exists
|
||||
|
||||
@@ -17,7 +17,8 @@ const renderPage = (
|
||||
metaTags = "",
|
||||
needsRouter = false,
|
||||
config,
|
||||
suppressLogs = false
|
||||
suppressLogs = false,
|
||||
productData = null
|
||||
) => {
|
||||
const {
|
||||
isProduction,
|
||||
@@ -26,7 +27,7 @@ const renderPage = (
|
||||
globalCssCollection,
|
||||
webpackEntrypoints,
|
||||
} = config;
|
||||
const { writeCombinedCssFile, optimizeCss } = require("./utils.cjs");
|
||||
const { optimizeCss } = require("./utils.cjs");
|
||||
|
||||
// @note Set prerender fallback flag in global environment for CategoryBox during SSR
|
||||
if (typeof global !== "undefined" && global.window) {
|
||||
@@ -51,26 +52,20 @@ const renderPage = (
|
||||
);
|
||||
|
||||
let renderedMarkup;
|
||||
let pageSpecificCss = ""; // Declare outside try block for broader scope
|
||||
|
||||
try {
|
||||
renderedMarkup = ReactDOMServer.renderToString(pageElement);
|
||||
const emotionChunks = extractCriticalToChunks(renderedMarkup);
|
||||
|
||||
// Collect CSS from this page
|
||||
// Collect CSS from this page for direct inlining (no global accumulation)
|
||||
if (emotionChunks.styles.length > 0) {
|
||||
const oldSize = globalCssCollection.size;
|
||||
|
||||
emotionChunks.styles.forEach((style) => {
|
||||
if (style.css) {
|
||||
globalCssCollection.add(style.css);
|
||||
pageSpecificCss += style.css + "\n";
|
||||
}
|
||||
});
|
||||
|
||||
// Check if new styles were added
|
||||
if (globalCssCollection.size > oldSize) {
|
||||
// Write CSS file immediately when new styles are added
|
||||
writeCombinedCssFile(globalCssCollection, outputDir);
|
||||
}
|
||||
if (!suppressLogs) console.log(` - CSS rules: ${emotionChunks.styles.length}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Rendering failed for ${filename}:`, error);
|
||||
@@ -126,26 +121,12 @@ const renderPage = (
|
||||
}
|
||||
});
|
||||
|
||||
// Read and inline prerender CSS to eliminate render-blocking request
|
||||
try {
|
||||
const prerenderCssPath = path.resolve(__dirname, "..", outputDir, "prerender.css");
|
||||
if (fs.existsSync(prerenderCssPath)) {
|
||||
const prerenderCssContent = fs.readFileSync(prerenderCssPath, "utf8");
|
||||
// Use advanced CSS optimization
|
||||
const optimizedPrerenderCss = optimizeCss(prerenderCssContent);
|
||||
inlinedCss += optimizedPrerenderCss;
|
||||
if (!suppressLogs) console.log(` ✅ Inlined prerender CSS (${Math.round(optimizedPrerenderCss.length / 1024)}KB)`);
|
||||
} else {
|
||||
// Fallback to external loading if prerender.css doesn't exist yet
|
||||
additionalTags += `<link rel="preload" href="/prerender.css" as="style" onload="this.onload=null;this.rel='stylesheet'">`;
|
||||
additionalTags += `<noscript><link rel="stylesheet" href="/prerender.css"></noscript>`;
|
||||
if (!suppressLogs) console.log(` ⚠️ prerender.css not found for inlining, using async loading`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback to external loading
|
||||
additionalTags += `<link rel="preload" href="/prerender.css" as="style" onload="this.onload=null;this.rel='stylesheet'">`;
|
||||
additionalTags += `<noscript><link rel="stylesheet" href="/prerender.css"></noscript>`;
|
||||
if (!suppressLogs) console.log(` ⚠️ Error reading prerender.css: ${error.message}, using async loading`);
|
||||
// Inline page-specific CSS directly (no shared prerender.css file)
|
||||
if (pageSpecificCss.trim()) {
|
||||
// Use advanced CSS optimization on page-specific CSS
|
||||
const optimizedPageCss = optimizeCss(pageSpecificCss);
|
||||
inlinedCss += optimizedPageCss;
|
||||
if (!suppressLogs) console.log(` ✅ Inlined page-specific CSS (${Math.round(optimizedPageCss.length / 1024)}KB)`);
|
||||
}
|
||||
|
||||
// Add JavaScript files
|
||||
@@ -182,23 +163,47 @@ const renderPage = (
|
||||
content: ${JSON.stringify(renderedMarkup)},
|
||||
timestamp: ${Date.now()}
|
||||
};
|
||||
|
||||
// DEBUG: Multiple alerts throughout the loading process
|
||||
// Debug alerts removed
|
||||
|
||||
|
||||
</script>
|
||||
`;
|
||||
|
||||
// @note Create script to populate window.productCache with ONLY the static category tree
|
||||
let productCacheScript = '';
|
||||
if (typeof global !== "undefined" && global.window && global.window.productCache) {
|
||||
if (typeof global !== "undefined" && global.window && global.window.categoryCache) {
|
||||
// Only include the static categoryTree_209, not any dynamic data that gets added during rendering
|
||||
const staticCache = {};
|
||||
if (global.window.productCache.categoryTree_209) {
|
||||
staticCache.categoryTree_209 = global.window.productCache.categoryTree_209;
|
||||
if (global.window.categoryCache["209_de"]) {
|
||||
staticCache["209_de"] = global.window.categoryCache["209_de"];
|
||||
}
|
||||
|
||||
const staticCacheData = JSON.stringify(staticCache);
|
||||
productCacheScript = `
|
||||
<script>
|
||||
// Populate window.productCache with static category tree only
|
||||
window.productCache = ${staticCacheData};
|
||||
// Populate window.categoryCache with static category tree only
|
||||
window.categoryCache = ${staticCacheData};
|
||||
</script>
|
||||
`;
|
||||
}
|
||||
|
||||
// Create script to populate window.productDetailCache for individual product pages
|
||||
let productDetailCacheScript = '';
|
||||
if (productData && productData.product) {
|
||||
// Cache the entire response object (includes product, attributes, etc.)
|
||||
// Use language-aware cache key (prerender defaults to German)
|
||||
const productDetailCacheData = JSON.stringify(productData);
|
||||
const language = 'de'; // Prerender system caches German version
|
||||
const cacheKey = `product_${productData.product.seoName}_${language}`;
|
||||
productDetailCacheScript = `
|
||||
<script>
|
||||
// Populate window.productDetailCache with complete product data for SPA hydration
|
||||
if (!window.productDetailCache) {
|
||||
window.productDetailCache = {};
|
||||
}
|
||||
window.productDetailCache['${cacheKey}'] = ${productDetailCacheData};
|
||||
</script>
|
||||
`;
|
||||
}
|
||||
@@ -214,7 +219,7 @@ const renderPage = (
|
||||
|
||||
template = template.replace(
|
||||
"</head>",
|
||||
`${resourceHints}${combinedCssTag}${additionalTags}${metaTags}${prerenderFallbackScript}${productCacheScript}</head>`
|
||||
`${resourceHints}${combinedCssTag}${additionalTags}${metaTags}${prerenderFallbackScript}${productCacheScript}${productDetailCacheScript}</head>`
|
||||
);
|
||||
|
||||
const rootDivRegex = /<div id="root"[\s\S]*?>[\s\S]*?<\/div>/;
|
||||
@@ -222,8 +227,10 @@ const renderPage = (
|
||||
|
||||
let newHtml;
|
||||
if (rootDivRegex.test(template)) {
|
||||
if (!suppressLogs) console.log(` 📝 Root div found, replacing with ${renderedMarkup.length} chars of markup`);
|
||||
newHtml = template.replace(rootDivRegex, replacementHtml);
|
||||
} else {
|
||||
if (!suppressLogs) console.log(` ⚠️ No root div found, appending to body`);
|
||||
newHtml = template.replace("<body>", `<body>${replacementHtml}`);
|
||||
}
|
||||
|
||||
@@ -240,10 +247,9 @@ const renderPage = (
|
||||
if (!suppressLogs) {
|
||||
console.log(`✅ ${description} prerendered to ${outputPath}`);
|
||||
console.log(` - Markup length: ${renderedMarkup.length} characters`);
|
||||
console.log(` - CSS rules: ${Object.keys(cache.inserted).length}`);
|
||||
console.log(` - Total inlined CSS: ${Math.round(combinedCss.length / 1024)}KB`);
|
||||
console.log(` - Render-blocking CSS eliminated: ${inlinedCss ? 'YES' : 'NO'}`);
|
||||
console.log(` - Fallback content saved to window.__PRERENDER_FALLBACK__`);
|
||||
if (productDetailCacheScript) {
|
||||
console.log(` - Product detail cache populated for SPA hydration`);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
// Category IDs to skip (seeds, plants, headshop items)
|
||||
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258];
|
||||
|
||||
// Check if category ID is in skip list
|
||||
if (category.id && skipCategoryIds.includes(parseInt(category.id))) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const categoryUrl = `${baseUrl}/Kategorie/${category.seoName}`;
|
||||
|
||||
// Calculate price valid date (current date + 3 months)
|
||||
const priceValidDate = new Date();
|
||||
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
|
||||
const priceValidUntil = priceValidDate.toISOString().split("T")[0];
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org/",
|
||||
"@type": "CollectionPage",
|
||||
@@ -42,7 +55,7 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
product.pictureList && product.pictureList.trim()
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||
.split(",")[0]
|
||||
.trim()}.jpg`
|
||||
.trim()}.avif`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`,
|
||||
description: product.description
|
||||
? product.description.replace(/<[^>]*>/g, "").substring(0, 200)
|
||||
@@ -57,6 +70,7 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
url: `${baseUrl}/Artikel/${product.seoName}`,
|
||||
price: product.price && !isNaN(product.price) ? product.price.toString() : "0.00",
|
||||
priceCurrency: config.currency,
|
||||
priceValidUntil: priceValidUntil,
|
||||
availability: product.available
|
||||
? "https://schema.org/InStock"
|
||||
: "https://schema.org/OutOfStock",
|
||||
@@ -65,6 +79,41 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
name: config.brandName,
|
||||
},
|
||||
itemCondition: "https://schema.org/NewCondition",
|
||||
hasMerchantReturnPolicy: {
|
||||
"@type": "MerchantReturnPolicy",
|
||||
applicableCountry: "DE",
|
||||
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
|
||||
merchantReturnDays: 14,
|
||||
returnMethod: "https://schema.org/ReturnByMail",
|
||||
returnFees: "https://schema.org/FreeReturn",
|
||||
},
|
||||
shippingDetails: {
|
||||
"@type": "OfferShippingDetails",
|
||||
shippingRate: {
|
||||
"@type": "MonetaryAmount",
|
||||
value: 5.90,
|
||||
currency: "EUR",
|
||||
},
|
||||
shippingDestination: {
|
||||
"@type": "DefinedRegion",
|
||||
addressCountry: "DE",
|
||||
},
|
||||
deliveryTime: {
|
||||
"@type": "ShippingDeliveryTime",
|
||||
handlingTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
transitTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 2,
|
||||
maxValue: 3,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -11,6 +11,102 @@ Crawl-delay: 0
|
||||
return robotsTxt;
|
||||
};
|
||||
|
||||
// Helper function to determine unit pricing data based on product data
|
||||
const determineUnitPricingData = (product) => {
|
||||
const result = {
|
||||
unit_pricing_measure: null,
|
||||
unit_pricing_base_measure: null
|
||||
};
|
||||
|
||||
// Unit mapping from German to Google Shopping accepted units
|
||||
const unitMapping = {
|
||||
// Volume (German -> Google)
|
||||
'Milliliter': 'ml',
|
||||
'milliliter': 'ml',
|
||||
'ml': 'ml',
|
||||
'Liter': 'l',
|
||||
'liter': 'l',
|
||||
'l': 'l',
|
||||
'Zentiliter': 'cl',
|
||||
'zentiliter': 'cl',
|
||||
'cl': 'cl',
|
||||
|
||||
// Weight (German -> Google)
|
||||
'Gramm': 'g',
|
||||
'gramm': 'g',
|
||||
'g': 'g',
|
||||
'Kilogramm': 'kg',
|
||||
'kilogramm': 'kg',
|
||||
'kg': 'kg',
|
||||
'Milligramm': 'mg',
|
||||
'milligramm': 'mg',
|
||||
'mg': 'mg',
|
||||
|
||||
// Length (German -> Google)
|
||||
'Meter': 'm',
|
||||
'meter': 'm',
|
||||
'm': 'm',
|
||||
'Zentimeter': 'cm',
|
||||
'zentimeter': 'cm',
|
||||
'cm': 'cm',
|
||||
|
||||
// Count (German -> Google)
|
||||
'Stück': 'ct',
|
||||
'stück': 'ct',
|
||||
'Stk': 'ct',
|
||||
'stk': 'ct',
|
||||
'ct': 'ct',
|
||||
'Blatt': 'sheet',
|
||||
'blatt': 'sheet',
|
||||
'sheet': 'sheet'
|
||||
};
|
||||
|
||||
// Helper function to convert German unit to Google Shopping unit
|
||||
const convertUnit = (unit) => {
|
||||
if (!unit) return null;
|
||||
const trimmedUnit = unit.trim();
|
||||
return unitMapping[trimmedUnit] || trimmedUnit.toLowerCase();
|
||||
};
|
||||
|
||||
// unit_pricing_measure: The quantity unit of the product as it's sold
|
||||
if (product.fEinheitMenge && product.cEinheit) {
|
||||
const amount = parseFloat(product.fEinheitMenge);
|
||||
const unit = convertUnit(product.cEinheit);
|
||||
|
||||
if (amount > 0 && unit) {
|
||||
result.unit_pricing_measure = `${amount} ${unit}`;
|
||||
}
|
||||
}
|
||||
|
||||
// unit_pricing_base_measure: The base quantity unit for unit pricing
|
||||
if (product.cGrundEinheit && product.cGrundEinheit.trim()) {
|
||||
const baseUnit = convertUnit(product.cGrundEinheit);
|
||||
if (baseUnit) {
|
||||
// Base measure usually needs a quantity (like 100g, 1l, etc.)
|
||||
// If it's just a unit, we'll add a default quantity
|
||||
if (baseUnit.match(/^[a-z]+$/)) {
|
||||
// For weight/volume units, use standard base quantities
|
||||
if (['g', 'kg', 'mg'].includes(baseUnit)) {
|
||||
result.unit_pricing_base_measure = baseUnit === 'kg' ? '1 kg' : '100 g';
|
||||
} else if (['ml', 'l', 'cl'].includes(baseUnit)) {
|
||||
result.unit_pricing_base_measure = baseUnit === 'l' ? '1 l' : '100 ml';
|
||||
} else if (['m', 'cm'].includes(baseUnit)) {
|
||||
result.unit_pricing_base_measure = baseUnit === 'm' ? '1 m' : '100 cm';
|
||||
} else {
|
||||
result.unit_pricing_base_measure = `1 ${baseUnit}`;
|
||||
}
|
||||
} else {
|
||||
result.unit_pricing_base_measure = baseUnit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
const currentDate = new Date().toISOString();
|
||||
|
||||
@@ -23,134 +119,147 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
const getGoogleProductCategory = (categoryId) => {
|
||||
const categoryMappings = {
|
||||
// Seeds & Plants
|
||||
689: "Home & Garden > Plants > Seeds",
|
||||
706: "Home & Garden > Plants", // Stecklinge (cuttings)
|
||||
376: "Home & Garden > Plants > Plant & Herb Growing Kits", // Grow-Sets
|
||||
689: "543561", // Seeds (Saatgut)
|
||||
706: "543561", // Stecklinge (cuttings) – ebenfalls Pflanzen/Saatgut
|
||||
376: "2802", // Grow-Sets – Pflanzen- & Kräuteranbausets
|
||||
915: "2802", // Grow-Sets > Set-Zubehör – Pflanzen- & Kräuteranbausets
|
||||
|
||||
// Headshop & Accessories
|
||||
709: "Arts & Entertainment > Hobbies & Creative Arts", // Headshop
|
||||
711: "Arts & Entertainment > Hobbies & Creative Arts", // Bongs
|
||||
714: "Arts & Entertainment > Hobbies & Creative Arts", // Zubehör
|
||||
748: "Arts & Entertainment > Hobbies & Creative Arts", // Köpfe
|
||||
749: "Arts & Entertainment > Hobbies & Creative Arts", // Chillums / Diffusoren / Kupplungen
|
||||
896: "Electronics > Electronics Accessories", // Vaporizer
|
||||
710: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Grinder
|
||||
709: "4082", // Headshop – Rauchzubehör
|
||||
711: "4082", // Headshop > Bongs – Rauchzubehör
|
||||
714: "4082", // Headshop > Bongs > Zubehör – Rauchzubehör
|
||||
748: "4082", // Headshop > Bongs > Köpfe – Rauchzubehör
|
||||
749: "4082", // Headshop > Bongs > Chillums/Diffusoren/Kupplungen – Rauchzubehör
|
||||
921: "4082", // Headshop > Pfeifen – Rauchzubehör
|
||||
924: "4082", // Headshop > Dabbing – Rauchzubehör
|
||||
896: "3151", // Headshop > Vaporizer – Vaporizer
|
||||
923: "4082", // Headshop > Papes & Blunts – Rauchzubehör
|
||||
710: "5109", // Headshop > Grinder – Gewürzmühlen (Küchenhelfer)
|
||||
922: "4082", // Headshop > Aktivkohlefilter & Tips – Rauchzubehör
|
||||
916: "4082", // Headshop > Rollen & Bauen – Rauchzubehör
|
||||
|
||||
// Measuring & Packaging
|
||||
186: "Business & Industrial", // Wiegen & Verpacken
|
||||
187: "Business & Industrial > Science & Laboratory > Lab Equipment", // Waagen
|
||||
346: "Home & Garden > Kitchen & Dining > Food Storage", // Vakuumbeutel
|
||||
355: "Home & Garden > Kitchen & Dining > Food Storage", // Boveda & Integra Boost
|
||||
407: "Home & Garden > Kitchen & Dining > Food Storage", // Grove Bags
|
||||
449: "Home & Garden > Kitchen & Dining > Food Storage", // Cliptütchen
|
||||
539: "Home & Garden > Kitchen & Dining > Food Storage", // Gläser & Dosen
|
||||
186: "5631", // Headshop > Wiegen & Verpacken – Aufbewahrung/Zubehör
|
||||
187: "4767", // Headshop > Waagen – Personenwaagen (Medizinisch)
|
||||
346: "7118", // Headshop > Vakuumbeutel – Vakuumierer-Beutel
|
||||
355: "606", // Headshop > Boveda & Integra Boost – Luftentfeuchter (nächstmögliche)
|
||||
407: "3561", // Headshop > Grove Bags – Aufbewahrungsbehälter
|
||||
449: "1496", // Headshop > Cliptütchen – Lebensmittelverpackungsmaterial
|
||||
539: "3110", // Headshop > Gläser & Dosen – Lebensmittelbehälter
|
||||
920: "581", // Headshop > Räucherstäbchen – Raumdüfte (Home Fragrances)
|
||||
|
||||
// Lighting & Equipment
|
||||
694: "Home & Garden > Lighting", // Lampen
|
||||
261: "Home & Garden > Lighting", // Lampenzubehör
|
||||
694: "3006", // Lampen – Lampen (Beleuchtung)
|
||||
261: "3006", // Zubehör > Lampenzubehör – Lampen
|
||||
|
||||
// Plants & Growing
|
||||
691: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger
|
||||
692: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger - Zubehör
|
||||
693: "Sporting Goods > Outdoor Recreation > Camping & Hiking > Tents", // Zelte
|
||||
691: "500033", // Dünger – Dünger
|
||||
692: "5633", // Zubehör > Dünger-Zubehör – Zubehör für Gartenarbeit
|
||||
693: "5655", // Zelte – Zelte
|
||||
|
||||
// Pots & Containers
|
||||
219: "Home & Garden > Decor > Planters & Pots", // Töpfe
|
||||
220: "Home & Garden > Decor > Planters & Pots", // Untersetzer
|
||||
301: "Home & Garden > Decor > Planters & Pots", // Stofftöpfe
|
||||
317: "Home & Garden > Decor > Planters & Pots", // Air-Pot
|
||||
364: "Home & Garden > Decor > Planters & Pots", // Kunststofftöpfe
|
||||
292: "Home & Garden > Decor > Planters & Pots", // Trays & Fluttische
|
||||
219: "113", // Töpfe – Blumentöpfe & Pflanzgefäße
|
||||
220: "3173", // Töpfe > Untersetzer – Gartentopfuntersetzer und Trays
|
||||
301: "113", // Töpfe > Stofftöpfe – (Blumentöpfe/Pflanzgefäße)
|
||||
317: "113", // Töpfe > Air-Pot – (Blumentöpfe/Pflanzgefäße)
|
||||
364: "113", // Töpfe > Kunststofftöpfe – (Blumentöpfe/Pflanzgefäße)
|
||||
292: "3568", // Bewässerung > Trays & Fluttische – Bewässerungssysteme
|
||||
|
||||
// Ventilation & Climate
|
||||
703: "Home & Garden > Outdoor Power Tools", // Abluft-Sets
|
||||
247: "Home & Garden > Outdoor Power Tools", // Belüftung
|
||||
214: "Home & Garden > Outdoor Power Tools", // Umluft-Ventilatoren
|
||||
308: "Home & Garden > Outdoor Power Tools", // Ab- und Zuluft
|
||||
609: "Home & Garden > Outdoor Power Tools", // Schalldämpfer
|
||||
248: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Aktivkohlefilter
|
||||
392: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Zuluftfilter
|
||||
658: "Home & Garden > Climate Control > Dehumidifiers", // Luftbe- und entfeuchter
|
||||
310: "Home & Garden > Climate Control > Heating", // Heizmatten
|
||||
379: "Home & Garden > Household Supplies > Air Fresheners", // Geruchsneutralisation
|
||||
703: "2802", // Grow-Sets > Abluft-Sets – (verwendet Pflanzen-Kräuter-Anbausets)
|
||||
247: "1700", // Belüftung – Ventilatoren (Klimatisierung)
|
||||
214: "1700", // Belüftung > Umluft-Ventilatoren – Ventilatoren
|
||||
308: "1700", // Belüftung > Ab- und Zuluft – Ventilatoren
|
||||
609: "1700", // Belüftung > Ab- und Zuluft > Schalldämpfer – Ventilatoren
|
||||
248: "1700", // Belüftung > Aktivkohlefilter – Ventilatoren (nächstmögliche)
|
||||
392: "1700", // Belüftung > Ab- und Zuluft > Zuluftfilter – Ventilatoren
|
||||
658: "606", // Belüftung > Luftbe- und -entfeuchter – Luftentfeuchter
|
||||
310: "2802", // Anzucht > Heizmatten – Pflanzen- & Kräuteranbausets
|
||||
379: "5631", // Belüftung > Geruchsneutralisation – Haushaltsbedarf: Aufbewahrung
|
||||
|
||||
// Irrigation & Watering
|
||||
221: "Home & Garden > Lawn & Garden > Watering Equipment", // Bewässerung
|
||||
250: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
|
||||
297: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpen
|
||||
354: "Home & Garden > Lawn & Garden > Watering Equipment", // Sprüher
|
||||
372: "Home & Garden > Lawn & Garden > Watering Equipment", // AutoPot
|
||||
389: "Home & Garden > Lawn & Garden > Watering Equipment", // Blumat
|
||||
405: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
|
||||
425: "Home & Garden > Lawn & Garden > Watering Equipment", // Wassertanks
|
||||
480: "Home & Garden > Lawn & Garden > Watering Equipment", // Tropfer
|
||||
519: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpsprüher
|
||||
221: "3568", // Bewässerung – Bewässerungssysteme (Gesamt)
|
||||
250: "6318", // Bewässerung > Schläuche – Gartenschläuche
|
||||
297: "500100", // Bewässerung > Pumpen – Bewässerung-/Sprinklerpumpen
|
||||
354: "3780", // Bewässerung > Sprüher – Sprinkler & Sprühköpfe
|
||||
372: "3568", // Bewässerung > AutoPot – Bewässerungssysteme
|
||||
389: "3568", // Bewässerung > Blumat – Bewässerungssysteme
|
||||
405: "6318", // Bewässerung > Schläuche – Gartenschläuche
|
||||
425: "3568", // Bewässerung > Wassertanks – Bewässerungssysteme
|
||||
480: "3568", // Bewässerung > Tropfer – Bewässerungssysteme
|
||||
519: "3568", // Bewässerung > Pumpsprüher – Bewässerungssysteme
|
||||
|
||||
// Growing Media & Soils
|
||||
242: "Home & Garden > Lawn & Garden > Fertilizers", // Böden
|
||||
243: "Home & Garden > Lawn & Garden > Fertilizers", // Erde
|
||||
269: "Home & Garden > Lawn & Garden > Fertilizers", // Kokos
|
||||
580: "Home & Garden > Lawn & Garden > Fertilizers", // Perlite & Blähton
|
||||
242: "543677", // Böden – Gartenerde
|
||||
243: "543677", // Böden > Erde – Gartenerde
|
||||
269: "543677", // Böden > Kokos – Gartenerde
|
||||
580: "543677", // Böden > Perlite & Blähton – Gartenerde
|
||||
|
||||
// Propagation & Starting
|
||||
286: "Home & Garden > Plants", // Anzucht
|
||||
298: "Home & Garden > Plants", // Steinwolltrays
|
||||
421: "Home & Garden > Plants", // Vermehrungszubehör
|
||||
489: "Home & Garden > Plants", // EazyPlug & Jiffy
|
||||
359: "Home & Garden > Outdoor Structures > Greenhouses", // Gewächshäuser
|
||||
286: "2802", // Anzucht – Pflanzen- & Kräuteranbausets
|
||||
298: "2802", // Anzucht > Steinwolltrays – Pflanzen- & Kräuteranbausets
|
||||
421: "2802", // Anzucht > Vermehrungszubehör – Pflanzen- & Kräuteranbausets
|
||||
489: "2802", // Anzucht > EazyPlug & Jiffy – Pflanzen- & Kräuteranbausets
|
||||
359: "3103", // Anzucht > Gewächshäuser – Gewächshäuser
|
||||
|
||||
// Tools & Equipment
|
||||
373: "Home & Garden > Tools > Hand Tools", // GrowTool
|
||||
403: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Messbecher & mehr
|
||||
259: "Home & Garden > Tools > Hand Tools", // Pressen
|
||||
280: "Home & Garden > Tools > Hand Tools", // Erntescheeren
|
||||
258: "Home & Garden > Tools", // Ernte & Verarbeitung
|
||||
278: "Home & Garden > Tools", // Extraktion
|
||||
302: "Home & Garden > Tools", // Erntemaschinen
|
||||
373: "3568", // Bewässerung > GrowTool – Bewässerungssysteme
|
||||
403: "3999", // Bewässerung > Messbecher & mehr – Messbecher & Dosierlöffel
|
||||
259: "756", // Zubehör > Ernte & Verarbeitung > Pressen – Nudelmaschinen
|
||||
280: "2948", // Zubehör > Ernte & Verarbeitung > Erntescheeren – Küchenmesser
|
||||
258: "684", // Zubehör > Ernte & Verarbeitung – Abfallzerkleinerer
|
||||
278: "5057", // Zubehör > Ernte & Verarbeitung > Extraktion – Slush-Eis-Maschinen
|
||||
302: "7332", // Zubehör > Ernte & Verarbeitung > Erntemaschinen – Gartenmaschinen
|
||||
|
||||
// Hardware & Plumbing
|
||||
222: "Hardware > Plumbing", // PE-Teile
|
||||
374: "Hardware > Plumbing > Plumbing Fittings", // Verbindungsteile
|
||||
222: "3568", // Bewässerung > PE-Teile – Bewässerungssysteme
|
||||
374: "1700", // Belüftung > Ab- und Zuluft > Verbindungsteile – Ventilatoren
|
||||
|
||||
// Electronics & Control
|
||||
314: "Electronics > Electronics Accessories", // Steuergeräte
|
||||
408: "Electronics > Electronics Accessories", // GrowControl
|
||||
344: "Business & Industrial > Science & Laboratory > Lab Equipment", // Messgeräte
|
||||
555: "Business & Industrial > Science & Laboratory > Lab Equipment > Microscopes", // Mikroskope
|
||||
314: "1700", // Belüftung > Steuergeräte – Ventilatoren
|
||||
408: "1700", // Belüftung > Steuergeräte > GrowControl – Ventilatoren
|
||||
344: "1207", // Zubehör > Messgeräte – Messwerkzeuge & Messwertgeber
|
||||
555: "4555", // Zubehör > Anbauzubehör > Mikroskope – Mikroskope
|
||||
|
||||
// Camping & Outdoor
|
||||
226: "Sporting Goods > Outdoor Recreation > Camping & Hiking", // Zeltzubehör
|
||||
226: "5655", // Zubehör > Zeltzubehör – Zelte
|
||||
|
||||
// Plant Care & Protection
|
||||
239: "Home & Garden > Lawn & Garden > Pest Control", // Pflanzenschutz
|
||||
240: "Home & Garden > Plants", // Anbauzubehör
|
||||
239: "4085", // Zubehör > Anbauzubehör > Pflanzenschutz – Herbizide
|
||||
240: "5633", // Zubehör > Anbauzubehör – Zubehör für Gartenarbeit
|
||||
|
||||
// Office & Media
|
||||
424: "Office Supplies > Labels", // Etiketten & Schilder
|
||||
387: "Media > Books", // Literatur
|
||||
424: "4377", // Zubehör > Anbauzubehör > Etiketten & Schilder – Etiketten & Anhängerschilder
|
||||
387: "543541", // Zubehör > Anbauzubehör > Literatur – Bücher
|
||||
|
||||
// General categories
|
||||
705: "Home & Garden", // Set-Konfigurator
|
||||
686: "Home & Garden", // Zubehör
|
||||
741: "Home & Garden", // Zubehör
|
||||
294: "Home & Garden", // Zubehör
|
||||
695: "Home & Garden", // Zubehör
|
||||
293: "Home & Garden", // Trockennetze
|
||||
4: "Home & Garden", // Sonstiges
|
||||
450: "Home & Garden", // Restposten
|
||||
686: "1700", // Belüftung > Aktivkohlefilter > Zubehör – Ventilatoren
|
||||
741: "1700", // Belüftung > Ab- und Zuluft > Zubehör – Ventilatoren
|
||||
294: "3568", // Bewässerung > Zubehör – Bewässerungssysteme
|
||||
695: "5631", // Zubehör – Haushaltsbedarf: Aufbewahrung
|
||||
293: "5631", // Zubehör > Ernte & Verarbeitung > Trockennetze – Haushaltsbedarf: Aufbewahrung
|
||||
4: "5631", // Zubehör > Anbauzubehör > Sonstiges – Haushaltsbedarf: Aufbewahrung
|
||||
450: "5631", // Zubehör > Anbauzubehör > Restposten – Haushaltsbedarf: Aufbewahrung
|
||||
};
|
||||
|
||||
return categoryMappings[categoryId] || "Home & Garden > Plants";
|
||||
const categoryId_str = categoryMappings[categoryId] || "5631"; // Default to Haushaltsbedarf: Aufbewahrung
|
||||
|
||||
// Validate that the category ID is not empty
|
||||
if (!categoryId_str || categoryId_str.trim() === "") {
|
||||
return "5631"; // Haushaltsbedarf: Aufbewahrung
|
||||
}
|
||||
|
||||
return categoryId_str;
|
||||
};
|
||||
|
||||
let productsXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss xmlns:g="http://base.google.com/ns/1.0" version="2.0">
|
||||
<channel>
|
||||
<title>${config.descriptions.short}</title>
|
||||
<title>${config.descriptions.de.short}</title>
|
||||
<link>${baseUrl}</link>
|
||||
<description>${config.descriptions.short}</description>
|
||||
<description>${config.descriptions.de.short}</description>
|
||||
<lastBuildDate>${currentDate}</lastBuildDate>
|
||||
<language>${config.language}</language>`;
|
||||
<language>de-DE</language>`;
|
||||
|
||||
// Helper function to clean text content of problematic characters
|
||||
const cleanTextContent = (text) => {
|
||||
@@ -197,15 +306,43 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
let processedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
// Track skip reasons with counts and product lists
|
||||
const skipReasons = {
|
||||
noProductOrSeoName: { count: 0, products: [] },
|
||||
excludedCategory: { count: 0, products: [] },
|
||||
excludedTermsTitle: { count: 0, products: [] },
|
||||
excludedTermsDescription: { count: 0, products: [] },
|
||||
missingGTIN: { count: 0, products: [] },
|
||||
invalidGTINChecksum: { count: 0, products: [] },
|
||||
missingPicture: { count: 0, products: [] },
|
||||
missingWeight: { count: 0, products: [] },
|
||||
insufficientDescription: { count: 0, products: [] },
|
||||
nameTooShort: { count: 0, products: [] },
|
||||
outOfStock: { count: 0, products: [] },
|
||||
zeroPriceOrInvalid: { count: 0, products: [] },
|
||||
processingError: { count: 0, products: [] }
|
||||
};
|
||||
|
||||
// Legacy arrays for backward compatibility
|
||||
const productsNeedingWeight = [];
|
||||
const productsNeedingDescription = [];
|
||||
|
||||
// Category IDs to skip (seeds, plants, headshop items)
|
||||
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710];
|
||||
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258];
|
||||
|
||||
// Add each product as an item
|
||||
allProductsData.forEach((product, index) => {
|
||||
|
||||
try {
|
||||
// Skip products without essential data
|
||||
if (!product || !product.seoName) {
|
||||
skippedCount++;
|
||||
skipReasons.noProductOrSeoName.count++;
|
||||
skipReasons.noProductOrSeoName.products.push({
|
||||
id: product?.articleNumber || 'N/A',
|
||||
name: product?.name || 'N/A',
|
||||
url: product?.seoName ? `/Artikel/${product.seoName}` : 'N/A'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -213,27 +350,168 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
const productCategoryId = product.categoryId || product.category_id || product.category || null;
|
||||
if (productCategoryId && skipCategoryIds.includes(parseInt(productCategoryId))) {
|
||||
skippedCount++;
|
||||
skipReasons.excludedCategory.count++;
|
||||
skipReasons.excludedCategory.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
categoryId: productCategoryId,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip products 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()) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Skip products without pictures
|
||||
if (!product.pictureList || !product.pictureList.trim()) {
|
||||
skippedCount++;
|
||||
skipReasons.missingPicture.count++;
|
||||
skipReasons.missingPicture.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if product has weight data - validate BEFORE building XML
|
||||
if (!product.weight || isNaN(product.weight)) {
|
||||
// Track products without weight
|
||||
const productInfo = {
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'Unnamed',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
};
|
||||
productsNeedingWeight.push(productInfo);
|
||||
skipReasons.missingWeight.count++;
|
||||
skipReasons.missingWeight.products.push(productInfo);
|
||||
skippedCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if description is missing or too short (less than 20 characters) - skip if insufficient
|
||||
const originalDescription = productDescription ? cleanTextContent(productDescription) : '';
|
||||
if (!originalDescription || originalDescription.length < 20) {
|
||||
const productInfo = {
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'Unnamed',
|
||||
currentDescription: originalDescription || 'NONE',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
};
|
||||
productsNeedingDescription.push(productInfo);
|
||||
skipReasons.insufficientDescription.count++;
|
||||
skipReasons.insufficientDescription.products.push(productInfo);
|
||||
skippedCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean description for feed (remove HTML tags and limit length)
|
||||
const rawDescription = product.description
|
||||
? cleanTextContent(product.description).substring(0, 500)
|
||||
: `${product.name || 'Product'} - Art.-Nr.: ${product.articleNumber || 'N/A'}`;
|
||||
|
||||
const cleanDescription = escapeXml(rawDescription) || "Produktbeschreibung nicht verfügbar";
|
||||
const feedDescription = cleanTextContent(productDescription).substring(0, 500);
|
||||
const cleanDescription = escapeXml(feedDescription) || "Produktbeschreibung nicht verfügbar";
|
||||
|
||||
// Clean product name
|
||||
const rawName = product.name || "Unnamed Product";
|
||||
@@ -242,6 +520,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
// Validate essential fields
|
||||
if (!cleanName || cleanName.length < 2) {
|
||||
skippedCount++;
|
||||
skipReasons.nameTooShort.count++;
|
||||
skipReasons.nameTooShort.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: rawName,
|
||||
cleanedName: cleanName,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -250,7 +535,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
|
||||
// Generate image URL
|
||||
const imageUrl = product.pictureList && product.pictureList.trim()
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.jpg`
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.avif`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
|
||||
// Generate brand (manufacturer)
|
||||
@@ -263,6 +548,18 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
// Generate availability
|
||||
const availability = product.available ? "in stock" : "out of stock";
|
||||
|
||||
// Skip products that are out of stock
|
||||
if (!product.available) {
|
||||
skippedCount++;
|
||||
skipReasons.outOfStock.count++;
|
||||
skipReasons.outOfStock.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate price (ensure it's a valid number)
|
||||
const price = product.price && !isNaN(product.price)
|
||||
? `${parseFloat(product.price).toFixed(2)} ${config.currency}`
|
||||
@@ -271,11 +568,18 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
// Skip products with price == 0
|
||||
if (!product.price || parseFloat(product.price) === 0) {
|
||||
skippedCount++;
|
||||
skipReasons.zeroPriceOrInvalid.count++;
|
||||
skipReasons.zeroPriceOrInvalid.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
price: product.price,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate GTIN/EAN if available
|
||||
const gtin = product.gtin ? escapeXml(product.gtin.toString().trim()) : null;
|
||||
// Generate GTIN/EAN if available (use the already validated gtinString)
|
||||
const gtin = gtinString ? escapeXml(gtinString) : null;
|
||||
|
||||
// Generate product ID (using articleNumber or seoName)
|
||||
const rawProductId = product.articleNumber || product.seoName || `product_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
|
||||
@@ -286,7 +590,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
const googleCategory = getGoogleProductCategory(categoryId);
|
||||
const escapedGoogleCategory = escapeXml(googleCategory);
|
||||
|
||||
// Build item XML with proper formatting
|
||||
// Build item XML with proper formatting (all validation passed, safe to write XML)
|
||||
productsXml += `
|
||||
<item>
|
||||
<g:id>${productId}</g:id>
|
||||
@@ -312,10 +616,21 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
<g:gtin>${gtin}</g:gtin>`;
|
||||
}
|
||||
|
||||
// Add weight if available
|
||||
if (product.weight && !isNaN(product.weight)) {
|
||||
// Add weight (we know it exists at this point since we validated it earlier)
|
||||
// Convert from kg to grams (multiply by 1000)
|
||||
const weightInGrams = parseFloat(product.weight) * 1000;
|
||||
productsXml += `
|
||||
<g:shipping_weight>${parseFloat(product.weight).toFixed(2)} g</g:shipping_weight>`;
|
||||
<g:shipping_weight>${weightInGrams.toFixed(2)} g</g:shipping_weight>`;
|
||||
|
||||
// Add unit pricing data (required by German law for many products)
|
||||
const unitPricingData = determineUnitPricingData(product);
|
||||
if (unitPricingData.unit_pricing_measure) {
|
||||
productsXml += `
|
||||
<g:unit_pricing_measure>${unitPricingData.unit_pricing_measure}</g:unit_pricing_measure>`;
|
||||
}
|
||||
if (unitPricingData.unit_pricing_base_measure) {
|
||||
productsXml += `
|
||||
<g:unit_pricing_base_measure>${unitPricingData.unit_pricing_base_measure}</g:unit_pricing_base_measure>`;
|
||||
}
|
||||
|
||||
productsXml += `
|
||||
@@ -326,6 +641,13 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
} catch (itemError) {
|
||||
console.log(` ⚠️ Skipped product ${index + 1}: ${itemError.message}`);
|
||||
skippedCount++;
|
||||
skipReasons.processingError.count++;
|
||||
skipReasons.processingError.products.push({
|
||||
id: product?.articleNumber || product?.seoName || 'N/A',
|
||||
name: product?.name || 'N/A',
|
||||
error: itemError.message,
|
||||
url: product?.seoName ? `/Artikel/${product.seoName}` : 'N/A'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -333,7 +655,133 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
console.log(` 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
|
||||
console.log(`\n 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
|
||||
|
||||
// Display skip reason totals
|
||||
console.log(`\n 📋 Skip Reasons Breakdown:`);
|
||||
console.log(` ────────────────────────────────────────────────────────────`);
|
||||
|
||||
const skipReasonLabels = {
|
||||
noProductOrSeoName: 'No Product or SEO Name',
|
||||
excludedCategory: 'Excluded Category',
|
||||
excludedTermsTitle: 'Excluded Terms in Title',
|
||||
excludedTermsDescription: 'Excluded Terms in Description',
|
||||
missingGTIN: 'Missing GTIN',
|
||||
invalidGTINChecksum: 'Invalid GTIN Checksum',
|
||||
missingPicture: 'Missing Picture',
|
||||
missingWeight: 'Missing Weight',
|
||||
insufficientDescription: 'Insufficient Description',
|
||||
nameTooShort: 'Name Too Short',
|
||||
outOfStock: 'Out of Stock',
|
||||
zeroPriceOrInvalid: 'Zero or Invalid Price',
|
||||
processingError: 'Processing Error'
|
||||
};
|
||||
|
||||
let hasAnySkips = false;
|
||||
Object.entries(skipReasons).forEach(([key, data]) => {
|
||||
if (data.count > 0) {
|
||||
hasAnySkips = true;
|
||||
const label = skipReasonLabels[key] || key;
|
||||
console.log(` • ${label}: ${data.count}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasAnySkips) {
|
||||
console.log(` ✅ No products were skipped`);
|
||||
}
|
||||
|
||||
console.log(` ────────────────────────────────────────────────────────────`);
|
||||
console.log(` Total: ${skippedCount} products skipped\n`);
|
||||
|
||||
// Write log files for products needing attention
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const logsDir = path.join(process.cwd(), 'logs');
|
||||
|
||||
// Ensure logs directory exists
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write comprehensive skip reasons log
|
||||
const skipLogPath = path.join(logsDir, `skip-reasons-${timestamp}.log`);
|
||||
let skipLogContent = `# Product Skip Reasons Report
|
||||
# Generated: ${new Date().toISOString()}
|
||||
# Total products processed: ${processedCount}
|
||||
# Total products skipped: ${skippedCount}
|
||||
# Base URL: ${baseUrl}
|
||||
|
||||
`;
|
||||
|
||||
Object.entries(skipReasons).forEach(([key, data]) => {
|
||||
if (data.count > 0) {
|
||||
const label = skipReasonLabels[key] || key;
|
||||
skipLogContent += `\n## ${label} (${data.count} products)\n`;
|
||||
skipLogContent += `${'='.repeat(80)}\n`;
|
||||
|
||||
data.products.forEach(product => {
|
||||
skipLogContent += `ID: ${product.id}\n`;
|
||||
skipLogContent += `Name: ${product.name}\n`;
|
||||
if (product.categoryId !== undefined) {
|
||||
skipLogContent += `Category ID: ${product.categoryId}\n`;
|
||||
}
|
||||
if (product.term !== undefined) {
|
||||
skipLogContent += `Excluded Term: ${product.term}\n`;
|
||||
}
|
||||
if (product.gtin !== undefined) {
|
||||
skipLogContent += `GTIN: ${product.gtin}\n`;
|
||||
}
|
||||
if (product.currentDescription !== undefined) {
|
||||
skipLogContent += `Current Description: "${product.currentDescription}"\n`;
|
||||
}
|
||||
if (product.cleanedName !== undefined) {
|
||||
skipLogContent += `Cleaned Name: "${product.cleanedName}"\n`;
|
||||
}
|
||||
if (product.price !== undefined) {
|
||||
skipLogContent += `Price: ${product.price}\n`;
|
||||
}
|
||||
if (product.error !== undefined) {
|
||||
skipLogContent += `Error: ${product.error}\n`;
|
||||
}
|
||||
skipLogContent += `URL: ${baseUrl}${product.url}\n`;
|
||||
skipLogContent += `${'-'.repeat(80)}\n`;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(skipLogPath, skipLogContent, 'utf8');
|
||||
console.log(` 📄 Detailed skip reasons report saved to: ${skipLogPath}`);
|
||||
|
||||
// Write missing weight log (for backward compatibility)
|
||||
if (productsNeedingWeight.length > 0) {
|
||||
const weightLogContent = `# Products Missing Weight Data
|
||||
# Generated: ${new Date().toISOString()}
|
||||
# Total products missing weight: ${productsNeedingWeight.length}
|
||||
|
||||
${productsNeedingWeight.map(product => `${product.id}\t${product.name}\t${baseUrl}${product.url}`).join('\n')}
|
||||
`;
|
||||
|
||||
const weightLogPath = path.join(logsDir, `missing-weight-${timestamp}.log`);
|
||||
fs.writeFileSync(weightLogPath, weightLogContent, 'utf8');
|
||||
console.log(` ⚠️ Products missing weight (${productsNeedingWeight.length}) - saved to: ${weightLogPath}`);
|
||||
}
|
||||
|
||||
// Write missing description log (for backward compatibility)
|
||||
if (productsNeedingDescription.length > 0) {
|
||||
const descLogContent = `# Products With Insufficient Description Data
|
||||
# Generated: ${new Date().toISOString()}
|
||||
# Total products needing description: ${productsNeedingDescription.length}
|
||||
|
||||
${productsNeedingDescription.map(product => `${product.id}\t${product.name}\t"${product.currentDescription}"\t${baseUrl}${product.url}`).join('\n')}
|
||||
`;
|
||||
|
||||
const descLogPath = path.join(logsDir, `missing-description-${timestamp}.log`);
|
||||
fs.writeFileSync(descLogPath, descLogContent, 'utf8');
|
||||
console.log(` ⚠️ Products with insufficient description (${productsNeedingDescription.length}) - saved to: ${descLogPath}`);
|
||||
}
|
||||
|
||||
if (productsNeedingWeight.length === 0 && productsNeedingDescription.length === 0) {
|
||||
console.log(` ✅ All products have adequate weight and description data`);
|
||||
}
|
||||
|
||||
return productsXml;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const generateHomepageMetaTags = (baseUrl, config) => {
|
||||
const description = config.descriptions.long;
|
||||
const keywords = config.keywords;
|
||||
const description = config.descriptions.de.long;
|
||||
const keywords = config.keywords.de;
|
||||
const imageUrl = `${baseUrl}${config.images.logo}`;
|
||||
|
||||
// Ensure URLs are properly formatted
|
||||
@@ -12,7 +12,7 @@ const generateHomepageMetaTags = (baseUrl, config) => {
|
||||
<meta name="keywords" content="${keywords}">
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="${config.descriptions.short}">
|
||||
<meta property="og:title" content="${config.descriptions.de.short}">
|
||||
<meta property="og:description" content="${description}">
|
||||
<meta property="og:image" content="${imageUrl}">
|
||||
<meta property="og:url" content="${canonicalUrl}">
|
||||
@@ -21,7 +21,7 @@ const generateHomepageMetaTags = (baseUrl, config) => {
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="${config.descriptions.short}">
|
||||
<meta name="twitter:title" content="${config.descriptions.de.short}">
|
||||
<meta name="twitter:description" content="${description}">
|
||||
<meta name="twitter:image" content="${imageUrl}">
|
||||
|
||||
@@ -41,7 +41,7 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
|
||||
"@type": "WebSite",
|
||||
name: config.brandName,
|
||||
url: canonicalUrl,
|
||||
description: config.descriptions.long,
|
||||
description: config.descriptions.de.long,
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: config.brandName,
|
||||
@@ -73,7 +73,7 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
|
||||
"@type": "LocalBusiness",
|
||||
"name": config.brandName,
|
||||
"alternateName": config.siteName,
|
||||
"description": config.descriptions.long,
|
||||
"description": config.descriptions.de.long,
|
||||
"url": canonicalUrl,
|
||||
"logo": logoUrl,
|
||||
"image": logoUrl,
|
||||
|
||||
@@ -31,6 +31,7 @@ const {
|
||||
generateLlmsTxt,
|
||||
generateCategoryLlmsTxt,
|
||||
generateAllCategoryLlmsPages,
|
||||
generateCategoryProductList,
|
||||
} = require('./llms.cjs');
|
||||
|
||||
// Export all functions for use in the main application
|
||||
@@ -61,4 +62,5 @@ module.exports = {
|
||||
generateLlmsTxt,
|
||||
generateCategoryLlmsTxt,
|
||||
generateAllCategoryLlmsPages,
|
||||
generateCategoryProductList,
|
||||
};
|
||||
@@ -183,7 +183,13 @@ This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in
|
||||
categoryLlmsTxt += `## ${globalIndex}. ${product.name}
|
||||
|
||||
- **Product URL**: ${baseUrl}/Artikel/${product.seoName}
|
||||
- **Article Number**: ${product.articleNumber || 'N/A'}
|
||||
`;
|
||||
|
||||
if (product.kurzBeschreibung) {
|
||||
categoryLlmsTxt += `- **Desc:** ${product.kurzBeschreibung}\n`;
|
||||
}
|
||||
|
||||
categoryLlmsTxt += `- **Article Number**: ${product.articleNumber || 'N/A'}
|
||||
- **Price**: €${product.price || '0.00'}
|
||||
- **Brand**: ${product.manufacturer || config.brandName}
|
||||
- **Availability**: ${product.available ? 'In Stock' : 'Out of Stock'}`;
|
||||
@@ -248,6 +254,41 @@ This category currently contains no products.
|
||||
return categoryLlmsTxt;
|
||||
};
|
||||
|
||||
// Helper function to generate a simple product list for a category
|
||||
const generateCategoryProductList = (category, categoryProducts = []) => {
|
||||
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
const fileName = `llms-${categorySlug}-list.txt`;
|
||||
|
||||
const subcategoryIds = (category.subcategories || []).join(',');
|
||||
let content = `${String(category.name)},${String(category.id)},[${subcategoryIds}]\n`;
|
||||
|
||||
categoryProducts.forEach((product) => {
|
||||
const artnr = String(product.articleNumber || '');
|
||||
const price = String(product.price || '0.00');
|
||||
const name = String(product.name || '');
|
||||
const kurzBeschreibung = String(product.kurzBeschreibung || '');
|
||||
|
||||
// Escape commas in fields by wrapping in quotes if they contain commas
|
||||
const escapeField = (field) => {
|
||||
const fieldStr = String(field || '');
|
||||
if (fieldStr.includes(',')) {
|
||||
return `"${fieldStr.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return fieldStr;
|
||||
};
|
||||
|
||||
content += `${escapeField(artnr)},${escapeField(price)},${escapeField(name)},${escapeField(kurzBeschreibung)}\n`;
|
||||
});
|
||||
|
||||
return {
|
||||
fileName,
|
||||
content,
|
||||
categoryName: category.name,
|
||||
categoryId: category.id,
|
||||
productCount: categoryProducts.length
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to generate all pages for a category
|
||||
const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl, config, productsPerPage = 50) => {
|
||||
const totalProducts = categoryProducts.length;
|
||||
@@ -274,4 +315,5 @@ module.exports = {
|
||||
generateLlmsTxt,
|
||||
generateCategoryLlmsTxt,
|
||||
generateAllCategoryLlmsPages,
|
||||
generateCategoryProductList,
|
||||
};
|
||||
@@ -1,14 +1,21 @@
|
||||
const generateProductMetaTags = (product, baseUrl, config) => {
|
||||
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
|
||||
|
||||
const imageUrl =
|
||||
product.pictureList && product.pictureList.trim()
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||
.split(",")[0]
|
||||
.trim()}.jpg`
|
||||
.trim()}.avif`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
|
||||
|
||||
// Clean description for meta (remove HTML tags and limit length)
|
||||
const cleanDescription = product.description
|
||||
const cleanDescription = product.kurzBeschreibung
|
||||
? product.kurzBeschreibung
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\n/g, " ")
|
||||
.substring(0, 160)
|
||||
: product.description
|
||||
? product.description
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\n/g, " ")
|
||||
@@ -47,6 +54,11 @@ const generateProductMetaTags = (product, baseUrl, config) => {
|
||||
<!-- Additional Meta Tags -->
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="${productUrl}">
|
||||
|
||||
<!-- Store image URL in window object -->
|
||||
<script>
|
||||
window.productImageUrl = "${imageUrl}";
|
||||
</script>
|
||||
`;
|
||||
};
|
||||
|
||||
@@ -56,7 +68,7 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
|
||||
product.pictureList && product.pictureList.trim()
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||
.split(",")[0]
|
||||
.trim()}.jpg`
|
||||
.trim()}.avif`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
|
||||
// Clean description for JSON-LD (remove HTML tags)
|
||||
@@ -94,6 +106,41 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
|
||||
"@type": "Organization",
|
||||
name: config.brandName,
|
||||
},
|
||||
hasMerchantReturnPolicy: {
|
||||
"@type": "MerchantReturnPolicy",
|
||||
applicableCountry: "DE",
|
||||
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
|
||||
merchantReturnDays: 14,
|
||||
returnMethod: "https://schema.org/ReturnByMail",
|
||||
returnFees: "https://schema.org/FreeReturn",
|
||||
},
|
||||
shippingDetails: {
|
||||
"@type": "OfferShippingDetails",
|
||||
shippingRate: {
|
||||
"@type": "MonetaryAmount",
|
||||
value: 5.90,
|
||||
currency: "EUR",
|
||||
},
|
||||
shippingDestination: {
|
||||
"@type": "DefinedRegion",
|
||||
addressCountry: "DE",
|
||||
},
|
||||
deliveryTime: {
|
||||
"@type": "ShippingDeliveryTime",
|
||||
handlingTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
transitTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 2,
|
||||
maxValue: 3,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -7,11 +7,17 @@ const collectAllCategories = (categoryNode, categories = []) => {
|
||||
|
||||
// Add current category (skip root category 209)
|
||||
if (categoryNode.id !== 209) {
|
||||
// Extract subcategory IDs from children
|
||||
const subcategoryIds = categoryNode.children
|
||||
? categoryNode.children.map(child => child.id)
|
||||
: [];
|
||||
|
||||
categories.push({
|
||||
id: categoryNode.id,
|
||||
name: categoryNode.name,
|
||||
seoName: categoryNode.seoName,
|
||||
parentId: categoryNode.parentId
|
||||
parentId: categoryNode.parentId,
|
||||
subcategories: subcategoryIds
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
83
process_llms_cat.cjs
Normal file
@@ -0,0 +1,83 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Read the input file
|
||||
const inputFile = path.join(__dirname, 'dist', 'llms-cat.txt');
|
||||
const outputFile = path.join(__dirname, 'output.csv');
|
||||
|
||||
// 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 {
|
||||
const data = fs.readFileSync(inputFile, 'utf8');
|
||||
const lines = data.trim().split('\n');
|
||||
|
||||
const outputLines = ['URL,SEO Description'];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '') continue;
|
||||
|
||||
// Parse the CSV line properly handling escaped quotes
|
||||
const fields = parseCSVLine(line);
|
||||
|
||||
if (fields.length !== 3) {
|
||||
console.warn(`Skipping malformed line (got ${fields.length} fields): ${line.substring(0, 100)}...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [field1, field2, field3] = fields;
|
||||
const url = field2;
|
||||
|
||||
// field3 is a JSON string - parse it directly
|
||||
let seoDescription = '';
|
||||
try {
|
||||
const parsed = JSON.parse(field3);
|
||||
seoDescription = parsed.seo_description || '';
|
||||
} catch (e) {
|
||||
console.warn(`Failed to parse JSON for URL ${url}: ${e.message}`);
|
||||
console.warn(`JSON string: ${field3.substring(0, 200)}...`);
|
||||
}
|
||||
|
||||
// Escape quotes for CSV output - URL doesn't need quotes, description does
|
||||
const escapedDescription = '"' + seoDescription.replace(/"/g, '""') + '"';
|
||||
|
||||
outputLines.push(`${url},${escapedDescription}`);
|
||||
}
|
||||
|
||||
// Write the output CSV
|
||||
fs.writeFileSync(outputFile, outputLines.join('\n'), 'utf8');
|
||||
console.log(`Processed ${lines.length} lines and created ${outputFile}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing file:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 85 KiB |
BIN
public/assets/images/cutlings.avif
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/assets/images/filiale1.jpg
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
public/assets/images/filiale2.jpg
Normal file
|
After Width: | Height: | Size: 242 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/presse.jpg
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
public/assets/images/purpl.jpg
Normal file
|
After Width: | Height: | Size: 68 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 |
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="de" data-i18n-lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
90
public/llms-cat.txt
Normal file
@@ -0,0 +1,90 @@
|
||||
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 {
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useNavigate
|
||||
} from "react-router-dom";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
@@ -14,23 +15,30 @@ import Fab from "@mui/material/Fab";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import SmartToyIcon from "@mui/icons-material/SmartToy";
|
||||
import PaletteIcon from "@mui/icons-material/Palette";
|
||||
import BugReportIcon from "@mui/icons-material/BugReport";
|
||||
import ScienceIcon from "@mui/icons-material/Science";
|
||||
|
||||
import SocketProvider from "./providers/SocketProvider.js";
|
||||
import SocketContext from "./contexts/SocketContext.js";
|
||||
import { CarouselProvider } from "./contexts/CarouselContext.js";
|
||||
import { ProductContextProvider } from "./context/ProductContext.js";
|
||||
import { CategoryContextProvider } from "./context/CategoryContext.js";
|
||||
import TitleUpdater from "./components/TitleUpdater.js";
|
||||
import config from "./config.js";
|
||||
import ScrollToTop from "./components/ScrollToTop.js";
|
||||
|
||||
// Import i18n
|
||||
import './i18n/index.js';
|
||||
import { LanguageProvider } from './i18n/withTranslation.js';
|
||||
import i18n from './i18n/index.js';
|
||||
//import TelemetryService from './services/telemetryService.js';
|
||||
|
||||
import Header from "./components/Header.js";
|
||||
import Footer from "./components/Footer.js";
|
||||
import Home from "./pages/Home.js";
|
||||
import MainPageLayout from "./components/MainPageLayout.js";
|
||||
|
||||
// Lazy load all route components to reduce initial bundle size
|
||||
const Content = lazy(() => import(/* webpackChunkName: "content" */ "./components/Content.js"));
|
||||
const ProductDetailWithSocket = lazy(() => import(/* webpackChunkName: "product-detail" */ "./components/ProductDetailWithSocket.js"));
|
||||
const ProfilePageWithSocket = lazy(() => import(/* webpackChunkName: "profile" */ "./pages/ProfilePage.js"));
|
||||
const ResetPassword = lazy(() => import(/* webpackChunkName: "reset-password" */ "./pages/ResetPassword.js"));
|
||||
// TEMPORARILY DISABLE ALL LAZY LOADING TO ELIMINATE CircularProgress
|
||||
import Content from "./components/Content.js";
|
||||
import ProductDetail from "./components/ProductDetail.js";
|
||||
import ProfilePage from "./pages/ProfilePage.js";
|
||||
import ResetPassword from "./pages/ResetPassword.js";
|
||||
|
||||
// Lazy load admin pages - only loaded when admin users access them
|
||||
const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js"));
|
||||
@@ -40,8 +48,9 @@ const ServerLogsPage = lazy(() => import(/* webpackChunkName: "admin-logs" */ ".
|
||||
// Lazy load legal pages - rarely accessed
|
||||
const Datenschutz = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Datenschutz.js"));
|
||||
const AGB = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/AGB.js"));
|
||||
const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js"));
|
||||
//const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); <Route path="/404" element={<NotFound404 />} />
|
||||
const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js"));
|
||||
const CategoriesPage = lazy(() => import(/* webpackChunkName: "categories" */ "./pages/CategoriesPage.js"));
|
||||
const Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
|
||||
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
|
||||
const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js"));
|
||||
@@ -50,55 +59,54 @@ const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./page
|
||||
const GrowTentKonfigurator = lazy(() => import(/* webpackChunkName: "konfigurator" */ "./pages/GrowTentKonfigurator.js"));
|
||||
const ChatAssistant = lazy(() => import(/* webpackChunkName: "chat" */ "./components/ChatAssistant.js"));
|
||||
|
||||
// Lazy load separate pages that are truly different
|
||||
const PresseverleihPage = lazy(() => import(/* webpackChunkName: "presseverleih" */ "./pages/PresseverleihPage.js"));
|
||||
const ThcTestPage = lazy(() => import(/* webpackChunkName: "thc-test" */ "./pages/ThcTestPage.js"));
|
||||
|
||||
// Lazy load payment success page
|
||||
const PaymentSuccess = lazy(() => import(/* webpackChunkName: "payment" */ "./components/PaymentSuccess.js"));
|
||||
|
||||
// Lazy load prerender component (development testing only)
|
||||
const PrerenderHome = lazy(() => import(/* webpackChunkName: "prerender-home" */ "./PrerenderHome.js"));
|
||||
|
||||
// Import theme from separate file to reduce main bundle size
|
||||
import defaultTheme from "./theme.js";
|
||||
// Lazy load theme customizer for development only
|
||||
const ThemeCustomizerDialog = lazy(() => import(/* webpackChunkName: "theme-customizer" */ "./components/ThemeCustomizerDialog.js"));
|
||||
import { createTheme } from "@mui/material/styles";
|
||||
|
||||
const deleteMessages = () => {
|
||||
console.log("Deleting messages");
|
||||
window.chatMessages = [];
|
||||
};
|
||||
|
||||
// Component to initialize telemetry service with socket
|
||||
const TelemetryInitializer = ({ socket }) => {
|
||||
const telemetryServiceRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (socket && !telemetryServiceRef.current) {
|
||||
//telemetryServiceRef.current = new TelemetryService(socket);
|
||||
//telemetryServiceRef.current.init();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (telemetryServiceRef.current) {
|
||||
telemetryServiceRef.current.destroy();
|
||||
telemetryServiceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
};
|
||||
|
||||
const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
|
||||
// State to manage chat visibility
|
||||
const [isChatOpen, setChatOpen] = useState(false);
|
||||
const [authVersion, setAuthVersion] = useState(0);
|
||||
// @note Theme customizer state for development mode
|
||||
const [isThemeCustomizerOpen, setThemeCustomizerOpen] = useState(false);
|
||||
// State to track active category for article pages
|
||||
const [articleCategoryId, setArticleCategoryId] = useState(null);
|
||||
|
||||
|
||||
// Remove duplicate theme state since it's passed as prop
|
||||
// const [dynamicTheme, setDynamicTheme] = useState(createTheme(defaultTheme));
|
||||
|
||||
// Get current location
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (location.hash && location.hash.startsWith("#ORD-")) {
|
||||
if (location.hash && location.hash.length > 1) {
|
||||
// Check if it's a potential order ID (starts with # and has alphanumeric characters with dashes)
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -111,10 +119,44 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Extract categoryId from pathname if on category route
|
||||
// Clear article category when navigating away from article pages
|
||||
useEffect(() => {
|
||||
const isArticlePage = location.pathname.startsWith('/Artikel/');
|
||||
const isCategoryPage = location.pathname.startsWith('/Kategorie/');
|
||||
const isHomePage = location.pathname === '/';
|
||||
|
||||
// Only clear article category when navigating to non-article pages
|
||||
// (but keep it when going from category to article)
|
||||
if (!isArticlePage && !isCategoryPage && !isHomePage) {
|
||||
setArticleCategoryId(null);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
// Read article category from navigation state (when coming from product click)
|
||||
useEffect(() => {
|
||||
if (location.state && location.state.articleCategoryId !== undefined) {
|
||||
if (location.state.articleCategoryId !== null) {
|
||||
setArticleCategoryId(location.state.articleCategoryId);
|
||||
}
|
||||
// Clear the state so it doesn't persist on page refresh
|
||||
navigate(location.pathname, { replace: true, state: {} });
|
||||
}
|
||||
}, [location.state, navigate, location.pathname]);
|
||||
|
||||
// Extract categoryId from pathname if on category route, or use article category
|
||||
const getCategoryId = () => {
|
||||
const match = location.pathname.match(/^\/Kategorie\/(.+)$/);
|
||||
return match ? match[1] : null;
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
// For article pages, use the article category if available
|
||||
const isArticlePage = location.pathname.startsWith('/Artikel/');
|
||||
if (isArticlePage && articleCategoryId) {
|
||||
return articleCategoryId;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const categoryId = getCategoryId();
|
||||
@@ -139,35 +181,40 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
setThemeCustomizerOpen(!isThemeCustomizerOpen);
|
||||
};
|
||||
|
||||
// Handler to open GitHub issue reporting
|
||||
const handleReportIssue = () => {
|
||||
const issueTitle = encodeURIComponent("Fehlerbericht");
|
||||
const issueBody = encodeURIComponent(
|
||||
`**Seite:** ${window.location.href}
|
||||
**Browser:** ${navigator.userAgent.split(' ')[0]}
|
||||
**Datum:** ${new Date().toLocaleDateString('de-DE')}
|
||||
|
||||
**Problem:**
|
||||
[Beschreibe kurz das Problem]
|
||||
|
||||
**So ist es passiert:**
|
||||
1.
|
||||
2.
|
||||
|
||||
**Was sollte passieren:**
|
||||
[Was erwartet wurde]`
|
||||
);
|
||||
|
||||
const githubIssueUrl = `https://github.com/Growheads-de/shopFrontEnd/issues/new?title=${issueTitle}&body=${issueBody}`;
|
||||
window.open(githubIssueUrl, '_blank');
|
||||
};
|
||||
|
||||
// Check if we're in development mode
|
||||
const isDevelopment = process.env.NODE_ENV === "development";
|
||||
|
||||
const {socket,socketB} = useContext(SocketContext);
|
||||
console.log("AppContent: socket", socket);
|
||||
// Check if current route is a prerender test route
|
||||
const isPrerenderTestRoute = isDevelopment && location.pathname === "/prerenderTest/home";
|
||||
|
||||
// If it's a prerender test route, render it standalone without app layout
|
||||
if (isPrerenderTestRoute) {
|
||||
return (
|
||||
<LanguageProvider i18n={i18n}>
|
||||
<ThemeProvider theme={dynamicTheme}>
|
||||
<CssBaseline />
|
||||
<Suspense fallback={
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<CircularProgress color="primary" />
|
||||
</Box>
|
||||
}>
|
||||
<PrerenderHome />
|
||||
</Suspense>
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular app layout for all other routes
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -179,11 +226,19 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
bgcolor: "background.default",
|
||||
}}
|
||||
>
|
||||
<TitleUpdater />
|
||||
<ScrollToTop />
|
||||
<TelemetryInitializer socket={socket} />
|
||||
<Header active categoryId={categoryId} key={authVersion} />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box component="main" sx={{ flexGrow: 1 }}>
|
||||
<Suspense fallback={
|
||||
// Use prerender fallback if available, otherwise show loading spinner
|
||||
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: window.__PRERENDER_FALLBACK__.content,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
@@ -194,48 +249,55 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
>
|
||||
<CircularProgress color="primary" />
|
||||
</Box>
|
||||
)
|
||||
}>
|
||||
<CarouselProvider>
|
||||
<Routes>
|
||||
{/* Home page with text only */}
|
||||
<Route path="/" element={<Home />} />
|
||||
{/* Main pages using unified component */}
|
||||
<Route path="/" element={<MainPageLayout />} />
|
||||
<Route path="/aktionen" element={<MainPageLayout />} />
|
||||
<Route path="/filiale" element={<MainPageLayout />} />
|
||||
|
||||
{/* Category page - Render Content in parallel */}
|
||||
<Route
|
||||
path="/Kategorie/:categoryId"
|
||||
element={<Content socket={socket} socketB={socketB} />}
|
||||
element={<Content />}
|
||||
/>
|
||||
{/* Single product page */}
|
||||
<Route
|
||||
path="/Artikel/:seoName"
|
||||
element={<ProductDetailWithSocket />}
|
||||
element={<ProductDetail />}
|
||||
/>
|
||||
|
||||
{/* Search page - Render Content in parallel */}
|
||||
<Route path="/search" element={<Content socket={socket} socketB={socketB} />} />
|
||||
<Route path="/search" element={<Content />} />
|
||||
|
||||
{/* Profile page */}
|
||||
<Route path="/profile" element={<ProfilePageWithSocket />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
|
||||
{/* Payment success page for Mollie redirects */}
|
||||
<Route path="/payment/success" element={<PaymentSuccess />} />
|
||||
|
||||
{/* Reset password page */}
|
||||
<Route
|
||||
path="/resetPassword"
|
||||
element={<ResetPassword socket={socket} socketB={socketB} />}
|
||||
element={<ResetPassword />}
|
||||
/>
|
||||
|
||||
{/* Admin page */}
|
||||
<Route path="/admin" element={<AdminPage socket={socket} socketB={socketB} />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
|
||||
{/* Admin Users page */}
|
||||
<Route path="/admin/users" element={<UsersPage socket={socket} socketB={socketB} />} />
|
||||
<Route path="/admin/users" element={<UsersPage />} />
|
||||
|
||||
{/* Admin Server Logs page */}
|
||||
<Route path="/admin/logs" element={<ServerLogsPage socket={socket} socketB={socketB} />} />
|
||||
<Route path="/admin/logs" element={<ServerLogsPage />} />
|
||||
|
||||
{/* Legal pages */}
|
||||
<Route path="/datenschutz" element={<Datenschutz />} />
|
||||
<Route path="/agb" element={<AGB />} />
|
||||
<Route path="/404" element={<NotFound404 />} />
|
||||
<Route path="/sitemap" element={<Sitemap />} />
|
||||
<Route path="/Kategorien" element={<CategoriesPage />} />
|
||||
<Route path="/impressum" element={<Impressum />} />
|
||||
<Route
|
||||
path="/batteriegesetzhinweise"
|
||||
@@ -246,18 +308,32 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
{/* Grow Tent Configurator */}
|
||||
<Route path="/Konfigurator" element={<GrowTentKonfigurator />} />
|
||||
|
||||
{/* Separate pages that are truly different */}
|
||||
<Route path="/presseverleih" element={<PresseverleihPage />} />
|
||||
<Route path="/thc-test" element={<ThcTestPage />} />
|
||||
|
||||
{/* Fallback for undefined routes */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</CarouselProvider>
|
||||
</Suspense>
|
||||
</Box>
|
||||
{/* Conditionally render the Chat Assistant */}
|
||||
{isChatOpen && (
|
||||
<Suspense fallback={<CircularProgress size={20} />}>
|
||||
<Suspense fallback={
|
||||
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: window.__PRERENDER_FALLBACK__.content,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CircularProgress size={20} />
|
||||
)
|
||||
}>
|
||||
<ChatAssistant
|
||||
open={isChatOpen}
|
||||
onClose={handleChatClose}
|
||||
socket={socket}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
@@ -279,7 +355,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
|
||||
{/* GitHub Issue Reporter FAB */}
|
||||
{/* GitHub Issue Reporter FAB
|
||||
<Tooltip title="Fehler oder Problem melden" placement="left">
|
||||
<Fab
|
||||
color="error"
|
||||
@@ -294,7 +370,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
>
|
||||
<BugReportIcon sx={{ fontSize: "1.2rem" }} />
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
</Tooltip>*/}
|
||||
|
||||
{/* Development-only Theme Customizer FAB */}
|
||||
{isDevelopment && (
|
||||
@@ -315,9 +391,38 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Development-only Prerender Test FAB */}
|
||||
{isDevelopment && (
|
||||
<Tooltip title="Test Prerender Home" placement="left">
|
||||
<Fab
|
||||
color="warning"
|
||||
aria-label="prerender test"
|
||||
size="small"
|
||||
sx={{
|
||||
position: "fixed",
|
||||
bottom: 31,
|
||||
right: 75,
|
||||
}}
|
||||
onClick={() => navigate('/prerenderTest/home')}
|
||||
>
|
||||
<ScienceIcon sx={{ fontSize: "1.2rem" }} />
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Development-only Theme Customizer Dialog */}
|
||||
{isDevelopment && isThemeCustomizerOpen && (
|
||||
<Suspense fallback={<CircularProgress size={20} />}>
|
||||
<Suspense fallback={
|
||||
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: window.__PRERENDER_FALLBACK__.content,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CircularProgress size={20} />
|
||||
)
|
||||
}>
|
||||
<ThemeCustomizerDialog
|
||||
open={isThemeCustomizerOpen}
|
||||
onClose={() => setThemeCustomizerOpen(false)}
|
||||
@@ -343,30 +448,26 @@ const App = () => {
|
||||
setDynamicTheme(createTheme(newTheme));
|
||||
};
|
||||
|
||||
// Make config globally available for language switching
|
||||
useEffect(() => {
|
||||
window.shopConfig = config;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LanguageProvider i18n={i18n}>
|
||||
<ThemeProvider theme={dynamicTheme}>
|
||||
<ProductContextProvider>
|
||||
<CategoryContextProvider>
|
||||
<CssBaseline />
|
||||
<SocketProvider
|
||||
url={config.apiBaseUrl}
|
||||
fallback={
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
}}
|
||||
>
|
||||
<CircularProgress color="primary" />
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<AppContent
|
||||
currentTheme={currentTheme}
|
||||
dynamicTheme={dynamicTheme}
|
||||
onThemeChange={handleThemeChange}
|
||||
/>
|
||||
</SocketProvider>
|
||||
</CategoryContextProvider>
|
||||
</ProductContextProvider>
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Box, AppBar, Toolbar, Container} from '@mui/material';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import Footer from './components/Footer.js';
|
||||
import { Logo, CategoryList } from './components/header/index.js';
|
||||
import Home from './pages/Home.js';
|
||||
import MainPageLayout from './components/MainPageLayout.js';
|
||||
import { CarouselProvider } from './contexts/CarouselContext.js';
|
||||
|
||||
const PrerenderAppContent = (socket) => (
|
||||
<Box
|
||||
@@ -43,10 +44,12 @@ const PrerenderAppContent = (socket) => (
|
||||
<CategoryList categoryId={209} activeCategoryId={null} socket={socket}/>
|
||||
</AppBar>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box component="main" sx={{ flexGrow: 1 }}>
|
||||
<CarouselProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/" element={<MainPageLayout />} />
|
||||
</Routes>
|
||||
</CarouselProvider>
|
||||
</Box>
|
||||
|
||||
<Footer/>
|
||||
|
||||
118
src/PrerenderCategoriesPage.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import LegalPage from './pages/LegalPage.js';
|
||||
import CategoryBox from './components/CategoryBox.js';
|
||||
|
||||
const PrerenderCategoriesPage = ({ categoryData }) => {
|
||||
// Helper function to recursively collect all categories from the tree
|
||||
const collectAllCategories = (categoryNode, categories = [], level = 0) => {
|
||||
if (!categoryNode) return categories;
|
||||
|
||||
// Add current category (skip root category 209)
|
||||
if (categoryNode.id !== 209 && categoryNode.seoName) {
|
||||
categories.push({
|
||||
id: categoryNode.id,
|
||||
name: categoryNode.name,
|
||||
seoName: categoryNode.seoName,
|
||||
level: level
|
||||
});
|
||||
}
|
||||
|
||||
// Recursively add children
|
||||
if (categoryNode.children) {
|
||||
for (const child of categoryNode.children) {
|
||||
collectAllCategories(child, categories, level + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return categories;
|
||||
};
|
||||
|
||||
// The categoryData passed prop is the root tree (id: 209)
|
||||
const rootTree = categoryData;
|
||||
|
||||
const renderLevel1Section = (l1Node) => {
|
||||
// Collect all descendants (excluding the L1 node itself, which collectAllCategories would include first)
|
||||
const descendants = collectAllCategories(l1Node).slice(1);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
key={l1Node.id}
|
||||
elevation={1}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
alignItems: { xs: 'flex-start', md: 'flex-start' },
|
||||
gap: 3
|
||||
}}
|
||||
>
|
||||
{/* Level 1 Header/Box */}
|
||||
<Box sx={{
|
||||
minWidth: '150px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1
|
||||
}}>
|
||||
<CategoryBox
|
||||
id={l1Node.id}
|
||||
name={l1Node.name}
|
||||
seoName={l1Node.seoName}
|
||||
sx={{
|
||||
boxShadow: 4,
|
||||
width: '150px',
|
||||
height: '150px'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Descendants area */}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2
|
||||
}}>
|
||||
{descendants.map((cat) => (
|
||||
<CategoryBox
|
||||
key={cat.id}
|
||||
id={cat.id}
|
||||
name={cat.name}
|
||||
seoName={cat.seoName}
|
||||
sx={{
|
||||
width: '100px',
|
||||
height: '100px',
|
||||
minWidth: '100px',
|
||||
minHeight: '100px',
|
||||
boxShadow: 1,
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<Box>
|
||||
<Box>
|
||||
{rootTree && rootTree.children && rootTree.children.map((child) => (
|
||||
renderLevel1Section(child)
|
||||
))}
|
||||
{(!rootTree || !rootTree.children || rootTree.children.length === 0) && (
|
||||
<Typography>Keine Kategorien gefunden.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return <LegalPage title="Kategorien" content={content} />;
|
||||
};
|
||||
|
||||
export default PrerenderCategoriesPage;
|
||||
@@ -3,7 +3,7 @@ import { Box, AppBar, Toolbar, Container, Typography, Grid, Card, CardMedia, Car
|
||||
import Footer from './components/Footer.js';
|
||||
import { Logo, SearchBar, CategoryList } from './components/header/index.js';
|
||||
|
||||
const PrerenderCategory = ({ categoryId, categoryName, categorySeoName, productData }) => {
|
||||
const PrerenderCategory = ({ categoryId, categoryName, categorySeoName: _categorySeoName, productData }) => {
|
||||
const products = productData?.products || [];
|
||||
|
||||
return (
|
||||
@@ -111,7 +111,7 @@ const PrerenderCategory = ({ categoryId, categoryName, categorySeoName, productD
|
||||
component="img"
|
||||
height="200"
|
||||
image={product.pictureList && product.pictureList.trim()
|
||||
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.jpg`
|
||||
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.avif`
|
||||
: '/assets/images/nopicture.jpg'
|
||||
}
|
||||
alt={product.name}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
const React = require('react');
|
||||
const {
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Container
|
||||
} = require('@mui/material');
|
||||
const Footer = require('./components/Footer.js').default;
|
||||
const { Logo, CategoryList } = require('./components/header/index.js');
|
||||
const Home = require('./pages/Home.js').default;
|
||||
} from '@mui/material';
|
||||
import Footer from './components/Footer.js';
|
||||
import { Logo, CategoryList } from './components/header/index.js';
|
||||
|
||||
|
||||
class PrerenderHome extends React.Component {
|
||||
render() {
|
||||
@@ -28,10 +28,14 @@ class PrerenderHome extends React.Component {
|
||||
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
|
||||
React.createElement(
|
||||
Toolbar,
|
||||
{ sx: { minHeight: 64 } },
|
||||
{ sx: { minHeight: 64, py: { xs: 0.5, sm: 0 } } },
|
||||
React.createElement(
|
||||
Container,
|
||||
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center' } },
|
||||
{ maxWidth: 'lg', sx: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
px: { xs: 0, sm: 3 }
|
||||
} },
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
@@ -49,24 +53,78 @@ class PrerenderHome extends React.Component {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
justifyContent: { xs: 'space-between', sm: 'flex-start' }
|
||||
justifyContent: { xs: 'space-between', sm: 'flex-start' },
|
||||
minHeight: { xs: 52, sm: 'auto' },
|
||||
px: { xs: 0, sm: 0 }
|
||||
}
|
||||
},
|
||||
React.createElement(Logo)
|
||||
React.createElement(Logo),
|
||||
// Invisible SearchBar placeholder on desktop
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
flexGrow: 1,
|
||||
height: 41, // Reserve space for SearchBar
|
||||
opacity: 0 // Invisible placeholder
|
||||
}
|
||||
}
|
||||
),
|
||||
// Invisible ButtonGroup placeholder
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: 'flex',
|
||||
alignItems: { xs: 'flex-end', sm: 'center' },
|
||||
transform: { xs: 'translateY(4px) translateX(9px)', sm: 'none' },
|
||||
ml: { xs: 0, sm: 0 },
|
||||
gap: { xs: 0.5, sm: 1 },
|
||||
opacity: 0 // Invisible placeholder
|
||||
}
|
||||
},
|
||||
// Placeholder for LanguageSwitcher (approx width)
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { width: 40, height: 40 } }
|
||||
),
|
||||
// Placeholder for LoginComponent (approx width)
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { width: 40, height: 40 } }
|
||||
),
|
||||
// Placeholder for Cart button (approx width)
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { width: 48, height: 40, ml: 1 } }
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
// Invisible SearchBar placeholder on mobile
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
width: '100%',
|
||||
mt: { xs: 1, sm: 0 },
|
||||
mb: { xs: 0.5, sm: 0 },
|
||||
px: { xs: 0, sm: 0 },
|
||||
height: 41, // Reserve space for SearchBar
|
||||
opacity: 0 // Invisible placeholder
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
React.createElement(CategoryList, { categoryId: 209, activeCategoryId: null })
|
||||
),
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { flexGrow: 1 } },
|
||||
React.createElement(Home)
|
||||
),
|
||||
React.createElement(Footer)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { default: PrerenderHome };
|
||||
export default PrerenderHome;
|
||||
@@ -66,6 +66,7 @@ class PrerenderKonfigurator extends Component {
|
||||
15%
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{/* Note: This is a prerender file - translation key would be: product.discount.from3Products */}
|
||||
ab 3 Produkten
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -74,6 +75,7 @@ class PrerenderKonfigurator extends Component {
|
||||
24%
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{/* Note: This is a prerender file - translation key would be: product.discount.from5Products */}
|
||||
ab 5 Produkten
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -82,11 +84,13 @@ class PrerenderKonfigurator extends Component {
|
||||
36%
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{/* Note: This is a prerender file - translation key would be: product.discount.from7Products */}
|
||||
ab 7 Produkten
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<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!
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
92
src/PrerenderNotFound.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Container
|
||||
} from '@mui/material';
|
||||
import Footer from './components/Footer.js';
|
||||
import { Logo } from './components/header/index.js';
|
||||
import NotFound404 from './pages/NotFound404.js';
|
||||
|
||||
class PrerenderNotFound extends React.Component {
|
||||
render() {
|
||||
return React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
mb: 0,
|
||||
pb: 0,
|
||||
bgcolor: 'background.default'
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
AppBar,
|
||||
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
|
||||
React.createElement(
|
||||
Toolbar,
|
||||
{ sx: { minHeight: 64, py: { xs: 0.5, sm: 0 } } },
|
||||
React.createElement(
|
||||
Container,
|
||||
{
|
||||
maxWidth: 'lg',
|
||||
sx: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
px: { xs: 0, sm: 3 }
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
flexDirection: { xs: 'column', sm: 'row' }
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
justifyContent: { xs: 'space-between', sm: 'flex-start' },
|
||||
minHeight: { xs: 52, sm: 'auto' },
|
||||
px: { xs: 0, sm: 0 }
|
||||
}
|
||||
},
|
||||
React.createElement(Logo)
|
||||
),
|
||||
// Reserve space for SearchBar on mobile (invisible placeholder)
|
||||
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: 40, // Reserve space for SearchBar
|
||||
opacity: 0 // Invisible placeholder
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { flexGrow: 1 } },
|
||||
React.createElement(NotFound404)
|
||||
),
|
||||
React.createElement(Footer)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PrerenderNotFound;
|
||||
@@ -1,18 +1,25 @@
|
||||
const React = require('react');
|
||||
const {
|
||||
import React from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardMedia,
|
||||
Grid,
|
||||
Box,
|
||||
Chip,
|
||||
Stack,
|
||||
AppBar,
|
||||
Toolbar
|
||||
} = require('@mui/material');
|
||||
const Footer = require('./components/Footer.js').default;
|
||||
const { Logo } = require('./components/header/index.js');
|
||||
Toolbar,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import Footer from './components/Footer.js';
|
||||
import { Logo } from './components/header/index.js';
|
||||
import ProductImage from './components/ProductImage.js';
|
||||
|
||||
// Utility function to clean product names by removing trailing number in parentheses
|
||||
const cleanProductName = (name) => {
|
||||
if (!name) return "";
|
||||
// Remove patterns like " (1)", " (3)", " (10)" at the end of the string
|
||||
return name.replace(/\s*\(\d+\)\s*$/, "").trim();
|
||||
};
|
||||
|
||||
class PrerenderProduct extends React.Component {
|
||||
render() {
|
||||
@@ -20,21 +27,29 @@ class PrerenderProduct extends React.Component {
|
||||
|
||||
if (!productData) {
|
||||
return React.createElement(
|
||||
Container,
|
||||
{ maxWidth: 'lg', sx: { py: 4 } },
|
||||
Box,
|
||||
{ sx: { p: 4, textAlign: "center" } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h4', component: 'h1', gutterBottom: true },
|
||||
'Product not found'
|
||||
{ variant: 'h5', gutterBottom: true },
|
||||
'Produkt nicht gefunden'
|
||||
),
|
||||
React.createElement(
|
||||
Typography,
|
||||
null,
|
||||
'Das gesuchte Produkt existiert nicht oder wurde entfernt.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const product = productData.product;
|
||||
const attributes = productData.attributes || [];
|
||||
const mainImage = product.pictureList && product.pictureList.trim()
|
||||
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.jpg`
|
||||
: '/assets/images/nopicture.jpg';
|
||||
|
||||
// Format price with tax
|
||||
const priceWithTax = new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(product.price);
|
||||
|
||||
return React.createElement(
|
||||
Box,
|
||||
@@ -53,137 +68,497 @@ class PrerenderProduct extends React.Component {
|
||||
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
|
||||
React.createElement(
|
||||
Toolbar,
|
||||
{ sx: { minHeight: 64 } },
|
||||
{ sx: { minHeight: 64, py: { xs: 0.5, sm: 0 } } },
|
||||
React.createElement(
|
||||
Container,
|
||||
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center' } },
|
||||
React.createElement(Logo)
|
||||
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center', px: { xs: 0, sm: 3 } } },
|
||||
// Desktop: simple layout, Mobile: column layout with SearchBar space
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
flexDirection: { xs: 'column', sm: 'row' }
|
||||
}
|
||||
},
|
||||
// First row: Logo and invisible placeholders to match SPA layout
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
justifyContent: { xs: 'space-between', sm: 'flex-start' }, // Match SPA layout
|
||||
minHeight: { xs: 52, sm: 'auto' },
|
||||
px: { xs: 0, sm: 0 }
|
||||
}
|
||||
},
|
||||
React.createElement(Logo),
|
||||
// Invisible SearchBar placeholder on desktop to match SPA spacing
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
flexGrow: 1,
|
||||
mx: { xs: 0, sm: 2, md: 4 },
|
||||
visibility: 'hidden',
|
||||
height: 40 // Match SearchBar height
|
||||
}
|
||||
}
|
||||
),
|
||||
// Invisible ButtonGroup placeholder to match SPA spacing
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: { xs: 'flex', sm: 'flex' },
|
||||
alignItems: { xs: 'flex-end', sm: 'center' },
|
||||
transform: { xs: 'translateY(4px) translateX(9px)', sm: 'none' },
|
||||
ml: { xs: 0, sm: 0 },
|
||||
visibility: 'hidden',
|
||||
width: { xs: 'auto', sm: '120px' }, // Approximate ButtonGroup width
|
||||
height: 40
|
||||
}
|
||||
}
|
||||
)
|
||||
),
|
||||
// Second row: SearchBar placeholder only on mobile
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
width: '100%',
|
||||
mt: { xs: 1, sm: 0 },
|
||||
mb: { xs: 0.5, sm: 0 },
|
||||
px: { xs: 0, sm: 0 },
|
||||
height: 41, // Small TextField height
|
||||
visibility: 'hidden'
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { flexGrow: 1 } },
|
||||
React.createElement(
|
||||
Container,
|
||||
{ maxWidth: 'lg', sx: { py: 4, flexGrow: 1 } },
|
||||
React.createElement(
|
||||
Grid,
|
||||
{ container: true, spacing: 4 },
|
||||
// Product Image
|
||||
React.createElement(
|
||||
Grid,
|
||||
{ item: true, xs: 12, md: 6 },
|
||||
React.createElement(
|
||||
Card,
|
||||
{ sx: { height: '100%' } },
|
||||
React.createElement(
|
||||
CardMedia,
|
||||
{
|
||||
component: 'img',
|
||||
height: '400',
|
||||
image: mainImage,
|
||||
alt: product.name,
|
||||
sx: { objectFit: 'contain', p: 2 }
|
||||
maxWidth: "lg",
|
||||
sx: {
|
||||
p: { xs: 2, md: 2 },
|
||||
pb: { xs: 4, md: 8 },
|
||||
flexGrow: 1
|
||||
}
|
||||
},
|
||||
// Back button (breadcrumbs section)
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
mb: 2,
|
||||
position: ["-webkit-sticky", "sticky"],
|
||||
top: {
|
||||
xs: "80px",
|
||||
sm: "80px",
|
||||
md: "80px",
|
||||
lg: "80px",
|
||||
},
|
||||
left: 0,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
zIndex: 999, // Just below the AppBar
|
||||
py: 0,
|
||||
px: 2,
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
ml: { xs: 0, md: 0 },
|
||||
display: "inline-flex",
|
||||
px: 0,
|
||||
py: 1,
|
||||
backgroundColor: "#2e7d32", // primary dark green
|
||||
borderRadius: 1,
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: "body2", color: "text.secondary" },
|
||||
React.createElement(
|
||||
'a',
|
||||
{
|
||||
href: "#",
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
},
|
||||
style: {
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
textDecoration: "none",
|
||||
color: "#fff",
|
||||
fontWeight: "bold",
|
||||
cursor: "pointer"
|
||||
}
|
||||
},
|
||||
this.props.t ? this.props.t('common.back') : 'Zurück'
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", md: "row" },
|
||||
gap: 4,
|
||||
background: "#fff",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
}
|
||||
},
|
||||
// Product Image Section
|
||||
React.createElement(
|
||||
ProductImage,
|
||||
{
|
||||
product: product,
|
||||
socket: null,
|
||||
socketB: null,
|
||||
fullscreenOpen: false,
|
||||
onOpenFullscreen: null,
|
||||
onCloseFullscreen: null
|
||||
}
|
||||
),
|
||||
// Product Details Section
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
flex: "1 1 60%",
|
||||
p: { xs: 2, md: 4 },
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}
|
||||
},
|
||||
// Product identifiers
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { mb: 1 } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'body2', color: 'text.secondary' },
|
||||
(this.props.t ? this.props.t('product.articleNumber') : 'Artikelnummer')+': '+product.articleNumber+' '+(product.gtin ? ` | GTIN: ${product.gtin}` : "")
|
||||
)
|
||||
),
|
||||
// Product title
|
||||
React.createElement(
|
||||
Typography,
|
||||
{
|
||||
variant: 'h4',
|
||||
component: 'h1',
|
||||
gutterBottom: true,
|
||||
sx: {
|
||||
fontWeight: 600,
|
||||
color: "#333"
|
||||
}
|
||||
},
|
||||
cleanProductName(product.name)
|
||||
),
|
||||
// Manufacturer if available - exact match to SPA: only render Box if manufacturer exists
|
||||
product.manufacturer && React.createElement(
|
||||
Box,
|
||||
{ sx: { display: "flex", alignItems: "center", mb: 2 } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'body2', sx: { fontStyle: "italic" } },
|
||||
(this.props.t ? this.props.t('product.manufacturer') : 'Hersteller')+': '+product.manufacturer
|
||||
)
|
||||
),
|
||||
// Attribute images and chips with action buttons section - exact replica of SPA version
|
||||
// SPA condition: (attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert]))
|
||||
// This essentially means "if there are any attributes at all"
|
||||
// For products with no attributes (like Vakuumbeutel), this section should NOT render
|
||||
(attributes.length > 0) && React.createElement(
|
||||
Box,
|
||||
{ sx: { display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2, gap: 2 } },
|
||||
// Left side - attributes
|
||||
React.createElement(
|
||||
Stack,
|
||||
{ direction: 'row', spacing: 2, sx: { flexWrap: "wrap", gap: 1, flex: 1 } },
|
||||
// In prerender: attributes.filter(attribute => attributeImages[attribute.kMerkmalWert]) = [] (empty)
|
||||
// Then: attributes.filter(attribute => !attributeImages[attribute.kMerkmalWert]) = all attributes as Chips
|
||||
...attributes.map((attribute, index) =>
|
||||
React.createElement(
|
||||
Chip,
|
||||
{
|
||||
key: attribute.kMerkmalWert || index,
|
||||
label: attribute.cWert,
|
||||
disabled: true,
|
||||
sx: { mb: 1 }
|
||||
}
|
||||
)
|
||||
)
|
||||
),
|
||||
// Product Details
|
||||
React.createElement(
|
||||
Grid,
|
||||
{ item: true, xs: 12, md: 6 },
|
||||
// Right side - action buttons (exact replica with invisible versions)
|
||||
React.createElement(
|
||||
Stack,
|
||||
{ spacing: 3 },
|
||||
{ direction: 'column', spacing: 1, sx: { flexShrink: 0 } },
|
||||
// "Frage zum Artikel" button - exact replica but invisible
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h3', component: 'h1', gutterBottom: true },
|
||||
product.name
|
||||
Button,
|
||||
{
|
||||
variant: "outlined",
|
||||
size: "small",
|
||||
sx: {
|
||||
fontSize: "0.75rem",
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
minWidth: "auto",
|
||||
whiteSpace: "nowrap",
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none"
|
||||
}
|
||||
},
|
||||
"Frage zum Artikel"
|
||||
),
|
||||
// "Artikel Bewerten" button - exact replica but invisible
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h6', color: 'text.secondary' },
|
||||
'Artikelnummer: '+product.articleNumber+' '+(product.gtin ? ` | GTIN: ${product.gtin}` : "")
|
||||
Button,
|
||||
{
|
||||
variant: "outlined",
|
||||
size: "small",
|
||||
sx: {
|
||||
fontSize: "0.75rem",
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
minWidth: "auto",
|
||||
whiteSpace: "nowrap",
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none"
|
||||
}
|
||||
},
|
||||
"Artikel Bewerten"
|
||||
),
|
||||
React.createElement(
|
||||
// "Verfügbarkeit anfragen" button - conditional, exact replica but invisible
|
||||
(product.available !== 1 && product.availableSupplier !== 1) && React.createElement(
|
||||
Button,
|
||||
{
|
||||
variant: "outlined",
|
||||
size: "small",
|
||||
sx: {
|
||||
fontSize: "0.75rem",
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
minWidth: "auto",
|
||||
whiteSpace: "nowrap",
|
||||
borderColor: "warning.main",
|
||||
color: "warning.main",
|
||||
"&:hover": {
|
||||
borderColor: "warning.dark",
|
||||
backgroundColor: "warning.light"
|
||||
},
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none"
|
||||
}
|
||||
},
|
||||
"Verfügbarkeit anfragen"
|
||||
)
|
||||
)
|
||||
),
|
||||
// Weight
|
||||
(product.weight && product.weight > 0) ? React.createElement(
|
||||
Box,
|
||||
{ sx: { mt: 1 } },
|
||||
{ sx: { mb: 2 } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h4', color: 'primary', fontWeight: 'bold' },
|
||||
new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(product.price)
|
||||
),
|
||||
product.vat && React.createElement(
|
||||
Typography,
|
||||
{ variant: 'body2', color: 'text.secondary' },
|
||||
`inkl. ${product.vat}% MwSt.`
|
||||
),
|
||||
(this.props.t ? this.props.t('product.weight', { weight: product.weight.toFixed(1).replace(".", ",") }) : `Gewicht: ${product.weight.toFixed(1).replace(".", ",")} kg`)
|
||||
)
|
||||
) : null,
|
||||
// Price and availability section
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
mt: "auto",
|
||||
transform: "translateY(-1px)", // Move 1px up
|
||||
p: 3,
|
||||
background: "#f9f9f9",
|
||||
borderRadius: 2,
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
justifyContent: "space-between",
|
||||
alignItems: { xs: "flex-start", sm: "flex-start" },
|
||||
gap: 2,
|
||||
}
|
||||
},
|
||||
// Left side - Price information (exact match to SPA)
|
||||
React.createElement(
|
||||
Box,
|
||||
null,
|
||||
React.createElement(
|
||||
Typography,
|
||||
{
|
||||
variant: 'body1',
|
||||
color: product.available ? 'success.main' : 'error.main',
|
||||
fontWeight: 'medium',
|
||||
sx: { mt: 1 }
|
||||
variant: "h4",
|
||||
color: "primary",
|
||||
sx: { fontWeight: "bold" }
|
||||
},
|
||||
product.available ? '✅ Verfügbar' : '❌ Nicht verfügbar'
|
||||
)
|
||||
priceWithTax
|
||||
),
|
||||
product.description && React.createElement(
|
||||
Box,
|
||||
{ sx: { mt: 2 } },
|
||||
// VAT info (exact match to SPA - direct Typography, no wrapper)
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h6', gutterBottom: true },
|
||||
'Beschreibung'
|
||||
{ variant: 'body2', color: 'text.secondary' },
|
||||
(this.props.t ? this.props.t('product.inclVat', { vat: product.vat }) : `inkl. ${product.vat}% MwSt.`) +
|
||||
(product.cGrundEinheit && product.fGrundPreis ?
|
||||
`; ${new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(product.fGrundPreis)}/${product.cGrundEinheit}` :
|
||||
"")
|
||||
),
|
||||
// Shipping class (exact match to SPA - direct Typography, conditional render)
|
||||
product.versandklasse &&
|
||||
product.versandklasse != "standard" &&
|
||||
product.versandklasse != "kostenlos" && React.createElement(
|
||||
Typography,
|
||||
{ variant: 'body2', color: 'text.secondary' },
|
||||
product.versandklasse
|
||||
)
|
||||
),
|
||||
// Right side - Complex cart button area structure (matching SPA exactly)
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
gap: 2,
|
||||
alignItems: "flex-start",
|
||||
}
|
||||
},
|
||||
// Empty steckling column placeholder - maintains flex positioning
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { display: "flex", flexDirection: "column" } }
|
||||
// Empty - no steckling for this product
|
||||
),
|
||||
// Main cart button column (exact match to SPA structure)
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}
|
||||
},
|
||||
// AddToCartButton placeholder - invisible button that reserves exact space
|
||||
React.createElement(
|
||||
Button,
|
||||
{
|
||||
variant: "contained",
|
||||
size: "large",
|
||||
sx: {
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none",
|
||||
height: "36px",
|
||||
width: "140px",
|
||||
minWidth: "140px",
|
||||
maxWidth: "140px"
|
||||
}
|
||||
},
|
||||
"In den Warenkorb"
|
||||
),
|
||||
// Delivery time Typography (exact match to SPA)
|
||||
React.createElement(
|
||||
Typography,
|
||||
{
|
||||
variant: 'caption',
|
||||
sx: {
|
||||
fontStyle: "italic",
|
||||
color: "text.secondary",
|
||||
textAlign: "center",
|
||||
mt: 1
|
||||
}
|
||||
},
|
||||
product.id && product.id.toString().endsWith("steckling") ?
|
||||
(this.props.t ? this.props.t('delivery.times.cutting14Days') : "Lieferzeit: 14 Tage") :
|
||||
product.available == 1 ?
|
||||
(this.props.t ? this.props.t('delivery.times.standard2to3Days') : "Lieferzeit: 2-3 Tage") :
|
||||
product.availableSupplier == 1 ?
|
||||
(this.props.t ? this.props.t('delivery.times.supplier7to9Days') : "Lieferzeit: 7-9 Tage") :
|
||||
""
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
// Product full description - separate card
|
||||
product.description && React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
mt: 4,
|
||||
p: 4,
|
||||
background: "#fff",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
mt: 2,
|
||||
lineHeight: 1.7,
|
||||
"& p": { mt: 0, mb: 2 },
|
||||
"& strong": { fontWeight: 600 },
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
'div',
|
||||
{
|
||||
dangerouslySetInnerHTML: { __html: product.description },
|
||||
dangerouslySetInnerHTML: {
|
||||
__html: sanitizeHtml(product.description, {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
|
||||
allowedAttributes: {
|
||||
'*': ['class', 'style'],
|
||||
'a': ['href', 'title'],
|
||||
'img': ['src', 'alt', 'width', 'height']
|
||||
},
|
||||
disallowedTagsMode: 'discard'
|
||||
})
|
||||
},
|
||||
style: {
|
||||
fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.5',
|
||||
color: '#33691E'
|
||||
lineHeight: '1.7',
|
||||
color: '#333'
|
||||
}
|
||||
}
|
||||
)
|
||||
),
|
||||
// Product specifications
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { mt: 2 } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h6', gutterBottom: true },
|
||||
'Produktdetails'
|
||||
),
|
||||
React.createElement(
|
||||
Stack,
|
||||
{ direction: 'row', spacing: 1, flexWrap: 'wrap', gap: 1 },
|
||||
product.manufacturer && React.createElement(
|
||||
Chip,
|
||||
{ label: `Hersteller: ${product.manufacturer}`, variant: 'outlined' }
|
||||
),
|
||||
product.weight && product.weight > 0 && React.createElement(
|
||||
Chip,
|
||||
{ label: `Gewicht: ${product.weight} kg`, variant: 'outlined' }
|
||||
),
|
||||
...attributes.map((attr, index) =>
|
||||
React.createElement(
|
||||
Chip,
|
||||
{
|
||||
key: index,
|
||||
label: `${attr.cName}: ${attr.cWert}`,
|
||||
variant: 'outlined',
|
||||
color: 'primary'
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -193,4 +568,4 @@ class PrerenderProduct extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { default: PrerenderProduct };
|
||||
export default PrerenderProduct;
|
||||
@@ -1,17 +1,11 @@
|
||||
const React = require('react');
|
||||
const {
|
||||
Box,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Container,
|
||||
import React from 'react';
|
||||
import {
|
||||
Typography,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText
|
||||
} = require('@mui/material');
|
||||
const Footer = require('./components/Footer.js').default;
|
||||
const { Logo, CategoryList } = require('./components/header/index.js');
|
||||
const LegalPage = require('./pages/LegalPage.js').default;
|
||||
} from '@mui/material';
|
||||
import LegalPage from './pages/LegalPage.js';
|
||||
|
||||
const PrerenderSitemap = ({ categoryData }) => {
|
||||
// Process category data to flatten the hierarchy
|
||||
@@ -134,4 +128,4 @@ const PrerenderSitemap = ({ categoryData }) => {
|
||||
return React.createElement(LegalPage, { title: 'Sitemap', content: content });
|
||||
};
|
||||
|
||||
module.exports = { default: PrerenderSitemap };
|
||||
export default PrerenderSitemap;
|
||||
@@ -10,6 +10,7 @@ import AddIcon from "@mui/icons-material/Add";
|
||||
import RemoveIcon from "@mui/icons-material/Remove";
|
||||
import ShoppingCartIcon from "@mui/icons-material/ShoppingCart";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import { withI18n } from "../i18n/withTranslation.js";
|
||||
|
||||
if (!Array.isArray(window.cart)) window.cart = [];
|
||||
|
||||
@@ -51,11 +52,14 @@ class AddToCartButton extends Component {
|
||||
seoName: this.props.seoName,
|
||||
pictureList: this.props.pictureList,
|
||||
price: this.props.price,
|
||||
fGrundPreis: this.props.fGrundPreis,
|
||||
cGrundEinheit: this.props.cGrundEinheit,
|
||||
quantity: 1,
|
||||
weight: this.props.weight,
|
||||
vat: this.props.vat,
|
||||
versandklasse: this.props.versandklasse,
|
||||
availableSupplier: this.props.availableSupplier,
|
||||
komponenten: this.props.komponenten,
|
||||
available: this.props.available
|
||||
});
|
||||
} else {
|
||||
@@ -150,12 +154,17 @@ class AddToCartButton extends Component {
|
||||
},
|
||||
}}
|
||||
>
|
||||
Ab{" "}
|
||||
{new Date(incoming).toLocaleDateString("de-DE", {
|
||||
{this.props.t ? this.props.t('cart.availableFrom', {
|
||||
date: new Date(incoming).toLocaleDateString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
})
|
||||
}) : `Ab ${new Date(incoming).toLocaleDateString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}`}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -181,7 +190,9 @@ class AddToCartButton extends Component {
|
||||
},
|
||||
}}
|
||||
>
|
||||
{this.props.steckling ? "Als Steckling vorbestellen" : "In den Korb"}
|
||||
{this.props.steckling ?
|
||||
(this.props.t ? this.props.t('cart.preorderCutting') : "Als Steckling vorbestellen") :
|
||||
(this.props.t ? this.props.t('cart.addToCart') : "In den Korb")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -205,6 +216,7 @@ class AddToCartButton extends Component {
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleDecrement}
|
||||
aria-label="Menge verringern"
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<RemoveIcon />
|
||||
@@ -254,15 +266,17 @@ class AddToCartButton extends Component {
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleIncrement}
|
||||
aria-label="Menge erhöhen"
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
|
||||
<Tooltip title="Aus dem Warenkorb entfernen" arrow>
|
||||
<Tooltip title={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'} arrow>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleClearCart}
|
||||
aria-label={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "error.light" },
|
||||
@@ -272,10 +286,11 @@ class AddToCartButton extends Component {
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{this.props.cartButton && (
|
||||
<Tooltip title="Warenkorb öffnen" arrow>
|
||||
<Tooltip title={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'} arrow>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.toggleCart}
|
||||
aria-label={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "primary.light" },
|
||||
@@ -302,7 +317,7 @@ class AddToCartButton extends Component {
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
Out of Stock
|
||||
{this.props.t ? this.props.t('product.outOfStock') : 'Out of Stock'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -327,7 +342,9 @@ class AddToCartButton extends Component {
|
||||
},
|
||||
}}
|
||||
>
|
||||
{this.props.steckling ? "Als Steckling vorbestellen" : "In den Korb"}
|
||||
{this.props.steckling ?
|
||||
(this.props.t ? this.props.t('cart.preorderCutting') : "Als Steckling vorbestellen") :
|
||||
(this.props.t ? this.props.t('cart.addToCart') : "In den Korb")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -350,6 +367,7 @@ class AddToCartButton extends Component {
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleDecrement}
|
||||
aria-label="Menge verringern"
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<RemoveIcon />
|
||||
@@ -399,15 +417,17 @@ class AddToCartButton extends Component {
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleIncrement}
|
||||
aria-label="Menge erhöhen"
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
|
||||
<Tooltip title="Aus dem Warenkorb entfernen" arrow>
|
||||
<Tooltip title={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'} arrow>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleClearCart}
|
||||
aria-label={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "error.light" },
|
||||
@@ -417,10 +437,11 @@ class AddToCartButton extends Component {
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{this.props.cartButton && (
|
||||
<Tooltip title="Warenkorb öffnen" arrow>
|
||||
<Tooltip title={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'} arrow>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.toggleCart}
|
||||
aria-label={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "primary.light" },
|
||||
@@ -436,4 +457,4 @@ class AddToCartButton extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default AddToCartButton;
|
||||
export default withI18n()(AddToCartButton);
|
||||
|
||||
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);
|
||||
@@ -8,6 +8,7 @@ import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import CartItem from './CartItem.js';
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
|
||||
|
||||
class CartDropdown extends Component {
|
||||
@@ -53,8 +54,8 @@ class CartDropdown extends Component {
|
||||
currency: 'EUR'
|
||||
});
|
||||
|
||||
const shippingNetPrice = deliveryCost / (1 + 19 / 100);
|
||||
const shippingVat = deliveryCost - shippingNetPrice;
|
||||
const shippingNetPrice = deliveryCost > 0 ? deliveryCost / (1 + 19 / 100) : 0;
|
||||
const shippingVat = deliveryCost > 0 ? deliveryCost - shippingNetPrice : 0;
|
||||
const totalVat7 = priceCalculations.vat7;
|
||||
const totalVat19 = priceCalculations.vat19 + shippingVat;
|
||||
const totalGross = priceCalculations.totalGross + deliveryCost;
|
||||
@@ -63,7 +64,7 @@ class CartDropdown extends Component {
|
||||
<>
|
||||
<Box sx={{ bgcolor: 'primary.main', color: 'white', p: 2 }}>
|
||||
<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>
|
||||
</Box>
|
||||
|
||||
@@ -73,7 +74,6 @@ class CartDropdown extends Component {
|
||||
{cartItems.map((item) => (
|
||||
<CartItem
|
||||
key={item.id}
|
||||
socket={this.props.socket}
|
||||
item={item}
|
||||
id={item.id}
|
||||
/>
|
||||
@@ -83,7 +83,7 @@ class CartDropdown extends Component {
|
||||
{/* Display total weight if greater than 0 */}
|
||||
{totalWeight > 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -94,7 +94,7 @@ class CartDropdown extends Component {
|
||||
// Detailed summary with shipping costs
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
Bestellübersicht
|
||||
{this.props.t ? this.props.t('cart.summary.title') : 'Bestellübersicht'}
|
||||
</Typography>
|
||||
{deliveryMethod && (
|
||||
<Typography variant="body2" sx={{ mb: 1, color: 'text.secondary' }}>
|
||||
@@ -104,14 +104,14 @@ class CartDropdown extends Component {
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Waren (netto):</TableCell>
|
||||
<TableCell>{this.props.t ? this.props.t('cart.summary.goodsNet') : 'Waren (netto):'}</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(priceCalculations.totalNet)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{deliveryCost > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>Versandkosten (netto):</TableCell>
|
||||
<TableCell>{this.props.t ? this.props.t('cart.summary.shippingNet') : 'Versandkosten (netto):'}</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(shippingNetPrice)}
|
||||
</TableCell>
|
||||
@@ -119,7 +119,7 @@ class CartDropdown extends Component {
|
||||
)}
|
||||
{totalVat7 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>7% Mehrwertsteuer:</TableCell>
|
||||
<TableCell>{this.props.t ? this.props.t('tax.vat7') : '7% Mehrwertsteuer'}:</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(totalVat7)}
|
||||
</TableCell>
|
||||
@@ -127,28 +127,37 @@ class CartDropdown extends Component {
|
||||
)}
|
||||
{totalVat19 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>19% Mehrwertsteuer (inkl. Versand):</TableCell>
|
||||
<TableCell>{this.props.t ? this.props.t('tax.vat19WithShipping') : '19% Mehrwertsteuer (inkl. Versand)'}:</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(totalVat19)}
|
||||
</TableCell>
|
||||
</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' }}>
|
||||
{currencyFormatter.format(priceCalculations.totalGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{deliveryCost > 0 && (
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Versandkosten:</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>
|
||||
{this.props.t ? this.props.t('cart.summary.shippingCosts') : 'Versandkosten:'}
|
||||
{deliveryCost === 0 && priceCalculations.totalGross < 100 && (
|
||||
<span style={{ color: '#2e7d32', fontSize: '0.8em', marginLeft: '4px' }}>
|
||||
{this.props.t ? this.props.t('cart.summary.freeFrom100') : '(kostenlos ab 100€)'}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{currencyFormatter.format(deliveryCost)}
|
||||
{deliveryCost === 0 ? (
|
||||
<span style={{ color: '#2e7d32' }}>{this.props.t ? this.props.t('cart.summary.free') : 'kostenlos'}</span>
|
||||
) : (
|
||||
currencyFormatter.format(deliveryCost)
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<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 }}>
|
||||
{currencyFormatter.format(totalGross)}
|
||||
</TableCell>
|
||||
@@ -161,14 +170,14 @@ class CartDropdown extends Component {
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Gesamtnettopreis:</TableCell>
|
||||
<TableCell>{this.props.t ? this.props.t('tax.totalNet') : 'Gesamtnettopreis'}:</TableCell>
|
||||
<TableCell align="right">
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.totalNet)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{priceCalculations.vat7 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>7% Mehrwertsteuer:</TableCell>
|
||||
<TableCell>{this.props.t ? this.props.t('tax.vat7') : '7% Mehrwertsteuer'}:</TableCell>
|
||||
<TableCell align="right">
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.vat7)}
|
||||
</TableCell>
|
||||
@@ -176,14 +185,14 @@ class CartDropdown extends Component {
|
||||
)}
|
||||
{priceCalculations.vat19 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>19% Mehrwertsteuer:</TableCell>
|
||||
<TableCell>{this.props.t ? this.props.t('tax.vat19') : '19% Mehrwertsteuer'}:</TableCell>
|
||||
<TableCell align="right">
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.vat19)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtbruttopreis ohne Versand:</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>{this.props.t ? this.props.t('tax.totalGross') : 'Gesamtbruttopreis ohne Versand'}:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.totalGross)}
|
||||
</TableCell>
|
||||
@@ -201,7 +210,7 @@ class CartDropdown extends Component {
|
||||
fullWidth
|
||||
onClick={onClose}
|
||||
>
|
||||
Weiter einkaufen
|
||||
{this.props.t ? this.props.t('cart.continueShopping') : 'Weiter einkaufen'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -213,7 +222,7 @@ class CartDropdown extends Component {
|
||||
sx={{ mt: 2 }}
|
||||
onClick={onCheckout}
|
||||
>
|
||||
Weiter zur Kasse
|
||||
{this.props.t ? this.props.t('cart.proceedToCheckout') : 'Weiter zur Kasse'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
@@ -223,4 +232,4 @@ class CartDropdown extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default CartDropdown;
|
||||
export default withI18n()(CartDropdown);
|
||||
@@ -6,6 +6,7 @@ import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import { Link } from 'react-router-dom';
|
||||
import AddToCartButton from './AddToCartButton.js';
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
|
||||
class CartItem extends Component {
|
||||
|
||||
@@ -19,17 +20,16 @@ class CartItem extends Component {
|
||||
this.setState({image:window.tinyPicCache[picid],loading:false, error: false})
|
||||
}else{
|
||||
this.setState({image: null, loading: true, error: false});
|
||||
if(this.props.socket){
|
||||
this.props.socket.emit('getPic', { bildId:picid, size:'tiny' }, (res) => {
|
||||
|
||||
window.socketManager.emit('getPic', { bildId:picid, size:'tiny' }, (res) => {
|
||||
if(res.success){
|
||||
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
|
||||
this.setState({image: window.tinyPicCache[picid], loading: false});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleIncrement = () => {
|
||||
const { item, onQuantityChange } = this.props;
|
||||
@@ -75,11 +75,25 @@ class CartItem extends Component {
|
||||
component="div"
|
||||
sx={{ fontWeight: 'bold', mb: 0.5 }}
|
||||
>
|
||||
{item.seoName ? (
|
||||
<Link to={`/Artikel/${item.seoName}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
{item.name}
|
||||
</Link>
|
||||
) : (
|
||||
item.name
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
{item.komponenten && Array.isArray(item.komponenten) && (
|
||||
<Box sx={{ ml: 2, mb: 1 }}>
|
||||
{item.komponenten.map((comp, index) => (
|
||||
<Typography key={index} variant="body2" color="text.secondary">
|
||||
{comp.name}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1, mt: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
@@ -116,7 +130,7 @@ class CartItem extends Component {
|
||||
)}
|
||||
{item.versandklasse && item.versandklasse != 'standard' && item.versandklasse != 'kostenlos' && (
|
||||
<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>
|
||||
)}
|
||||
{item.vat && (
|
||||
@@ -126,9 +140,9 @@ class CartItem extends Component {
|
||||
fontStyle="italic"
|
||||
component="div"
|
||||
>
|
||||
inkl. {new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(
|
||||
{this.props.t ? this.props.t('product.inclShort') : 'inkl.'} {new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(
|
||||
(item.price * item.quantity) - ((item.price * item.quantity) / (1 + item.vat / 100))
|
||||
)} MwSt. ({item.vat}%)
|
||||
)} {this.props.t ? this.props.t('product.vatShort') : 'MwSt.'} ({item.vat}%)
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -146,11 +160,14 @@ class CartItem extends Component {
|
||||
display: "block"
|
||||
}}
|
||||
>
|
||||
{this.props.id.toString().endsWith("steckling") ? "Lieferzeit: 14 Tage" :
|
||||
item.available == 1 ? "Lieferzeit: 2-3 Tage" :
|
||||
item.availableSupplier == 1 ? "Lieferzeit: 7-9 Tage" : ""}
|
||||
{this.props.id?.toString().endsWith("steckling") ?
|
||||
(this.props.t ? this.props.t('delivery.times.cutting14Days') : "Lieferzeit: 14 Tage") :
|
||||
item.available == 1 ?
|
||||
(this.props.t ? this.props.t('delivery.times.standard2to3Days') : "Lieferzeit: 2-3 Tage") :
|
||||
item.availableSupplier == 1 ?
|
||||
(this.props.t ? this.props.t('delivery.times.supplier7to9Days') : "Lieferzeit: 7-9 Tage") : ""}
|
||||
</Typography>
|
||||
<AddToCartButton available={1} id={this.props.id} availableSupplier={item.availableSupplier} price={item.price} seoName={item.seoName} name={item.name} weight={item.weight} vat={item.vat} versandklasse={item.versandklasse}/>
|
||||
<AddToCartButton available={1} id={this.props.id} komponenten={item.komponenten} availableSupplier={item.availableSupplier} price={item.price} seoName={item.seoName} name={item.name} weight={item.weight} vat={item.vat} versandklasse={item.versandklasse}/>
|
||||
</Box>
|
||||
</Box>
|
||||
</ListItem>
|
||||
@@ -159,4 +176,4 @@ class CartItem extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default 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 Paper from '@mui/material/Paper';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SocketContext from '../contexts/SocketContext.js';
|
||||
|
||||
|
||||
// @note SwashingtonCP font is now loaded globally via index.css
|
||||
|
||||
@@ -16,13 +16,13 @@ const CategoryBox = ({
|
||||
name,
|
||||
seoName,
|
||||
bgcolor,
|
||||
fontSize = '0.8rem',
|
||||
fontSize = '1.2rem',
|
||||
...props
|
||||
}) => {
|
||||
const [imageUrl, setImageUrl] = useState(null);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const context = useContext(SocketContext);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl = null;
|
||||
@@ -47,7 +47,7 @@ const CategoryBox = ({
|
||||
// Create fresh blob URL from cached binary data
|
||||
try {
|
||||
const uint8Array = new Uint8Array(cachedImageData);
|
||||
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
|
||||
const blob = new Blob([uint8Array], { type: 'image/avif' });
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageUrl(objectUrl);
|
||||
setImageError(false);
|
||||
@@ -60,11 +60,10 @@ const CategoryBox = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// If socket is available and connected, fetch the image
|
||||
if (context && context.socket && context.socket.connected && id && !isLoading) {
|
||||
if (id && !isLoading) {
|
||||
setIsLoading(true);
|
||||
|
||||
context.socket.emit('getCategoryPic', { categoryId: id }, (response) => {
|
||||
window.socketManager.emit('getCategoryPic', { categoryId: id }, (response) => {
|
||||
setIsLoading(false);
|
||||
|
||||
if (response.success) {
|
||||
@@ -74,7 +73,7 @@ const CategoryBox = ({
|
||||
try {
|
||||
// Convert binary data to blob URL
|
||||
const uint8Array = new Uint8Array(imageData);
|
||||
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
|
||||
const blob = new Blob([uint8Array], { type: 'image/avif' });
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageUrl(objectUrl);
|
||||
setImageError(false);
|
||||
@@ -119,7 +118,7 @@ const CategoryBox = ({
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [context, context?.socket?.connected, id, isLoading]);
|
||||
}, [id, isLoading]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -159,7 +158,7 @@ const CategoryBox = ({
|
||||
position: 'relative',
|
||||
backgroundImage: ((typeof window !== 'undefined' && window.__PRERENDER_FALLBACK__) ||
|
||||
(typeof global !== 'undefined' && global.window && global.window.__PRERENDER_FALLBACK__))
|
||||
? `url("/assets/images/cat${id}.jpg")`
|
||||
? `url("/assets/images/cat${id}.avif")`
|
||||
: (imageUrl && !imageError ? `url("${imageUrl}")` : 'none'),
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
@@ -186,7 +185,7 @@ const CategoryBox = ({
|
||||
fontFamily: 'SwashingtonCP, "Times New Roman", Georgia, serif',
|
||||
fontWeight: 'normal',
|
||||
lineHeight: '1.2',
|
||||
padding: '0 8px'
|
||||
padding: '12px 8px'
|
||||
}}>
|
||||
{name}
|
||||
</div>
|
||||
|
||||
@@ -81,18 +81,6 @@ class ChatAssistant extends Component {
|
||||
if (prevState.messages !== this.state.messages || prevState.isTyping !== this.state.isTyping) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected) {
|
||||
// Socket just connected, add listeners
|
||||
this.addSocketListeners();
|
||||
} else if (wasConnected && !isNowConnected) {
|
||||
// Socket just disconnected, remove listeners
|
||||
this.removeSocketListeners();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -104,19 +92,18 @@ class ChatAssistant extends Component {
|
||||
}
|
||||
|
||||
addSocketListeners = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
// Remove existing listeners first to avoid duplicates
|
||||
|
||||
this.removeSocketListeners();
|
||||
this.props.socket.on('aiassyResponse', this.handleBotResponse);
|
||||
this.props.socket.on('aiassyStatus', this.handleStateResponse);
|
||||
}
|
||||
window.socketManager.on('aiassyResponse', this.handleBotResponse);
|
||||
window.socketManager.on('aiassyStatus', this.handleStateResponse);
|
||||
|
||||
}
|
||||
|
||||
removeSocketListeners = () => {
|
||||
if (this.props.socket) {
|
||||
this.props.socket.off('aiassyResponse', this.handleBotResponse);
|
||||
this.props.socket.off('aiassyStatus', this.handleStateResponse);
|
||||
}
|
||||
|
||||
window.socketManager.off('aiassyResponse', this.handleBotResponse);
|
||||
window.socketManager.off('aiassyStatus', this.handleStateResponse);
|
||||
|
||||
}
|
||||
|
||||
handleBotResponse = (msgId,response) => {
|
||||
@@ -194,8 +181,8 @@ class ChatAssistant extends Component {
|
||||
};
|
||||
}, () => {
|
||||
// Emit message to socket server after state is updated
|
||||
if (userMessage.trim() && this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('aiassyMessage', userMessage);
|
||||
if (userMessage.trim()) {
|
||||
window.socketManager.emit('aiassyMessage', userMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -300,12 +287,10 @@ class ChatAssistant extends Component {
|
||||
reader.onloadend = () => {
|
||||
const base64Audio = reader.result.split(',')[1];
|
||||
// Send audio data to server
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('aiassyAudioMessage', {
|
||||
window.socketManager.emit('aiassyAudioMessage', {
|
||||
audio: base64Audio,
|
||||
format: 'wav'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -389,12 +374,12 @@ class ChatAssistant extends Component {
|
||||
reader.onloadend = () => {
|
||||
const base64Image = reader.result.split(',')[1];
|
||||
// Send image data to server
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('aiassyPicMessage', {
|
||||
|
||||
window.socketManager.emit('aiassyPicMessage', {
|
||||
image: base64Image,
|
||||
format: 'jpeg'
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
};
|
||||
|
||||
@@ -480,16 +465,16 @@ class ChatAssistant extends Component {
|
||||
elevation={4}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: { xs: 16, sm: 80 },
|
||||
right: { xs: 16, sm: 16 },
|
||||
left: { xs: 16, sm: 'auto' },
|
||||
top: { xs: 16, sm: 'auto' },
|
||||
width: { xs: 'calc(100vw - 32px)', sm: 450, md: 600, lg: 750 },
|
||||
height: { xs: 'calc(100vh - 32px)', sm: 600, md: 650, lg: 700 },
|
||||
bottom: { xs: 0, sm: 80 },
|
||||
right: { xs: 0, sm: 16 },
|
||||
left: { xs: 0, sm: 'auto' },
|
||||
top: { xs: 0, sm: 'auto' },
|
||||
width: { xs: '100vw', sm: 450, md: 600, lg: 750 },
|
||||
height: { xs: '100vh', sm: 600, md: 650, lg: 700 },
|
||||
maxWidth: { xs: 'none', sm: 450, md: 600, lg: 750 },
|
||||
maxHeight: { xs: 'calc(100vh - 72px)', sm: 600, md: 650, lg: 700 },
|
||||
maxHeight: { xs: '100vh', sm: 600, md: 650, lg: 700 },
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
borderRadius: { xs: 0, sm: 2 },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: 1300,
|
||||
@@ -518,7 +503,7 @@ class ChatAssistant extends Component {
|
||||
<Typography component="span" color={this.state.atDatabase ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🛢</Typography>
|
||||
<Typography component="span" color={this.state.atWeb ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🌐</Typography>
|
||||
</Typography>
|
||||
<IconButton onClick={onClose} size="small" sx={{ color: 'primary.contrastText' }}>
|
||||
<IconButton onClick={onClose} size="small" aria-label="Assistent schließen" sx={{ color: 'primary.contrastText' }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
@@ -581,6 +566,8 @@ class ChatAssistant extends Component {
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
gap: { xs: 1, sm: 0 },
|
||||
p: 1,
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
@@ -619,11 +606,13 @@ class ChatAssistant extends Component {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
{isRecording ? (
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={this.stopRecording}
|
||||
sx={{ ml: 1 }}
|
||||
aria-label="Aufnahme stoppen"
|
||||
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||
>
|
||||
<StopIcon />
|
||||
</IconButton>
|
||||
@@ -631,7 +620,8 @@ class ChatAssistant extends Component {
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={this.startRecording}
|
||||
sx={{ ml: 1 }}
|
||||
aria-label="Sprachaufnahme starten"
|
||||
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||
disabled={isTyping || inputsDisabled}
|
||||
>
|
||||
<MicIcon />
|
||||
@@ -641,7 +631,8 @@ class ChatAssistant extends Component {
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={this.handleImageUpload}
|
||||
sx={{ ml: 1 }}
|
||||
aria-label="Bild hochladen"
|
||||
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||
disabled={isTyping || isRecording || inputsDisabled}
|
||||
>
|
||||
<PhotoCameraIcon />
|
||||
@@ -649,13 +640,14 @@ class ChatAssistant extends Component {
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ ml: 1 }}
|
||||
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||
onClick={this.handleSendMessage}
|
||||
disabled={isTyping || isRecording || inputsDisabled}
|
||||
>
|
||||
Senden
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import CategoryBox from './CategoryBox.js';
|
||||
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js';
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
import { withCategory } from '../context/CategoryContext.js';
|
||||
|
||||
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
@@ -26,13 +28,13 @@ const withRouter = (ClassComponent) => {
|
||||
};
|
||||
};
|
||||
|
||||
function getCachedCategoryData(categoryId) {
|
||||
function getCachedCategoryData(categoryId, language = 'de') {
|
||||
if (!window.productCache) {
|
||||
window.productCache = {};
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = `categoryProducts_${categoryId}`;
|
||||
const cacheKey = `categoryProducts_${categoryId}_${language}`;
|
||||
const cachedData = window.productCache[cacheKey];
|
||||
|
||||
if (cachedData) {
|
||||
@@ -52,7 +54,7 @@ function getCachedCategoryData(categoryId) {
|
||||
|
||||
|
||||
|
||||
function getFilteredProducts(unfilteredProducts, attributes) {
|
||||
function getFilteredProducts(unfilteredProducts, attributes, t) {
|
||||
const attributeSettings = getAllSettingsWithPrefix('filter_attribute_');
|
||||
const manufacturerSettings = getAllSettingsWithPrefix('filter_manufacturer_');
|
||||
const availabilitySettings = getAllSettingsWithPrefix('filter_availability_');
|
||||
@@ -149,22 +151,22 @@ function getFilteredProducts(unfilteredProducts, attributes) {
|
||||
|
||||
// Check for "auf Lager" filter (in stock) - it's active when filter_availability is NOT set to '1'
|
||||
if (availabilityFilter !== '1') {
|
||||
activeAvailabilityFilters.push({id: '1', name: 'auf Lager'});
|
||||
activeAvailabilityFilters.push({id: '1', name: t ? t('product.inStock') : 'auf Lager'});
|
||||
}
|
||||
|
||||
// Check for "Neu" filter (new) - only show if there are actually new products and filter is active
|
||||
if (availabilityFilters.includes('2') && hasNewProducts) {
|
||||
activeAvailabilityFilters.push({id: '2', name: 'Neu'});
|
||||
activeAvailabilityFilters.push({id: '2', name: t ? t('product.new') : 'Neu'});
|
||||
}
|
||||
|
||||
// Check for "Bald verfügbar" filter (coming soon) - only show if there are actually coming soon products and filter is active
|
||||
if (availabilityFilters.includes('3') && hasComingSoonProducts) {
|
||||
activeAvailabilityFilters.push({id: '3', name: 'Bald verfügbar'});
|
||||
activeAvailabilityFilters.push({id: '3', name: t ? t('product.comingSoon') : 'Bald verfügbar'});
|
||||
}
|
||||
|
||||
return {filteredProducts,activeAttributeFilters:activeAttributeFiltersWithNames,activeManufacturerFilters:activeManufacturerFiltersWithNames,activeAvailabilityFilters};
|
||||
}
|
||||
function setCachedCategoryData(categoryId, data) {
|
||||
function setCachedCategoryData(categoryId, data, language = 'de') {
|
||||
if (!window.productCache) {
|
||||
window.productCache = {};
|
||||
}
|
||||
@@ -173,9 +175,10 @@ function setCachedCategoryData(categoryId, data) {
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = `categoryProducts_${categoryId}`;
|
||||
const cacheKey = `categoryProducts_${categoryId}_${language}`;
|
||||
if(data.products) for(const product of data.products) {
|
||||
window.productDetailCache[product.id] = product;
|
||||
const productCacheKey = `product_${product.id}_${language}`;
|
||||
window.productDetailCache[productCacheKey] = product;
|
||||
}
|
||||
window.productCache[cacheKey] = {
|
||||
...data,
|
||||
@@ -196,67 +199,113 @@ class Content extends Component {
|
||||
unfilteredProducts: [],
|
||||
filteredProducts: [],
|
||||
attributes: [],
|
||||
childCategories: []
|
||||
childCategories: [],
|
||||
lastFetchedLanguage: props.i18n?.language || 'de'
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if(this.props.params.categoryId) {this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
const currentLanguage = this.props.i18n?.language || 'de';
|
||||
if(this.props.params.categoryId) {this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage}, () => {
|
||||
this.fetchCategoryData(this.props.params.categoryId);
|
||||
})}
|
||||
else if (this.props.searchParams?.get('q')) {
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage}, () => {
|
||||
this.fetchSearchData(this.props.searchParams?.get('q'));
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if(this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId)) {
|
||||
const currentLanguage = this.props.i18n?.language || 'de';
|
||||
const categoryChanged = this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId);
|
||||
const searchChanged = this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q'));
|
||||
|
||||
if(categoryChanged) {
|
||||
// Clear context for new category loading
|
||||
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
|
||||
this.props.categoryContext.setCurrentCategory(null);
|
||||
}
|
||||
|
||||
window.currentSearchQuery = null;
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage}, () => {
|
||||
this.fetchCategoryData(this.props.params.categoryId);
|
||||
});
|
||||
return; // Don't check language change if category changed
|
||||
}
|
||||
else if (this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q'))) {
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
else if (searchChanged) {
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage}, () => {
|
||||
this.fetchSearchData(this.props.searchParams?.get('q'));
|
||||
})
|
||||
});
|
||||
return; // Don't check language change if search changed
|
||||
}
|
||||
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
// Re-fetch products when language changes to get translated content
|
||||
const languageChanged = currentLanguage !== this.state.lastFetchedLanguage;
|
||||
|
||||
if (!wasConnected && isNowConnected && !this.state.loaded) {
|
||||
// Socket just connected and we haven't loaded data yet, retry loading
|
||||
if (this.props.params.categoryId) {
|
||||
console.log('Content componentDidUpdate:', {
|
||||
languageChanged,
|
||||
lastFetchedLang: this.state.lastFetchedLanguage,
|
||||
currentLang: currentLanguage,
|
||||
prevPropsLang: prevProps.i18n?.language,
|
||||
hasCategoryId: !!this.props.params.categoryId,
|
||||
categoryId: this.props.params.categoryId,
|
||||
hasSearchQuery: !!this.props.searchParams?.get('q')
|
||||
});
|
||||
|
||||
if(languageChanged) {
|
||||
console.log('Content: Language changed! Re-fetching data...');
|
||||
// Re-fetch current data with new language
|
||||
// Note: Language is now part of the cache key, so it will automatically fetch fresh data
|
||||
if(this.props.params.categoryId) {
|
||||
// Re-fetch category data with new language
|
||||
console.log('Content: Re-fetching category', this.props.params.categoryId);
|
||||
this.setState({loaded: false, lastFetchedLanguage: currentLanguage}, () => {
|
||||
this.fetchCategoryData(this.props.params.categoryId);
|
||||
} else if (this.props.searchParams?.get('q')) {
|
||||
});
|
||||
} else if(this.props.searchParams?.get('q')) {
|
||||
// Re-fetch search data with new language
|
||||
console.log('Content: Re-fetching search', this.props.searchParams?.get('q'));
|
||||
this.setState({loaded: false, lastFetchedLanguage: currentLanguage}, () => {
|
||||
this.fetchSearchData(this.props.searchParams?.get('q'));
|
||||
});
|
||||
} else {
|
||||
// If not viewing category or search, just re-filter existing products
|
||||
console.log('Content: Just re-filtering existing products');
|
||||
this.setState({lastFetchedLanguage: currentLanguage});
|
||||
this.filterProducts();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processData(response) {
|
||||
const unfilteredProducts = response.products;
|
||||
const rawProducts = response.products;
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
|
||||
|
||||
if (!window.individualProductCache) {
|
||||
window.individualProductCache = {};
|
||||
}
|
||||
//console.log("processData", unfilteredProducts);
|
||||
if(unfilteredProducts) unfilteredProducts.forEach(product => {
|
||||
window.individualProductCache[product.id] = {
|
||||
data: product,
|
||||
|
||||
const unfilteredProducts = [];
|
||||
|
||||
//console.log("processData", rawProducts);
|
||||
if(rawProducts) rawProducts.forEach(product => {
|
||||
const effectiveProduct = product.translatedProduct || product;
|
||||
const cacheKey = `${effectiveProduct.id}_${currentLanguage}`;
|
||||
|
||||
window.individualProductCache[cacheKey] = {
|
||||
data: effectiveProduct,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
unfilteredProducts.push(effectiveProduct);
|
||||
});
|
||||
|
||||
this.setState({
|
||||
unfilteredProducts: unfilteredProducts,
|
||||
...getFilteredProducts(
|
||||
unfilteredProducts,
|
||||
response.attributes
|
||||
response.attributes,
|
||||
this.props.t
|
||||
),
|
||||
categoryName: response.categoryName || response.name || null,
|
||||
dataType: response.dataType,
|
||||
@@ -264,34 +313,49 @@ class Content extends Component {
|
||||
attributes: response.attributes,
|
||||
childCategories: response.childCategories || [],
|
||||
loaded: true
|
||||
}, () => {
|
||||
console.log('Content: processData finished', {
|
||||
hasContext: !!this.props.categoryContext,
|
||||
categoryName: response.categoryName,
|
||||
name: response.name
|
||||
});
|
||||
|
||||
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
|
||||
if (response.categoryName || response.name) {
|
||||
console.log('Content: Setting category context');
|
||||
this.props.categoryContext.setCurrentCategory({
|
||||
id: this.props.params.categoryId,
|
||||
name: response.categoryName || response.name
|
||||
});
|
||||
} else {
|
||||
console.log('Content: No category name found to set in context');
|
||||
}
|
||||
} else {
|
||||
console.warn('Content: categoryContext prop is missing!');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
fetchCategoryData(categoryId) {
|
||||
const cachedData = getCachedCategoryData(categoryId);
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
|
||||
const cachedData = getCachedCategoryData(categoryId, currentLanguage);
|
||||
if (cachedData) {
|
||||
this.processDataWithCategoryTree(cachedData, categoryId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
// The componentDidUpdate will retry when socket connects
|
||||
console.log("Socket not connected yet, waiting for connection to fetch category data");
|
||||
return;
|
||||
}
|
||||
console.log(`productList:${categoryId}`);
|
||||
this.props.socket.off(`productList:${categoryId}`);
|
||||
window.socketManager.off(`productList:${categoryId}`);
|
||||
|
||||
// Track if we've received the full response to ignore stub response if needed
|
||||
let receivedFullResponse = false;
|
||||
|
||||
this.props.socket.on(`productList:${categoryId}`,(response) => {
|
||||
window.socketManager.on(`productList:${categoryId}`,(response) => {
|
||||
console.log("getCategoryProducts full response", response);
|
||||
receivedFullResponse = true;
|
||||
setCachedCategoryData(categoryId, response);
|
||||
setCachedCategoryData(categoryId, response, currentLanguage);
|
||||
if (response && response.products !== undefined) {
|
||||
this.processDataWithCategoryTree(response, categoryId);
|
||||
} else {
|
||||
@@ -299,12 +363,14 @@ class Content extends Component {
|
||||
}
|
||||
});
|
||||
|
||||
this.props.socket.emit("getCategoryProducts", { categoryId: categoryId },
|
||||
window.socketManager.emit(
|
||||
"getCategoryProducts",
|
||||
{ categoryId: categoryId, language: currentLanguage, requestTranslation: currentLanguage === 'de' ? false : true },
|
||||
(response) => {
|
||||
console.log("getCategoryProducts stub response", response);
|
||||
// Only process stub response if we haven't received the full response yet
|
||||
if (!receivedFullResponse) {
|
||||
setCachedCategoryData(categoryId, response);
|
||||
setCachedCategoryData(categoryId, response, currentLanguage);
|
||||
if (response && response.products !== undefined) {
|
||||
this.processDataWithCategoryTree(response, categoryId);
|
||||
} else {
|
||||
@@ -318,15 +384,17 @@ class Content extends Component {
|
||||
}
|
||||
|
||||
processDataWithCategoryTree(response, categoryId) {
|
||||
console.log("---------------processDataWithCategoryTree", response, categoryId);
|
||||
// Get child categories from the cached category tree
|
||||
let childCategories = [];
|
||||
try {
|
||||
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
|
||||
if (categoryTreeCache && categoryTreeCache.categoryTree) {
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
|
||||
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
|
||||
if (categoryTreeCache) {
|
||||
// If categoryId is a string (SEO name), find by seoName, otherwise by ID
|
||||
const targetCategory = typeof categoryId === 'string'
|
||||
? this.findCategoryBySeoName(categoryTreeCache.categoryTree, categoryId)
|
||||
: this.findCategoryById(categoryTreeCache.categoryTree, categoryId);
|
||||
? this.findCategoryBySeoName(categoryTreeCache, categoryId)
|
||||
: this.findCategoryById(categoryTreeCache, categoryId);
|
||||
|
||||
if (targetCategory && targetCategory.children) {
|
||||
childCategories = targetCategory.children;
|
||||
@@ -342,6 +410,27 @@ class Content extends Component {
|
||||
childCategories
|
||||
};
|
||||
|
||||
// Attempt to set category name from the tree if missing in response
|
||||
if (!enhancedResponse.categoryName && !enhancedResponse.name) {
|
||||
// Try to find name in the tree using the ID or SEO name
|
||||
try {
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
|
||||
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
|
||||
|
||||
if (categoryTreeCache) {
|
||||
const targetCategory = typeof categoryId === 'string'
|
||||
? this.findCategoryBySeoName(categoryTreeCache, categoryId)
|
||||
: this.findCategoryById(categoryTreeCache, categoryId);
|
||||
|
||||
if (targetCategory && targetCategory.name) {
|
||||
enhancedResponse.categoryName = targetCategory.name;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error finding category name in tree:', err);
|
||||
}
|
||||
}
|
||||
|
||||
this.processData(enhancedResponse);
|
||||
}
|
||||
|
||||
@@ -363,17 +452,18 @@ class Content extends Component {
|
||||
}
|
||||
|
||||
fetchSearchData(query) {
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
// The componentDidUpdate will retry when socket connects
|
||||
console.log("Socket not connected yet, waiting for connection to fetch search data");
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.socket.emit("getSearchProducts", { query },
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
|
||||
window.socketManager.emit(
|
||||
"getSearchProducts",
|
||||
{ query, language: currentLanguage, requestTranslation: currentLanguage === 'de' ? false : true },
|
||||
(response) => {
|
||||
if (response && response.products) {
|
||||
this.processData(response);
|
||||
// Map products to use translatedProduct if available
|
||||
const enhancedResponse = {
|
||||
...response,
|
||||
products: response.products.map(p => p.translatedProduct || p)
|
||||
};
|
||||
this.processData(enhancedResponse);
|
||||
} else {
|
||||
console.log("fetchSearchData in Content failed", response);
|
||||
}
|
||||
@@ -385,7 +475,8 @@ class Content extends Component {
|
||||
this.setState({
|
||||
...getFilteredProducts(
|
||||
this.state.unfilteredProducts,
|
||||
this.state.attributes
|
||||
this.state.attributes,
|
||||
this.props.t
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -413,28 +504,30 @@ class Content extends Component {
|
||||
const seoName = this.props.params.categoryId;
|
||||
|
||||
// Get the category tree from cache
|
||||
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
|
||||
if (!categoryTreeCache || !categoryTreeCache.categoryTree) {
|
||||
return null;
|
||||
}
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
|
||||
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
|
||||
|
||||
// Find the category by seoName
|
||||
const category = this.findCategoryBySeoName(categoryTreeCache.categoryTree, seoName);
|
||||
const category = this.findCategoryBySeoName(categoryTreeCache, seoName);
|
||||
return category ? category.id : null;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.props.categoryContext && this.props.categoryContext.setCurrentCategory) {
|
||||
this.props.categoryContext.setCurrentCategory(null);
|
||||
}
|
||||
}
|
||||
|
||||
renderParentCategoryNavigation = () => {
|
||||
const currentCategoryId = this.getCurrentCategoryId();
|
||||
if (!currentCategoryId) return null;
|
||||
|
||||
// Get the category tree from cache
|
||||
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
|
||||
if (!categoryTreeCache || !categoryTreeCache.categoryTree) {
|
||||
return null;
|
||||
}
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
|
||||
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
|
||||
|
||||
// Find the current category in the tree
|
||||
const currentCategory = this.findCategoryById(categoryTreeCache.categoryTree, currentCategoryId);
|
||||
const currentCategory = this.findCategoryById(categoryTreeCache, currentCategoryId);
|
||||
if (!currentCategory) {
|
||||
return null;
|
||||
}
|
||||
@@ -445,7 +538,7 @@ class Content extends Component {
|
||||
}
|
||||
|
||||
// Find the parent category
|
||||
const parentCategory = this.findCategoryById(categoryTreeCache.categoryTree, currentCategory.parentId);
|
||||
const parentCategory = this.findCategoryById(categoryTreeCache, currentCategory.parentId);
|
||||
if (!parentCategory) {
|
||||
return null;
|
||||
}
|
||||
@@ -463,11 +556,14 @@ class Content extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
// console.log('Content props:', this.props);
|
||||
// Check if we should show category boxes instead of product list
|
||||
const showCategoryBoxes = this.state.loaded &&
|
||||
this.state.unfilteredProducts.length === 0 &&
|
||||
this.state.childCategories.length > 0;
|
||||
|
||||
console.log("showCategoryBoxes", showCategoryBoxes, this.state.unfilteredProducts.length, this.state.childCategories.length);
|
||||
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: { xs: 0, sm: 2 }, px: { xs: 0, sm: 3 }, flexGrow: 1, display: 'grid', gridTemplateRows: '1fr' }}>
|
||||
@@ -513,7 +609,8 @@ class Content extends Component {
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
<KeyboardArrowUpIcon sx={{ color: 'white', fontSize: '1.2rem' }} />
|
||||
</Box>
|
||||
@@ -562,7 +659,8 @@ class Content extends Component {
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
<KeyboardArrowUpIcon sx={{ color: 'white', fontSize: '1.2rem' }} />
|
||||
</Box>
|
||||
@@ -596,13 +694,14 @@ class Content extends Component {
|
||||
onFilterChange={()=>{this.filterProducts()}}
|
||||
dataType={this.state.dataType}
|
||||
dataParam={this.state.dataParam}
|
||||
categoryName={this.state.categoryName}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{(this.getCurrentCategoryId() == 706 || this.getCurrentCategoryId() == 689) &&
|
||||
{(this.props.params.categoryId == 'Stecklinge' || this.props.params.categoryId == 'Seeds') &&
|
||||
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
|
||||
<Typography variant="h6" sx={{mt:3}}>
|
||||
Andere Kategorien
|
||||
{this.props.t ? this.props.t('navigation.otherCategories') : 'Andere Kategorien'}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
@@ -631,12 +730,26 @@ class Content extends Component {
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
bgcolor: '#e1f0d3',
|
||||
backgroundImage: 'url("/assets/images/seeds.jpg")',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
position: 'relative'
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<img
|
||||
src="/assets/images/seeds.avif"
|
||||
alt="Seeds"
|
||||
fetchPriority="high"
|
||||
loading="eager"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)'
|
||||
}}
|
||||
/>
|
||||
{/* Overlay text - optional */}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
@@ -647,7 +760,7 @@ class Content extends Component {
|
||||
p: 2,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
|
||||
Seeds
|
||||
{this.props.t('sections.seeds')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -678,12 +791,26 @@ class Content extends Component {
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
bgcolor: '#e8f5d6',
|
||||
backgroundImage: 'url("/assets/images/cutlings.jpg")',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
position: 'relative'
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<img
|
||||
src="/assets/images/cutlings.avif"
|
||||
alt="Stecklinge"
|
||||
fetchPriority="high"
|
||||
loading="eager"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)'
|
||||
}}
|
||||
/>
|
||||
{/* Overlay text - optional */}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
@@ -694,7 +821,7 @@ class Content extends Component {
|
||||
p: 2,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
|
||||
Stecklinge
|
||||
{this.props.t('sections.stecklinge')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -703,8 +830,6 @@ class Content extends Component {
|
||||
|
||||
<Box>
|
||||
<ProductList
|
||||
socket={this.props.socket}
|
||||
socketB={this.props.socketB}
|
||||
totalProductCount={(this.state.unfilteredProducts || []).length}
|
||||
products={this.state.filteredProducts || []}
|
||||
activeAttributeFilters={this.state.activeAttributeFilters || []}
|
||||
@@ -723,4 +848,4 @@ class Content extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(Content);
|
||||
export default withRouter(withI18n()(withCategory(Content)));
|
||||
@@ -267,7 +267,7 @@ class Filter extends Component {
|
||||
)}
|
||||
</Typography>
|
||||
{isXsScreen && (
|
||||
<IconButton size="small" sx={{ p: 0 }}>
|
||||
<IconButton size="small" aria-label={isCollapsed ? "Filter erweitern" : "Filter einklappen"} sx={{ p: 0 }}>
|
||||
{isCollapsed ? <ExpandMoreIcon /> : <ExpandLessIcon />}
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from '@mui/material/Link';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
|
||||
// Styled component for the router links
|
||||
const StyledRouterLink = styled(RouterLink)(() => ({
|
||||
@@ -229,9 +230,9 @@ class Footer extends Component {
|
||||
alignItems={{ xs: 'center', md: 'left' }}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<StyledRouterLink to="/datenschutz">Datenschutz</StyledRouterLink>
|
||||
<StyledRouterLink to="/agb">AGB</StyledRouterLink>
|
||||
<StyledRouterLink to="/sitemap">Sitemap</StyledRouterLink>
|
||||
<StyledRouterLink to="/datenschutz">{this.props.t ? this.props.t('footer.legal.datenschutz') : 'Datenschutz'}</StyledRouterLink>
|
||||
<StyledRouterLink to="/agb">{this.props.t ? this.props.t('footer.legal.agb') : 'AGB'}</StyledRouterLink>
|
||||
<StyledRouterLink to="/sitemap">{this.props.t ? this.props.t('footer.legal.sitemap') : 'Sitemap'}</StyledRouterLink>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
@@ -241,12 +242,12 @@ class Footer extends Component {
|
||||
alignItems={{ xs: 'center', md: 'left' }}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<StyledRouterLink to="/impressum">Impressum</StyledRouterLink>
|
||||
<StyledRouterLink to="/batteriegesetzhinweise">Batteriegesetzhinweise</StyledRouterLink>
|
||||
<StyledRouterLink to="/widerrufsrecht">Widerrufsrecht</StyledRouterLink>
|
||||
<StyledRouterLink to="/impressum">{this.props.t ? this.props.t('footer.legal.impressum') : 'Impressum'}</StyledRouterLink>
|
||||
<StyledRouterLink to="/batteriegesetzhinweise">{this.props.t ? this.props.t('footer.legal.batteriegesetzhinweise') : 'Batteriegesetzhinweise'}</StyledRouterLink>
|
||||
<StyledRouterLink to="/widerrufsrecht">{this.props.t ? this.props.t('footer.legal.widerrufsrecht') : 'Widerrufsrecht'}</StyledRouterLink>
|
||||
</Stack>
|
||||
|
||||
{/* Payment Methods Section */}
|
||||
{/* Payment Methods Section
|
||||
<Stack
|
||||
direction="column"
|
||||
spacing={1}
|
||||
@@ -263,7 +264,7 @@ class Footer extends Component {
|
||||
<Box component="img" src="/assets/images/cards.png" alt="Cash" sx={{ height: { xs: 80, md: 95 } }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
*/}
|
||||
{/* Google Services Badge Section */}
|
||||
<Stack
|
||||
direction="column"
|
||||
@@ -274,9 +275,9 @@ class Footer extends Component {
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={{ xs: 1, md: 2 }}
|
||||
sx={{pb: '10px'}}
|
||||
sx={{pt: '10px', height: { xs: 50, md: 60 }, transform: 'translateY(-3px)'}}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
alignItems="flex-end"
|
||||
>
|
||||
<Link
|
||||
href="https://reviewthis.biz/growheads"
|
||||
@@ -285,17 +286,21 @@ class Footer extends Component {
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
position: 'relative',
|
||||
zIndex: 9999
|
||||
zIndex: 9999,
|
||||
display: 'inline-block',
|
||||
height: { xs: 57, md: 67 },
|
||||
lineHeight: 1
|
||||
}}
|
||||
onMouseEnter={this.handleReviewsMouseEnter}
|
||||
onMouseLeave={this.handleReviewsMouseLeave}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/assets/images/gg.png"
|
||||
src="/assets/images/gg.avif"
|
||||
alt="Google Reviews"
|
||||
sx={{
|
||||
height: { xs: 50, md: 60 },
|
||||
width: { xs: 105, md: 126 },
|
||||
cursor: 'pointer',
|
||||
transition: 'all 2s ease',
|
||||
'&:hover': {
|
||||
@@ -311,17 +316,21 @@ class Footer extends Component {
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
position: 'relative',
|
||||
zIndex: 9999
|
||||
zIndex: 9999,
|
||||
display: 'inline-block',
|
||||
height: { xs: 47, md: 67 },
|
||||
lineHeight: 1
|
||||
}}
|
||||
onMouseEnter={this.handleMapsMouseEnter}
|
||||
onMouseLeave={this.handleMapsMouseLeave}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/assets/images/maps.png"
|
||||
src="/assets/images/maps.avif"
|
||||
alt="Google Maps"
|
||||
sx={{
|
||||
height: { xs: 40, md: 50 },
|
||||
width: { xs: 38, md: 49 },
|
||||
cursor: 'pointer',
|
||||
transition: 'all 2s ease',
|
||||
filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))',
|
||||
@@ -338,11 +347,14 @@ class Footer extends Component {
|
||||
{/* Copyright Section */}
|
||||
<Box sx={{ pb:'20px',textAlign: 'center', filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))', opacity: 0.7 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
|
||||
* Alle Preise inkl. gesetzlicher USt., zzgl. Versand
|
||||
{this.props.t ? this.props.t('footer.allPricesIncl') : '* Alle Preise inkl. gesetzlicher USt., zzgl. Versand'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
|
||||
© {new Date().getFullYear()} <StyledDomainLink href="https://growheads.de" target="_blank" rel="noopener noreferrer">GrowHeads.de</StyledDomainLink>
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: { xs: '9px', md: '9px' }, lineHeight: 1.5, mt: 1 }}>
|
||||
<StyledDomainLink href="https://telegraf.growheads.de" target="_blank" rel="noreferrer">Telegraf - sicherer Chat mit unseren Mitarbeitern</StyledDomainLink>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -351,4 +363,4 @@ class Footer extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
export default withI18n()(Footer);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { Component } from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
import GoogleAuthContext from '../contexts/GoogleAuthContext.js';
|
||||
// import { withI18n } from '../i18n/withTranslation.js'; // Temporarily commented out for debugging
|
||||
|
||||
class GoogleLoginButton extends Component {
|
||||
static contextType = GoogleAuthContext;
|
||||
@@ -186,17 +187,20 @@ class GoogleLoginButton extends Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { disabled, style, className, text = 'Mit Google anmelden' } = this.props;
|
||||
const { disabled, style, className, text = 'Loading...'} = this.props;
|
||||
const { isInitializing, isPrompting } = this.state;
|
||||
const isLoading = isInitializing || isPrompting || (this.context && !this.context.isLoaded);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<GoogleIcon />}
|
||||
onClick={this.handleClick}
|
||||
disabled={disabled || isLoading}
|
||||
style={{ backgroundColor: '#4285F4', color: 'white', ...style }}
|
||||
fullWidth
|
||||
style={{backgroundColor: '#4285F4', color: 'white', ...style }}
|
||||
className={className}
|
||||
>
|
||||
{isLoading ? 'Loading...' : text}
|
||||
@@ -205,4 +209,4 @@ class GoogleLoginButton extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default GoogleLoginButton;
|
||||
export default GoogleLoginButton; // Temporarily removed withI18n for debugging
|
||||
@@ -4,7 +4,6 @@ import Toolbar from '@mui/material/Toolbar';
|
||||
import Container from '@mui/material/Container';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
import SocketContext from '../contexts/SocketContext.js';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
// Import extracted components
|
||||
@@ -12,7 +11,6 @@ import { Logo, SearchBar, ButtonGroupWithRouter, CategoryList } from './header/i
|
||||
|
||||
// Main Header Component
|
||||
class Header extends Component {
|
||||
static contextType = SocketContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -36,9 +34,8 @@ class Header extends Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
// Get socket directly from context in render method
|
||||
const {socket,socketB} = this.context;
|
||||
const { isHomePage, isProfilePage } = this.props;
|
||||
|
||||
const { isHomePage, isProfilePage, isAktionenPage, isFilialePage } = this.props;
|
||||
|
||||
return (
|
||||
<AppBar position="sticky" color="primary" elevation={0} sx={{ zIndex: 1100 }}>
|
||||
@@ -75,7 +72,7 @@ class Header extends Component {
|
||||
transform: { xs: 'translateY(4px) translateX(9px)', sm: 'none' },
|
||||
ml: { xs: 0, sm: 0 }
|
||||
}}>
|
||||
<ButtonGroupWithRouter socket={socket}/>
|
||||
<ButtonGroupWithRouter/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -94,7 +91,7 @@ class Header extends Component {
|
||||
</Box>
|
||||
</Container>
|
||||
</Toolbar>
|
||||
{(isHomePage || this.props.categoryId || isProfilePage) && <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>
|
||||
);
|
||||
}
|
||||
@@ -105,11 +102,14 @@ const HeaderWithContext = (props) => {
|
||||
const location = useLocation();
|
||||
const isHomePage = location.pathname === '/';
|
||||
const isProfilePage = location.pathname === '/profile';
|
||||
const isAktionenPage = location.pathname === '/aktionen';
|
||||
const isFilialePage = location.pathname === '/filiale';
|
||||
const isArtikel = location.pathname.startsWith('/Artikel/');
|
||||
|
||||
return (
|
||||
<SocketContext.Consumer>
|
||||
{({socket,socketB}) => <Header {...props} socket={socket} socketB={socketB} isHomePage={isHomePage} isProfilePage={isProfilePage} />}
|
||||
</SocketContext.Consumer>
|
||||
|
||||
<Header {...props} isHomePage={isHomePage} isArtikel={isArtikel} isProfilePage={isProfilePage} isAktionenPage={isAktionenPage} isFilialePage={isFilialePage} />
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -12,9 +12,7 @@ import LoupeIcon from '@mui/icons-material/Loupe';
|
||||
class Images extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { mainPic:0,pics:[]};
|
||||
|
||||
console.log('Images constructor',props);
|
||||
this.state = { mainPic:0,pics:[] };
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
@@ -25,6 +23,9 @@ class Images extends Component {
|
||||
this.updatePics();
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
window.productImageUrl = null;
|
||||
}
|
||||
|
||||
updatePics = (newMainPic = this.state.mainPic) => {
|
||||
if (!window.tinyPicCache) window.tinyPicCache = {};
|
||||
@@ -41,6 +42,7 @@ class Images extends Component {
|
||||
|
||||
for(const bildId of bildIds){
|
||||
if(bildId == mainPicId){
|
||||
if(window.productImageUrl) continue;
|
||||
|
||||
if(window.largePicCache[bildId]){
|
||||
pics.push(window.largePicCache[bildId]);
|
||||
@@ -51,10 +53,10 @@ class Images extends Component {
|
||||
pics.push(window.smallPicCache[bildId]);
|
||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||
}else if(window.tinyPicCache[bildId]){
|
||||
pics.push(bildId);
|
||||
pics.push(window.tinyPicCache[bildId]);
|
||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||
}else{
|
||||
pics.push(bildId);
|
||||
pics.push(`/assets/images/prod${bildId}.avif`);
|
||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||
}
|
||||
}else{
|
||||
@@ -69,7 +71,8 @@ class Images extends Component {
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('pics',pics);
|
||||
console.log('DEBUG: pics array contents:', pics);
|
||||
console.log('DEBUG: pics array types:', pics.map(p => typeof p + ': ' + p));
|
||||
this.setState({ pics, mainPic: newMainPic });
|
||||
}else{
|
||||
if(this.state.pics.length > 0) this.setState({ pics:[], mainPic: newMainPic });
|
||||
@@ -77,9 +80,11 @@ class Images extends Component {
|
||||
}
|
||||
|
||||
loadPic = (size,bildId,index) => {
|
||||
this.props.socketB.emit('getPic', { bildId, size }, (res) => {
|
||||
|
||||
|
||||
window.socketManager.emit('getPic', { bildId, size }, (res) => {
|
||||
if(res.success){
|
||||
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
|
||||
|
||||
if(size === 'medium') window.mediumPicCache[bildId] = url;
|
||||
if(size === 'small') window.smallPicCache[bildId] = url;
|
||||
@@ -101,27 +106,53 @@ class Images extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
// SPA version - full functionality with static fallback
|
||||
const getImageSrc = () => {
|
||||
if(window.productImageUrl) return window.productImageUrl;
|
||||
|
||||
// If dynamic image is loaded, use it
|
||||
if (this.state.pics[this.state.mainPic]) {
|
||||
return this.state.pics[this.state.mainPic];
|
||||
}
|
||||
// Otherwise, use static fallback (same as prerender)
|
||||
if (!this.props.pictureList || !this.props.pictureList.trim()) {
|
||||
return '/assets/images/nopicture.jpg';
|
||||
}
|
||||
return `/assets/images/prod${this.props.pictureList.split(',')[0].trim()}.avif`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{this.state.pics[this.state.mainPic] && (
|
||||
<Box sx={{ position: 'relative', display: 'inline-block' }}>
|
||||
<Box sx={{ position: 'relative', display: 'inline-block', width: '499px', maxWidth: '100%' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="400"
|
||||
fetchPriority="high"
|
||||
loading="eager"
|
||||
alt={this.props.productName || 'Produktbild'}
|
||||
onError={(e) => {
|
||||
// Ensure alt text is always present even on error
|
||||
if (!e.target.alt) {
|
||||
e.target.alt = this.props.productName || 'Produktbild';
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
width: '499px',
|
||||
maxWidth: '100%',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.02)'
|
||||
}
|
||||
}}
|
||||
image={this.state.pics[this.state.mainPic]}
|
||||
image={getImageSrc()}
|
||||
onClick={this.props.onOpenFullscreen}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
disableRipple
|
||||
aria-label="Zoom-Symbol"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
@@ -137,7 +168,6 @@ class Images extends Component {
|
||||
<LoupeIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
<Stack direction="row" spacing={2} sx={{ justifyContent: 'flex-start', mt: 1,mb: 1 }}>
|
||||
{this.state.pics.filter(pic => pic !== null && pic !== this.state.pics[this.state.mainPic]).map((pic, filterIndex) => {
|
||||
// Find the original index in the full pics array
|
||||
@@ -169,6 +199,13 @@ class Images extends Component {
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="80"
|
||||
alt={`${this.props.productName || 'Produktbild'} - Bild ${originalIndex + 1}`}
|
||||
onError={(e) => {
|
||||
// Ensure alt text is always present even on error
|
||||
if (!e.target.alt) {
|
||||
e.target.alt = `${this.props.productName || 'Produktbild'} - Bild ${originalIndex + 1}`;
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
@@ -223,6 +260,7 @@ class Images extends Component {
|
||||
{/* Close Button */}
|
||||
<IconButton
|
||||
onClick={this.props.onCloseFullscreen}
|
||||
aria-label="Vollbild schließen"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
@@ -241,6 +279,13 @@ class Images extends Component {
|
||||
{this.state.pics[this.state.mainPic] && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
alt={this.props.productName || 'Produktbild'}
|
||||
onError={(e) => {
|
||||
// Ensure alt text is always present even on error
|
||||
if (!e.target.alt) {
|
||||
e.target.alt = this.props.productName || 'Produktbild';
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
width: '90vw',
|
||||
@@ -294,6 +339,13 @@ class Images extends Component {
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="60"
|
||||
alt={`${this.props.productName || 'Produktbild'} - Miniaturansicht ${originalIndex + 1}`}
|
||||
onError={(e) => {
|
||||
// Ensure alt text is always present even on error
|
||||
if (!e.target.alt) {
|
||||
e.target.alt = `${this.props.productName || 'Produktbild'} - Miniaturansicht ${originalIndex + 1}`;
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
|
||||
278
src/components/LanguageSwitcher.js
Normal file
@@ -0,0 +1,278 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
|
||||
class LanguageSwitcher extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
anchorEl: null,
|
||||
loadedFlags: {}
|
||||
};
|
||||
}
|
||||
|
||||
handleClick = (event) => {
|
||||
this.setState({ anchorEl: event.currentTarget });
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
this.setState({ anchorEl: null });
|
||||
};
|
||||
|
||||
handleLanguageChange = async (language) => {
|
||||
const { languageContext } = this.props;
|
||||
if (languageContext) {
|
||||
try {
|
||||
await languageContext.changeLanguage(language);
|
||||
} catch (error) {
|
||||
console.error('Failed to change language:', error);
|
||||
}
|
||||
}
|
||||
this.handleClose();
|
||||
};
|
||||
|
||||
// Lazy load flag components
|
||||
loadFlagComponent = async (lang) => {
|
||||
if (this.state.loadedFlags[lang]) {
|
||||
return this.state.loadedFlags[lang];
|
||||
}
|
||||
|
||||
try {
|
||||
const flagMap = {
|
||||
'ar': () => import('country-flag-icons/react/3x2').then(m => m.EG),
|
||||
'bg': () => import('country-flag-icons/react/3x2').then(m => m.BG),
|
||||
'cs': () => import('country-flag-icons/react/3x2').then(m => m.CZ),
|
||||
'de': () => import('country-flag-icons/react/3x2').then(m => m.DE),
|
||||
'el': () => import('country-flag-icons/react/3x2').then(m => m.GR),
|
||||
'en': () => import('country-flag-icons/react/3x2').then(m => m.US),
|
||||
'es': () => import('country-flag-icons/react/3x2').then(m => m.ES),
|
||||
'fr': () => import('country-flag-icons/react/3x2').then(m => m.FR),
|
||||
'hr': () => import('country-flag-icons/react/3x2').then(m => m.HR),
|
||||
'hu': () => import('country-flag-icons/react/3x2').then(m => m.HU),
|
||||
'it': () => import('country-flag-icons/react/3x2').then(m => m.IT),
|
||||
'pl': () => import('country-flag-icons/react/3x2').then(m => m.PL),
|
||||
'ro': () => import('country-flag-icons/react/3x2').then(m => m.RO),
|
||||
'ru': () => import('country-flag-icons/react/3x2').then(m => m.RU),
|
||||
'sk': () => import('country-flag-icons/react/3x2').then(m => m.SK),
|
||||
'sl': () => import('country-flag-icons/react/3x2').then(m => m.SI),
|
||||
'sq': () => import('country-flag-icons/react/3x2').then(m => m.AL),
|
||||
'sr': () => import('country-flag-icons/react/3x2').then(m => m.RS),
|
||||
'sv': () => import('country-flag-icons/react/3x2').then(m => m.SE),
|
||||
'tr': () => import('country-flag-icons/react/3x2').then(m => m.TR),
|
||||
'uk': () => import('country-flag-icons/react/3x2').then(m => m.UA),
|
||||
'zh': () => import('country-flag-icons/react/3x2').then(m => m.CN)
|
||||
};
|
||||
|
||||
const flagLoader = flagMap[lang];
|
||||
if (flagLoader) {
|
||||
const FlagComponent = await flagLoader();
|
||||
this.setState(prevState => ({
|
||||
loadedFlags: {
|
||||
...prevState.loadedFlags,
|
||||
[lang]: FlagComponent
|
||||
}
|
||||
}));
|
||||
return FlagComponent;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load flag for language: ${lang}`, error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
getLanguageFlag = (lang) => {
|
||||
const FlagComponent = this.state.loadedFlags[lang];
|
||||
|
||||
if (FlagComponent) {
|
||||
return (
|
||||
<FlagComponent
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '14px',
|
||||
borderRadius: '2px',
|
||||
border: '1px solid #ddd'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading placeholder or fallback
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '20px',
|
||||
height: '14px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#666',
|
||||
fontSize: '8px',
|
||||
fontWeight: 'bold',
|
||||
borderRadius: '2px',
|
||||
fontFamily: 'monospace',
|
||||
border: '1px solid #ddd'
|
||||
}}
|
||||
>
|
||||
{this.getLanguageLabel(lang)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Load flags when menu opens
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const { anchorEl } = this.state;
|
||||
const { languageContext } = this.props;
|
||||
|
||||
if (anchorEl && !prevState.anchorEl && languageContext) {
|
||||
// Menu just opened, lazy load flags for all languages (not just available ones)
|
||||
languageContext.allLanguages.forEach(lang => {
|
||||
if (!this.state.loadedFlags[lang]) {
|
||||
this.loadFlagComponent(lang);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getLanguageLabel = (lang) => {
|
||||
const labels = {
|
||||
'ar': 'EG',
|
||||
'bg': 'BG',
|
||||
'cs': 'CZ',
|
||||
'de': 'DE',
|
||||
'el': 'GR',
|
||||
'en': 'US',
|
||||
'es': 'ES',
|
||||
'fr': 'FR',
|
||||
'hr': 'HR',
|
||||
'hu': 'HU',
|
||||
'it': 'IT',
|
||||
'pl': 'PL',
|
||||
'ro': 'RO',
|
||||
'ru': 'RU',
|
||||
'sk': 'SK',
|
||||
'sl': 'SI',
|
||||
'sq': 'AL',
|
||||
'sr': 'RS',
|
||||
'sv': 'SE',
|
||||
'tr': 'TR',
|
||||
'uk': 'UA',
|
||||
'zh': 'CN'
|
||||
};
|
||||
return labels[lang] || lang.toUpperCase();
|
||||
};
|
||||
|
||||
getLanguageName = (lang) => {
|
||||
const names = {
|
||||
'ar': 'العربية',
|
||||
'bg': 'Български',
|
||||
'cs': 'Čeština',
|
||||
'de': 'Deutsch',
|
||||
'el': 'Ελληνικά',
|
||||
'en': 'English',
|
||||
'es': 'Español',
|
||||
'fr': 'Français',
|
||||
'hr': 'Hrvatski',
|
||||
'hu': 'Magyar',
|
||||
'it': 'Italiano',
|
||||
'pl': 'Polski',
|
||||
'ro': 'Română',
|
||||
'ru': 'Русский',
|
||||
'sk': 'Slovenčina',
|
||||
'sl': 'Slovenščina',
|
||||
'sq': 'Shqip',
|
||||
'sr': 'Српски',
|
||||
'sv': 'Svenska',
|
||||
'tr': 'Türkçe',
|
||||
'uk': 'Українська',
|
||||
'zh': '中文'
|
||||
};
|
||||
return names[lang] || lang;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { languageContext } = this.props;
|
||||
const { anchorEl } = this.state;
|
||||
|
||||
if (!languageContext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { currentLanguage, allLanguages } = languageContext;
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
aria-controls={open ? 'language-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={this.handleClick}
|
||||
color="inherit"
|
||||
size="small"
|
||||
sx={{
|
||||
my: 1,
|
||||
mx: 0.5,
|
||||
minWidth: 'auto',
|
||||
textTransform: 'none',
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
{this.getLanguageLabel(currentLanguage)}
|
||||
</Button>
|
||||
<Menu
|
||||
id="language-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={this.handleClose}
|
||||
disableScrollLock={true}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'language-button',
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
{allLanguages.map((language) => {
|
||||
return (
|
||||
<MenuItem
|
||||
key={language}
|
||||
onClick={() => this.handleLanguageChange(language)}
|
||||
selected={language === currentLanguage}
|
||||
sx={{
|
||||
minWidth: 160,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{this.getLanguageFlag(language)}
|
||||
<Typography variant="body2">
|
||||
{this.getLanguageName(language)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
|
||||
{this.getLanguageLabel(language)}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withI18n()(LanguageSwitcher);
|
||||
@@ -22,6 +22,8 @@ import GoogleLoginButton from './GoogleLoginButton.js';
|
||||
import CartSyncDialog from './CartSyncDialog.js';
|
||||
import { localAndArchiveServer, mergeCarts } from '../utils/cartUtils.js';
|
||||
import config from '../config.js';
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
|
||||
// Lazy load GoogleAuthProvider
|
||||
const GoogleAuthProvider = lazy(() => import('../providers/GoogleAuthProvider.js'));
|
||||
@@ -170,30 +172,22 @@ export class LoginComponent extends Component {
|
||||
|
||||
handleLogin = () => {
|
||||
const { email, password } = this.state;
|
||||
const { socket, location, navigate } = this.props;
|
||||
const { location, navigate } = this.props;
|
||||
|
||||
if (!email || !password) {
|
||||
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: '' });
|
||||
|
||||
// Call verifyUser socket endpoint
|
||||
if (!socket || !socket.connected) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Verbindung zum Server verloren. Bitte versuchen Sie es erneut.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('verifyUser', { email, password }, (response) => {
|
||||
window.socketManager.emit('verifyUser', { email, password }, (response) => {
|
||||
console.log('LoginComponent: verifyUser', response);
|
||||
if (response.success) {
|
||||
sessionStorage.setItem('user', JSON.stringify(response.user));
|
||||
@@ -215,9 +209,9 @@ export class LoginComponent extends Component {
|
||||
const serverCartArr = newCart ? Object.values(newCart) : [];
|
||||
|
||||
if (serverCartArr.length === 0) {
|
||||
if (socket && socket.connected) {
|
||||
socket.emit('updateCart', window.cart);
|
||||
}
|
||||
|
||||
window.socketManager.emit('updateCart', window.cart);
|
||||
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else if (localCartArr.length === 0 && serverCartArr.length > 0) {
|
||||
@@ -244,7 +238,7 @@ export class LoginComponent extends Component {
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || 'Anmeldung fehlgeschlagen'
|
||||
error: response.message || (this.props.t ? this.props.t('auth.errors.loginFailed') : 'Anmeldung fehlgeschlagen')
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -252,50 +246,49 @@ export class LoginComponent extends Component {
|
||||
|
||||
handleRegister = () => {
|
||||
const { email, password, confirmPassword } = this.state;
|
||||
const { socket } = this.props;
|
||||
|
||||
if (!email || !password || !confirmPassword) {
|
||||
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
this.setState({ error: 'Passwörter stimmen nicht überein' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.passwordsNotMatchShort') : 'Passwörter stimmen nicht überein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
this.setState({ error: 'Das Passwort muss mindestens 8 Zeichen lang sein' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.passwordMinLength') : 'Das Passwort muss mindestens 8 Zeichen lang sein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: '' });
|
||||
|
||||
// Call createUser socket endpoint
|
||||
if (!socket || !socket.connected) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Verbindung zum Server verloren. Bitte versuchen Sie es erneut.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('createUser', { email, password }, (response) => {
|
||||
window.socketManager.emit('createUser', { email, password }, (response) => {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
success: 'Registrierung erfolgreich. Sie können sich jetzt anmelden.',
|
||||
success: this.props.t ? this.props.t('auth.success.registerComplete') : 'Registrierung erfolgreich. Sie können sich jetzt anmelden.',
|
||||
tabValue: 0 // Switch to login tab
|
||||
});
|
||||
} else {
|
||||
let errorMessage = this.props.t ? this.props.t('auth.errors.registerFailed') : 'Registrierung fehlgeschlagen';
|
||||
|
||||
if (response.cause === 'emailExists') {
|
||||
errorMessage = this.props.t ? this.props.t('auth.errors.emailExists') : 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits. Bitte verwenden Sie eine andere E-Mail-Adresse oder melden Sie sich an.';
|
||||
} else if (response.message) {
|
||||
errorMessage = response.message;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || 'Registrierung fehlgeschlagen'
|
||||
error: errorMessage
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -310,22 +303,7 @@ export class LoginComponent extends Component {
|
||||
};
|
||||
|
||||
handleLogout = () => {
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
// If socket is not connected, just clear local storage
|
||||
sessionStorage.removeItem('user');
|
||||
window.cart = [];
|
||||
window.dispatchEvent(new CustomEvent('cart'));
|
||||
window.dispatchEvent(new CustomEvent('userLoggedOut'));
|
||||
this.setState({
|
||||
isLoggedIn: false,
|
||||
user: null,
|
||||
isAdmin: false,
|
||||
anchorEl: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.socket.emit('logout', (response) => {
|
||||
window.socketManager.emit('logout', (response) => {
|
||||
if(response.success){
|
||||
sessionStorage.removeItem('user');
|
||||
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
||||
@@ -342,22 +320,21 @@ export class LoginComponent extends Component {
|
||||
|
||||
handleForgotPassword = () => {
|
||||
const { email } = this.state;
|
||||
const { socket } = this.props;
|
||||
|
||||
if (!email) {
|
||||
this.setState({ error: 'Bitte geben Sie Ihre E-Mail-Adresse ein' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.enterEmail') : 'Bitte geben Sie Ihre E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: '' });
|
||||
|
||||
// Call resetPassword socket endpoint
|
||||
socket.emit('resetPassword', {
|
||||
|
||||
window.socketManager.emit('resetPassword', {
|
||||
email,
|
||||
domain: window.location.origin
|
||||
}, (response) => {
|
||||
@@ -365,12 +342,12 @@ export class LoginComponent extends Component {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
success: 'Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.'
|
||||
success: this.props.t ? this.props.t('auth.resetPassword.emailSent') : 'Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.'
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || 'Fehler beim Senden der E-Mail'
|
||||
error: response.message || (this.props.t ? this.props.t('auth.resetPassword.emailError') : 'Fehler beim Senden der E-Mail')
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -378,13 +355,11 @@ export class LoginComponent extends Component {
|
||||
|
||||
// Google login functionality
|
||||
handleGoogleLoginSuccess = (credentialResponse) => {
|
||||
const { socket, location, navigate } = this.props;
|
||||
const { location, navigate } = this.props;
|
||||
this.setState({ loading: true, error: '' });
|
||||
console.log('beforeG',credentialResponse)
|
||||
|
||||
|
||||
|
||||
socket.emit('verifyGoogleUser', { credential: credentialResponse.credential }, (response) => {
|
||||
window.socketManager.emit('verifyGoogleUser', { credential: credentialResponse.credential }, (response) => {
|
||||
console.log('google respo',response);
|
||||
if (response.success) {
|
||||
sessionStorage.setItem('user', JSON.stringify(response.user));
|
||||
@@ -406,7 +381,7 @@ export class LoginComponent extends Component {
|
||||
const serverCartArr = newCart ? Object.values(newCart) : [];
|
||||
|
||||
if (serverCartArr.length === 0) {
|
||||
socket.emit('updateCart', window.cart);
|
||||
window.socketManager.emit('updateCart', window.cart);
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else if (localCartArr.length === 0 && serverCartArr.length > 0) {
|
||||
@@ -433,7 +408,7 @@ export class LoginComponent extends Component {
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Google-Anmeldung fehlgeschlagen',
|
||||
error: this.props.t ? this.props.t('auth.errors.googleLoginFailed') : 'Google-Anmeldung fehlgeschlagen',
|
||||
showGoogleAuth: false // Reset Google auth state on failed login
|
||||
});
|
||||
}
|
||||
@@ -443,7 +418,7 @@ export class LoginComponent extends Component {
|
||||
handleGoogleLoginError = (error) => {
|
||||
console.error('Google Login Error:', error);
|
||||
this.setState({
|
||||
error: 'Google-Anmeldung fehlgeschlagen',
|
||||
error: this.props.t ? this.props.t('auth.errors.googleLoginFailed') : 'Google-Anmeldung fehlgeschlagen',
|
||||
showGoogleAuth: false, // Reset Google auth state on error
|
||||
loading: false
|
||||
});
|
||||
@@ -456,7 +431,7 @@ export class LoginComponent extends Component {
|
||||
localAndArchiveServer(localCartSync, serverCartSync);
|
||||
break;
|
||||
case 'deleteServer':
|
||||
this.props.socket.emit('updateCart', window.cart)
|
||||
window.socketManager.emit('updateCart', window.cart)
|
||||
break;
|
||||
case 'useServer':
|
||||
window.cart = serverCartSync;
|
||||
@@ -510,7 +485,7 @@ export class LoginComponent extends Component {
|
||||
color={isAdmin ? 'secondary' : 'inherit'}
|
||||
sx={{ my: 1, mx: 1.5 }}
|
||||
>
|
||||
Profil
|
||||
{this.props.t ? this.props.t('auth.profile') : 'Profil'}
|
||||
</Button>
|
||||
<Menu
|
||||
disableScrollLock={true}
|
||||
@@ -526,14 +501,28 @@ export class LoginComponent extends Component {
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<MenuItem component={Link} to="/profile" onClick={this.handleUserMenuClose}>Profil</MenuItem>
|
||||
<MenuItem component={Link} to="/profile#cart" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Bestellabschluss</MenuItem>
|
||||
<MenuItem component={Link} to="/profile#orders" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Bestellungen</MenuItem>
|
||||
<MenuItem component={Link} to="/profile#settings" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Einstellungen</MenuItem>
|
||||
<MenuItem component={Link} to="/profile" onClick={this.handleUserMenuClose}>
|
||||
{this.props.t ? this.props.t('auth.menu.profile') : 'Profil'}
|
||||
</MenuItem>
|
||||
<MenuItem component={Link} to="/profile#cart" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>
|
||||
{this.props.t ? this.props.t('auth.menu.checkout') : 'Bestellabschluss'}
|
||||
</MenuItem>
|
||||
<MenuItem component={Link} to="/profile#orders" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>
|
||||
{this.props.t ? this.props.t('auth.menu.orders') : 'Bestellungen'}
|
||||
</MenuItem>
|
||||
<MenuItem component={Link} to="/profile#settings" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>
|
||||
{this.props.t ? this.props.t('auth.menu.settings') : 'Einstellungen'}
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
{isAdmin ? <MenuItem component={Link} to="/admin" onClick={this.handleUserMenuClose}>Admin Dashboard</MenuItem> : null}
|
||||
{isAdmin ? <MenuItem component={Link} to="/admin/users" onClick={this.handleUserMenuClose}>Admin Users</MenuItem> : null}
|
||||
<MenuItem onClick={this.handleLogout}>Abmelden</MenuItem>
|
||||
{isAdmin ? <MenuItem component={Link} to="/admin" onClick={this.handleUserMenuClose}>
|
||||
{this.props.t ? this.props.t('auth.menu.adminDashboard') : 'Admin Dashboard'}
|
||||
</MenuItem> : null}
|
||||
{isAdmin ? <MenuItem component={Link} to="/admin/users" onClick={this.handleUserMenuClose}>
|
||||
{this.props.t ? this.props.t('auth.menu.adminUsers') : 'Admin Users'}
|
||||
</MenuItem> : null}
|
||||
<MenuItem onClick={this.handleLogout}>
|
||||
{this.props.t ? this.props.t('auth.logout') : 'Abmelden'}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
) : (
|
||||
@@ -543,7 +532,7 @@ export class LoginComponent extends Component {
|
||||
onClick={this.handleOpen}
|
||||
sx={{ my: 1, mx: 1.5 }}
|
||||
>
|
||||
Login
|
||||
{this.props.t ? this.props.t('auth.login') : 'Login'}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
@@ -558,7 +547,10 @@ export class LoginComponent extends Component {
|
||||
<DialogTitle sx={{ bgcolor: 'white', pb: 0 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="h6" color="#2e7d32" fontWeight="bold">
|
||||
{tabValue === 0 ? 'Anmelden' : 'Registrieren'}
|
||||
{tabValue === 0 ?
|
||||
(this.props.t ? this.props.t('auth.login') : 'Anmelden') :
|
||||
(this.props.t ? this.props.t('auth.register') : 'Registrieren')
|
||||
}
|
||||
</Typography>
|
||||
<IconButton edge="end" onClick={this.handleClose} aria-label="close">
|
||||
<CloseIcon />
|
||||
@@ -578,14 +570,14 @@ export class LoginComponent extends Component {
|
||||
textColor="inherit"
|
||||
>
|
||||
<Tab
|
||||
label="ANMELDEN"
|
||||
label={this.props.t ? this.props.t('auth.login').toUpperCase() : "ANMELDEN"}
|
||||
sx={{
|
||||
color: tabValue === 0 ? '#2e7d32' : 'inherit',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="REGISTRIEREN"
|
||||
label={this.props.t ? this.props.t('auth.register').toUpperCase() : "REGISTRIEREN"}
|
||||
sx={{
|
||||
color: tabValue === 1 ? '#2e7d32' : 'inherit',
|
||||
fontWeight: 'bold'
|
||||
@@ -598,7 +590,14 @@ export class LoginComponent extends Component {
|
||||
<Box sx={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', mb: 2 }}>
|
||||
{!privacyConfirmed && (
|
||||
<Typography variant="caption" sx={{ mb: 1, textAlign: 'center' }}>
|
||||
{this.props.t ?
|
||||
<>
|
||||
{this.props.t('auth.privacyAccept')} <Link to="/datenschutz" style={{ color: '#4285F4' }}>{this.props.t('auth.privacyPolicy')}</Link>
|
||||
</> :
|
||||
<>
|
||||
Mit dem Click auf "Mit Google anmelden" akzeptiere ich die <Link to="/datenschutz" style={{ color: '#4285F4' }}>Datenschutzbestimmungen</Link>
|
||||
</>
|
||||
}
|
||||
</Typography>
|
||||
)}
|
||||
{!showGoogleAuth && (
|
||||
@@ -611,7 +610,7 @@ export class LoginComponent extends Component {
|
||||
}}
|
||||
sx={{ width: '100%', backgroundColor: '#4285F4', color: 'white' }}
|
||||
>
|
||||
Mit Google anmelden
|
||||
{this.props.t ? this.props.t('auth.loginWithGoogle') : 'Mit Google anmelden'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -619,17 +618,18 @@ export class LoginComponent extends Component {
|
||||
<Suspense fallback={
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PersonIcon />}
|
||||
sx={{ width: '100%', backgroundColor: '#4285F4', color: 'white' }}
|
||||
startIcon={<GoogleIcon />}
|
||||
disabled
|
||||
fullWidth
|
||||
style={{backgroundColor: '#4285F4', color: 'white' }}
|
||||
>
|
||||
Mit Google anmelden
|
||||
Loading...
|
||||
</Button>
|
||||
}>
|
||||
<GoogleAuthProvider clientId={config.googleClientId}>
|
||||
<GoogleLoginButton
|
||||
onSuccess={this.handleGoogleLoginSuccess}
|
||||
onError={this.handleGoogleLoginError}
|
||||
text="Mit Google anmelden"
|
||||
style={{ width: '100%', backgroundColor: '#4285F4' }}
|
||||
autoInitiate={true}
|
||||
/>
|
||||
@@ -643,7 +643,9 @@ export class LoginComponent extends Component {
|
||||
{/* OR Divider */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', my: 2 }}>
|
||||
<Box sx={{ flex: 1, height: '1px', backgroundColor: '#e0e0e0' }} />
|
||||
<Typography variant="body2" sx={{ px: 2, color: '#757575' }}>ODER</Typography>
|
||||
<Typography variant="body2" sx={{ px: 2, color: '#757575' }}>
|
||||
{this.props.t ? this.props.t('auth.or') : 'ODER'}
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1, height: '1px', backgroundColor: '#e0e0e0' }} />
|
||||
</Box>
|
||||
|
||||
@@ -654,7 +656,7 @@ export class LoginComponent extends Component {
|
||||
<Box sx={{ py: 1 }}>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="E-Mail"
|
||||
label={this.props.t ? this.props.t('auth.email') : 'E-Mail'}
|
||||
type="email"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
@@ -665,7 +667,7 @@ export class LoginComponent extends Component {
|
||||
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Passwort"
|
||||
label={this.props.t ? this.props.t('auth.password') : 'Passwort'}
|
||||
type="password"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
@@ -687,7 +689,7 @@ export class LoginComponent extends Component {
|
||||
'&:hover': { backgroundColor: 'transparent', textDecoration: 'underline' }
|
||||
}}
|
||||
>
|
||||
Passwort vergessen?
|
||||
{this.props.t ? this.props.t('auth.forgotPassword') : 'Passwort vergessen?'}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
@@ -695,7 +697,7 @@ export class LoginComponent extends Component {
|
||||
{tabValue === 1 && (
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Passwort bestätigen"
|
||||
label={this.props.t ? this.props.t('auth.confirmPassword') : 'Passwort bestätigen'}
|
||||
type="password"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
@@ -717,7 +719,7 @@ export class LoginComponent extends Component {
|
||||
onClick={tabValue === 0 ? this.handleLogin : this.handleRegister}
|
||||
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
||||
>
|
||||
{tabValue === 0 ? 'ANMELDEN' : 'REGISTRIEREN'}
|
||||
{tabValue === 0 ? (this.props.t ? this.props.t('auth.login').toUpperCase() : 'ANMELDEN') : (this.props.t ? this.props.t('auth.register').toUpperCase() : 'REGISTRIEREN')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
@@ -740,4 +742,4 @@ export class LoginComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(LoginComponent);
|
||||
export default withRouter(withI18n()(LoginComponent));
|
||||
284
src/components/MainPageLayout.js
Normal file
@@ -0,0 +1,284 @@
|
||||
import React from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import Container from "@mui/material/Container";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import ChevronLeft from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRight from "@mui/icons-material/ChevronRight";
|
||||
import { Link } from "react-router-dom";
|
||||
import SharedCarousel from "./SharedCarousel.js";
|
||||
import { getCombinedAnimatedBorderStyles } from "../utils/animatedBorderStyles.js";
|
||||
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 location = useLocation();
|
||||
const currentPath = location.pathname;
|
||||
const { t } = useTranslation();
|
||||
const [starHovered, setStarHovered] = React.useState(false);
|
||||
const translatedContent = {
|
||||
showUsPhoto: t('sections.showUsPhoto'),
|
||||
selectSeedRate: t('sections.selectSeedRate'),
|
||||
indoorSeason: t('sections.indoorSeason')
|
||||
};
|
||||
|
||||
const isHome = currentPath === "/";
|
||||
const isAktionen = currentPath === "/aktionen";
|
||||
const isFiliale = currentPath === "/filiale";
|
||||
|
||||
React.useEffect(() => {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes rotateClockwise {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes rotateCounterClockwise {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(-360deg); }
|
||||
}
|
||||
.star-rotate-slow-cw {
|
||||
animation: rotateClockwise 60s linear infinite;
|
||||
}
|
||||
.star-rotate-slow-ccw {
|
||||
animation: rotateCounterClockwise 45s linear infinite;
|
||||
}
|
||||
.star-rotate-medium-cw {
|
||||
animation: rotateClockwise 30s linear infinite;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
return () => document.head.removeChild(style);
|
||||
}, []);
|
||||
|
||||
const getNavigationConfig = () => {
|
||||
if (isHome) return { leftNav: { text: t('navigation.aktionen'), link: "/aktionen" }, rightNav: { text: t('navigation.filiale'), link: "/filiale" } };
|
||||
if (isAktionen) return { leftNav: { text: t('navigation.filiale'), link: "/filiale" }, rightNav: { text: t('navigation.home'), link: "/" } };
|
||||
if (isFiliale) return { leftNav: { text: t('navigation.home'), link: "/" }, rightNav: { text: t('navigation.aktionen'), link: "/aktionen" } };
|
||||
return { leftNav: null, rightNav: null };
|
||||
};
|
||||
|
||||
const allTitles = {
|
||||
home: t('titles.home') ,
|
||||
aktionen: t('titles.aktionen'),
|
||||
filiale: t('titles.filiale')
|
||||
};
|
||||
|
||||
const allContentBoxes = {
|
||||
home: [
|
||||
{ title: t('sections.seeds'), image: "/assets/images/seeds.avif", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" },
|
||||
{ title: t('sections.stecklinge'), image: "/assets/images/cutlings.avif", bgcolor: "#e8f5d6", link: "/Kategorie/Stecklinge" }
|
||||
],
|
||||
aktionen: [
|
||||
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/presseverleih" },
|
||||
{ title: t('sections.thcTest'), image: "/assets/images/purpl.jpg", bgcolor: "#e8f5d6", link: "/thc-test" }
|
||||
],
|
||||
filiale: [
|
||||
{ title: t('sections.address1'), image: "/assets/images/filiale1.jpg", bgcolor: "#e1f0d3", link: "/filiale" },
|
||||
{ title: t('sections.address2'), image: "/assets/images/filiale2.jpg", bgcolor: "#e8f5d6", link: "/filiale" }
|
||||
]
|
||||
};
|
||||
|
||||
const getOpacity = (pageType) => {
|
||||
if (pageType === "home" && isHome) return 1;
|
||||
if (pageType === "aktionen" && isAktionen) return 1;
|
||||
if (pageType === "filiale" && isFiliale) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const navConfig = getNavigationConfig();
|
||||
const navTexts = [
|
||||
{ key: 'aktionen', text: t('navigation.aktionen'), link: '/aktionen' },
|
||||
{ key: 'filiale', text: t('navigation.filiale'), link: '/filiale' },
|
||||
{ key: 'home', text: t('navigation.home'), link: '/' }
|
||||
];
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 2 }}>
|
||||
<style>{getCombinedAnimatedBorderStyles(['seeds', 'cutlings'])}</style>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 4, mt: 2, px: 0, transition: "all 0.3s ease-in-out", flexDirection: { xs: "column", sm: "row" } }}>
|
||||
<Box sx={{ display: { xs: "block", sm: "none" }, mb: { xs: 2, sm: 0 }, width: "100%", textAlign: "center", position: "relative" }}>
|
||||
{Object.entries(allTitles).map(([pageType, title]) => (
|
||||
<Typography key={pageType} variant="h3" component="h1" sx={{
|
||||
fontFamily: "SwashingtonCP", fontSize: { xs: "2.125rem", sm: "2.125rem", md: "3rem" }, textAlign: "center", color: "primary.main",
|
||||
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)", transition: "opacity 0.5s ease-in-out", opacity: getOpacity(pageType),
|
||||
position: pageType === "home" ? "relative" : "absolute", top: pageType !== "home" ? 0 : "auto", left: pageType !== "home" ? 0 : "auto",
|
||||
transform: "none", width: "100%", pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none",
|
||||
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, wordWrap: "break-word", hyphens: "auto"
|
||||
}}>{title}</Typography>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: { xs: "flex", sm: "contents" }, width: { xs: "100%", sm: "auto" }, justifyContent: { xs: "space-between", sm: "initial" }, alignItems: "center" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", flexShrink: 0, justifyContent: "flex-start", position: "relative", mr: { xs: 0, sm: 2 } }}>
|
||||
{navTexts.map((navItem, index) => {
|
||||
const isActive = navConfig.leftNav && navConfig.leftNav.text === navItem.text;
|
||||
return (
|
||||
<Box key={navItem.key} component={Link} to={navItem.link} sx={{
|
||||
display: "flex", alignItems: "center", textDecoration: "none", color: "inherit", transition: "all 0.3s ease",
|
||||
opacity: isActive ? 1 : 0, position: index === 0 ? "relative" : "absolute", left: index !== 0 ? 0 : "auto",
|
||||
pointerEvents: isActive ? "auto" : "none", "&:hover": { transform: "translateX(-5px)", color: "primary.main" }
|
||||
}}>
|
||||
<ChevronLeft sx={{ fontSize: "2rem", mr: 1 }} />
|
||||
<Typography sx={{
|
||||
fontFamily: "SwashingtonCP", fontSize: { xs: "1.25rem", sm: "1.25rem", md: "2.125rem" },
|
||||
textShadow: "2px 2px 4px rgba(0, 0, 0, 0.3)", lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, whiteSpace: "nowrap"
|
||||
}}>{navItem.text}</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, display: { xs: "none", sm: "flex" }, justifyContent: "center", alignItems: "center", px: 0, position: "relative", minWidth: 0 }}>
|
||||
{Object.entries(allTitles).map(([pageType, title]) => (
|
||||
<Typography key={pageType} variant="h3" component="h1" sx={{
|
||||
fontFamily: "SwashingtonCP", fontSize: { xs: "2.125rem", sm: "2.125rem", md: "3rem" }, textAlign: "center", color: "primary.main",
|
||||
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)", transition: "opacity 0.5s ease-in-out", opacity: getOpacity(pageType),
|
||||
position: pageType === "home" ? "relative" : "absolute", top: pageType !== "home" ? "50%" : "auto", left: pageType !== "home" ? "50%" : "auto",
|
||||
transform: pageType !== "home" ? "translate(-50%, -50%)" : "none", width: "100%", pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none",
|
||||
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, wordWrap: "break-word", hyphens: "auto"
|
||||
}}>{title}</Typography>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", flexShrink: 0, justifyContent: "flex-end", position: "relative", ml: { xs: 0, sm: 2 } }}>
|
||||
{navTexts.map((navItem, index) => {
|
||||
const isActive = navConfig.rightNav && navConfig.rightNav.text === navItem.text;
|
||||
return (
|
||||
<Box key={navItem.key} component={Link} to={navItem.link} sx={{
|
||||
display: "flex", alignItems: "center", textDecoration: "none", color: "inherit", transition: "all 0.3s ease",
|
||||
opacity: isActive ? 1 : 0, position: index === 0 ? "relative" : "absolute", right: index !== 0 ? 0 : "auto",
|
||||
pointerEvents: isActive ? "auto" : "none", "&:hover": { transform: "translateX(5px)", color: "primary.main" }
|
||||
}}>
|
||||
<Typography sx={{
|
||||
fontFamily: "SwashingtonCP", fontSize: { xs: "1.25rem", sm: "1.25rem", md: "2.125rem" },
|
||||
textShadow: "2px 2px 4px rgba(0, 0, 0, 0.3)", lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, whiteSpace: "nowrap"
|
||||
}}>{navItem.text}</Typography>
|
||||
<ChevronRight sx={{ fontSize: "2rem", ml: 1 }} />
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ position: "relative", mb: 4 }}>
|
||||
{Object.entries(allContentBoxes).map(([pageType, contentBoxes]) => (
|
||||
<Grid key={pageType} container spacing={0} sx={{
|
||||
transition: "opacity 0.5s ease-in-out", opacity: getOpacity(pageType),
|
||||
position: pageType === "home" ? "relative" : "absolute", top: 0, left: 0, width: "100%", pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none"
|
||||
}}>
|
||||
{contentBoxes.map((box, index) => (
|
||||
<ContentBox
|
||||
key={`${pageType}-${index}`}
|
||||
box={box}
|
||||
index={index}
|
||||
pageType={pageType}
|
||||
starHovered={starHovered}
|
||||
setStarHovered={setStarHovered}
|
||||
opacity={getOpacity(pageType)}
|
||||
translatedContent={translatedContent}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
))}
|
||||
</Box>
|
||||
<SharedCarousel />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainPageLayout;
|
||||
158
src/components/PaymentSuccess.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
|
||||
class PaymentSuccess extends Component {
|
||||
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
redirectUrl: null,
|
||||
processing: true,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.processMolliePayment();
|
||||
}
|
||||
|
||||
processMolliePayment = () => {
|
||||
try {
|
||||
// Get the stored payment ID from localStorage
|
||||
const pendingPayment = localStorage.getItem('pendingPayment');
|
||||
|
||||
if (!pendingPayment) {
|
||||
console.error('No pending payment found in localStorage');
|
||||
this.setState({
|
||||
redirectUrl: '/profile#cart',
|
||||
processing: false,
|
||||
error: 'No payment information found'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let paymentData;
|
||||
try {
|
||||
paymentData = JSON.parse(pendingPayment);
|
||||
// Clear the pending payment data
|
||||
localStorage.removeItem('pendingPayment');
|
||||
} catch (error) {
|
||||
console.error('Error parsing pending payment data:', error);
|
||||
this.setState({
|
||||
redirectUrl: '/profile#cart',
|
||||
processing: false,
|
||||
error: 'Invalid payment data'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!paymentData.paymentId) {
|
||||
console.error('No payment ID found in stored payment data');
|
||||
this.setState({
|
||||
redirectUrl: '/profile#cart',
|
||||
processing: false,
|
||||
error: 'Missing payment ID'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check payment status with backend
|
||||
this.checkMolliePaymentStatus(paymentData.paymentId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing Mollie payment:', error);
|
||||
this.setState({
|
||||
redirectUrl: '/profile#cart',
|
||||
processing: false,
|
||||
error: 'Payment processing failed'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
checkMolliePaymentStatus = (paymentId) => {
|
||||
|
||||
|
||||
|
||||
window.socketManager.emit('checkMollieIntent', { paymentId }, (response) => {
|
||||
if (response.success) {
|
||||
console.log('Payment Status:', response.payment.status);
|
||||
console.log('Is Paid:', response.payment.isPaid);
|
||||
console.log('Order Created:', response.order.created);
|
||||
|
||||
if (response.order.orderId) {
|
||||
console.log('Order ID:', response.order.orderId);
|
||||
}
|
||||
|
||||
// Build the redirect URL with Mollie completion parameters
|
||||
const profileUrl = new URL('/profile', window.location.origin);
|
||||
profileUrl.searchParams.set('mollieComplete', 'true');
|
||||
profileUrl.searchParams.set('mollie_payment_id', paymentId);
|
||||
profileUrl.searchParams.set('mollie_status', response.payment.status);
|
||||
profileUrl.searchParams.set('mollie_amount', response.payment.amount);
|
||||
profileUrl.searchParams.set('mollie_timestamp', Date.now().toString());
|
||||
profileUrl.searchParams.set('mollie_is_paid', response.payment.isPaid.toString());
|
||||
|
||||
if (response.order.orderId) {
|
||||
profileUrl.searchParams.set('mollie_order_id', response.order.orderId.toString());
|
||||
}
|
||||
|
||||
// Set hash based on payment success: orders for successful payments, cart for failed payments
|
||||
profileUrl.hash = response.payment.isPaid ? '#orders' : '#cart';
|
||||
|
||||
this.setState({
|
||||
redirectUrl: profileUrl.pathname + profileUrl.search + profileUrl.hash,
|
||||
processing: false
|
||||
});
|
||||
} else {
|
||||
console.error('Failed to check payment status:', response.error);
|
||||
this.setState({
|
||||
redirectUrl: '/profile#cart',
|
||||
processing: false,
|
||||
error: response.error || 'Payment verification failed'
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { redirectUrl, processing, error } = this.state;
|
||||
|
||||
if (processing) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '60vh',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Zahlung wird überprüft...
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Bitte warten Sie, während wir Ihre Zahlung bei Mollie überprüfen.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Navigate to="/profile#cart" replace />;
|
||||
}
|
||||
|
||||
if (redirectUrl) {
|
||||
return <Navigate to={redirectUrl} replace />;
|
||||
}
|
||||
|
||||
// Fallback redirect to profile
|
||||
return <Navigate to="/profile#cart" replace />;
|
||||
}
|
||||
}
|
||||
|
||||
export default PaymentSuccess;
|
||||
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 IconButton from '@mui/material/IconButton';
|
||||
import AddToCartButton from './AddToCartButton.js';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
|
||||
// Helper function to find level 1 category ID from any category ID
|
||||
const findLevel1CategoryId = (categoryId) => {
|
||||
try {
|
||||
const currentLanguage = 'de'; // Default to German
|
||||
const categoryTreeCache = window.categoryService?.getSync(209, currentLanguage);
|
||||
|
||||
if (!categoryTreeCache || !categoryTreeCache.children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper function to find category by ID and get its level 1 parent
|
||||
const findCategoryAndLevel1 = (categories, targetId) => {
|
||||
for (const category of categories) {
|
||||
if (category.id === targetId) {
|
||||
// Found the category, now find its level 1 parent
|
||||
return findLevel1Parent(categoryTreeCache.children, category);
|
||||
}
|
||||
|
||||
if (category.children && category.children.length > 0) {
|
||||
const result = findCategoryAndLevel1(category.children, targetId);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper function to find the level 1 parent (direct child of root category 209)
|
||||
const findLevel1Parent = (level1Categories, category) => {
|
||||
// If this category's parent is 209, it's already level 1
|
||||
if (category.parentId === 209) {
|
||||
return category.id;
|
||||
}
|
||||
|
||||
// Otherwise, find the parent and check if it's level 1
|
||||
for (const level1Category of level1Categories) {
|
||||
if (level1Category.id === category.parentId) {
|
||||
return level1Category.id;
|
||||
}
|
||||
|
||||
// If parent has children, search recursively
|
||||
if (level1Category.children && level1Category.children.length > 0) {
|
||||
const result = findLevel1Parent(level1Category.children, category);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return findCategoryAndLevel1(categoryTreeCache.children, parseInt(categoryId));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error finding level 1 category:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
class Product extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -26,10 +84,24 @@ class Product extends Component {
|
||||
this.state = {image:window.smallPicCache[bildId],loading:false, error: false}
|
||||
}else{
|
||||
this.state = {image: null, loading: true, error: false};
|
||||
console.log("Product: Fetching image from socketB", this.props.socketB);
|
||||
this.props.socketB.emit('getPic', { bildId, size:'small' }, (res) => {
|
||||
|
||||
this.loadImage(bildId);
|
||||
}
|
||||
}else{
|
||||
this.state = {image: null, loading: false, error: false};
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
}
|
||||
|
||||
loadImage = (bildId) => {
|
||||
|
||||
console.log('loadImagevisSocket', bildId);
|
||||
window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => {
|
||||
if(res.success){
|
||||
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
|
||||
if (this._isMounted) {
|
||||
this.setState({image: window.smallPicCache[bildId], loading: false});
|
||||
} else {
|
||||
@@ -45,16 +117,9 @@ class Product extends Component {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}else{
|
||||
this.state = {image: null, loading: false, error: false};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
@@ -65,11 +130,28 @@ class Product extends Component {
|
||||
// In a real app, this would update a cart state in a parent component or Redux store
|
||||
}
|
||||
|
||||
handleProductClick = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { categoryId } = this.props;
|
||||
|
||||
// Find the level 1 category for this product
|
||||
const level1CategoryId = categoryId ? findLevel1CategoryId(categoryId) : null;
|
||||
|
||||
// Navigate to the product page WITH the category information in the state
|
||||
const navigate = this.props.navigate;
|
||||
if (navigate) {
|
||||
navigate(`/Artikel/${this.props.seoName}`, {
|
||||
state: { articleCategoryId: level1CategoryId }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
id, name, price, available, manufacturer, seoName,
|
||||
currency, vat, massMenge, massEinheit, thc,
|
||||
floweringWeeks,incoming, neu, weight, versandklasse, availableSupplier
|
||||
currency, vat, cGrundEinheit, fGrundPreis, thc,
|
||||
floweringWeeks,incoming, neu, weight, versandklasse, availableSupplier, komponenten
|
||||
} = this.props;
|
||||
|
||||
const isNew = neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||
@@ -173,7 +255,7 @@ class Product extends Component {
|
||||
zIndex: 1000
|
||||
}}
|
||||
>
|
||||
NEU
|
||||
{this.props.t ? this.props.t('product.new') : 'NEU'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -240,20 +322,20 @@ class Product extends Component {
|
||||
transformOrigin: 'top left'
|
||||
}}
|
||||
>
|
||||
{floweringWeeks} Wochen
|
||||
{floweringWeeks} {this.props.t ? this.props.t('product.weeks') : 'Wochen'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Box
|
||||
component={Link}
|
||||
to={`/Artikel/${seoName}`}
|
||||
onClick={this.handleProductClick}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit'
|
||||
color: 'inherit',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
@@ -275,6 +357,14 @@ class Product extends Component {
|
||||
height={ window.innerWidth < 600 ? "240" : "180" }
|
||||
image="/assets/images/nopicture.jpg"
|
||||
alt={name}
|
||||
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}
|
||||
loading={this.props.priority === 'high' ? 'eager' : 'lazy'}
|
||||
onError={(e) => {
|
||||
// Ensure alt text is always present even on error
|
||||
if (!e.target.alt) {
|
||||
e.target.alt = name || 'Produktbild';
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
borderTopLeftRadius: '8px',
|
||||
@@ -288,6 +378,14 @@ class Product extends Component {
|
||||
height={ window.innerWidth < 600 ? "240" : "180" }
|
||||
image={this.state.image}
|
||||
alt={name}
|
||||
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}
|
||||
loading={this.props.priority === 'high' ? 'eager' : 'lazy'}
|
||||
onError={(e) => {
|
||||
// Ensure alt text is always present even on error
|
||||
if (!e.target.alt) {
|
||||
e.target.alt = name || 'Produktbild';
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
borderTopLeftRadius: '8px',
|
||||
@@ -329,20 +427,49 @@ class Product extends Component {
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<div style={{padding:'0px',margin:'0px',minHeight:'3.8em'}}>
|
||||
<div style={{padding:'0px',margin:'0px'}}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="primary"
|
||||
sx={{ mt: 'auto', fontWeight: 'bold', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<span>{new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price)}</span>
|
||||
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>(incl. {vat}% USt.,*)</small>
|
||||
|
||||
|
||||
|
||||
<Box sx={{ position: 'relative', display: 'inline-block' }}>
|
||||
{this.props.rebate && this.props.rebate > 0 && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
left: -8,
|
||||
fontWeight: 'bold',
|
||||
color: 'red',
|
||||
textDecoration: 'line-through',
|
||||
opacity: 0.4,
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
fontSize: 'inherit'
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const rebatePct = this.props.rebate / 100;
|
||||
const originalPrice = Math.round((price / (1 - rebatePct)) * 10) / 10;
|
||||
return new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(originalPrice);
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ position: 'relative', zIndex: 2 }}>{new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price)}</span>
|
||||
</Box>
|
||||
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>({this.props.t ? this.props.t('product.inclVatFooter', { vat }) : `incl. ${vat}% USt.,*`})</small>
|
||||
</Typography>
|
||||
{massMenge != 1 && massEinheit && (<Typography variant="body2" color="text.secondary" sx={{ m: 0,p: 0 }}>
|
||||
({new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price/massMenge)}/{massEinheit})
|
||||
</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>
|
||||
{/*incoming*/}
|
||||
@@ -354,11 +481,12 @@ class Product extends Component {
|
||||
component={Link}
|
||||
to={`/Artikel/${seoName}`}
|
||||
size="small"
|
||||
aria-label="Produktdetails anzeigen"
|
||||
sx={{ mr: 1, color: 'text.secondary' }}
|
||||
>
|
||||
<ZoomInIcon />
|
||||
</IconButton>
|
||||
<AddToCartButton cartButton={true} availableSupplier={availableSupplier} available={available} incoming={incoming} seoName={seoName} pictureList={this.props.pictureList} id={id} price={price} vat={vat} weight={weight} name={name} versandklasse={versandklasse}/>
|
||||
<AddToCartButton cartButton={true} availableSupplier={availableSupplier} komponenten={komponenten} cGrundEinheit={cGrundEinheit} fGrundPreis={fGrundPreis} available={available} incoming={incoming} seoName={seoName} pictureList={this.props.pictureList} id={id} price={price} vat={vat} weight={weight} name={name} versandklasse={versandklasse}/>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
@@ -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);
|
||||
|
||||
444
src/components/ProductCarousel.js
Normal file
@@ -0,0 +1,444 @@
|
||||
import React from 'react';
|
||||
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 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h2"
|
||||
sx={{
|
||||
mb: 2,
|
||||
fontFamily: "SwashingtonCP",
|
||||
color: "primary.main",
|
||||
textAlign: "center",
|
||||
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
|
||||
}}
|
||||
>
|
||||
{title || t('product.new')}
|
||||
</Typography>
|
||||
|
||||
<div
|
||||
className="product-carousel-wrapper"
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'visible',
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
padding: '0 20px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
{/* Left Arrow */}
|
||||
<IconButton
|
||||
aria-label="Vorherige Produkte anzeigen"
|
||||
onClick={this.handleLeftClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '8px',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 1200,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</IconButton>
|
||||
|
||||
{/* Right Arrow */}
|
||||
<IconButton
|
||||
aria-label="Nächste Produkte anzeigen"
|
||||
onClick={this.handleRightClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
right: '8px',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 1200,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
>
|
||||
<ChevronRight />
|
||||
</IconButton>
|
||||
|
||||
<div
|
||||
className="product-carousel-container"
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'visible',
|
||||
padding: '20px 0',
|
||||
width: '100%',
|
||||
maxWidth: '1080px',
|
||||
margin: '0 auto',
|
||||
zIndex: 1,
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="product-carousel-track"
|
||||
ref={this.carouselTrackRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
transition: 'none',
|
||||
alignItems: 'flex-start',
|
||||
width: 'fit-content',
|
||||
overflow: 'visible',
|
||||
position: 'relative',
|
||||
transform: 'translateX(0px)',
|
||||
margin: '0 auto'
|
||||
}}
|
||||
>
|
||||
{products.map((product, index) => (
|
||||
<div
|
||||
key={`${product.id}-${index}`}
|
||||
className="product-carousel-item"
|
||||
style={{
|
||||
flex: '0 0 250px',
|
||||
width: '250px',
|
||||
maxWidth: '250px',
|
||||
minWidth: '250px',
|
||||
boxSizing: 'border-box',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<Product
|
||||
id={product.id}
|
||||
name={product.name}
|
||||
seoName={product.seoName}
|
||||
price={product.price}
|
||||
currency={product.currency}
|
||||
available={product.available}
|
||||
manufacturer={product.manufacturer}
|
||||
vat={product.vat}
|
||||
cGrundEinheit={product.cGrundEinheit}
|
||||
fGrundPreis={product.fGrundPreis}
|
||||
incoming={product.incomingDate}
|
||||
neu={product.neu}
|
||||
thc={product.thc}
|
||||
floweringWeeks={product.floweringWeeks}
|
||||
versandklasse={product.versandklasse}
|
||||
weight={product.weight}
|
||||
pictureList={product.pictureList}
|
||||
availableSupplier={product.availableSupplier}
|
||||
komponenten={product.komponenten}
|
||||
rebate={product.rebate}
|
||||
categoryId={product.kategorien ? product.kategorien.split(',')[0] : undefined}
|
||||
priority={index < 6 ? 'high' : 'auto'}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Virtual Scrollbar */}
|
||||
{this.renderVirtualScrollbar()}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Shuffle array using Fisher-Yates algorithm
|
||||
shuffleArray(array) {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(withLanguage(ProductCarousel));
|
||||
|
||||
4
src/components/ProductDetail.js
Normal file
@@ -0,0 +1,4 @@
|
||||
// This file re-exports ProductDetailWithSocket to maintain compatibility with App.js imports
|
||||
import ProductDetailWithSocket from './ProductDetailWithSocket.js';
|
||||
|
||||
export default ProductDetailWithSocket;
|
||||
@@ -1,18 +1,21 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import SocketContext from '../contexts/SocketContext.js';
|
||||
import ProductDetailPage from './ProductDetailPage.js';
|
||||
import { useProduct } from '../context/ProductContext.js';
|
||||
|
||||
// Wrapper component for individual product detail page with socket
|
||||
const ProductDetailWithSocket = () => {
|
||||
const { seoName } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { setCurrentProduct } = useProduct();
|
||||
|
||||
return (
|
||||
<SocketContext.Consumer>
|
||||
{({socket,socketB}) => <ProductDetailPage seoName={seoName} navigate={navigate} location={location} socket={socket} socketB={socketB} />}
|
||||
</SocketContext.Consumer>
|
||||
<ProductDetailPage
|
||||
seoName={seoName}
|
||||
navigate={navigate}
|
||||
location={location}
|
||||
setCurrentProduct={setCurrentProduct}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Typography from '@mui/material/Typography';
|
||||
import Filter from './Filter.js';
|
||||
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { setSessionSetting, removeSessionSetting, clearAllSessionSettings } from '../utils/sessionStorage.js';
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
|
||||
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
@@ -46,6 +47,41 @@ class ProductFilters extends Component {
|
||||
window.addEventListener('resize', this.adjustPaperHeight);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Regenerate values when products, attributes, or language changes
|
||||
const productsChanged = this.props.products !== prevProps.products;
|
||||
const attributesChanged = this.props.attributes !== prevProps.attributes;
|
||||
const languageChanged = this.props.i18n && prevProps.i18n && this.props.i18n.language !== prevProps.i18n.language;
|
||||
const tFunctionChanged = this.props.t !== prevProps.t;
|
||||
|
||||
if(languageChanged) {
|
||||
console.log('ProductFilters: Language changed, will update when new data arrives');
|
||||
}
|
||||
|
||||
if(productsChanged || languageChanged || tFunctionChanged) {
|
||||
console.log('ProductFilters: Updating manufacturers and availability', {
|
||||
productsChanged,
|
||||
languageChanged,
|
||||
tFunctionChanged,
|
||||
productCount: this.props.products?.length
|
||||
});
|
||||
const uniqueManufacturerArray = this._getUniqueManufacturers(this.props.products);
|
||||
const availabilityValues = this._getAvailabilityValues(this.props.products);
|
||||
this.setState({uniqueManufacturerArray, availabilityValues});
|
||||
}
|
||||
|
||||
if(attributesChanged || (languageChanged && this.props.attributes)) {
|
||||
console.log('ProductFilters: Updating attributes', {
|
||||
attributesChanged,
|
||||
languageChanged,
|
||||
attributeCount: this.props.attributes?.length,
|
||||
firstAttribute: this.props.attributes?.[0]
|
||||
});
|
||||
const attributeGroups = this._getAttributeGroups(this.props.attributes);
|
||||
this.setState({attributeGroups});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Remove event listener when component unmounts
|
||||
window.removeEventListener('resize', this.adjustPaperHeight);
|
||||
@@ -93,14 +129,14 @@ class ProductFilters extends Component {
|
||||
}
|
||||
|
||||
_getAvailabilityValues = (products) => {
|
||||
const filters = [{id:1,name:'auf Lager'}];
|
||||
const filters = [{id:1,name: this.props.t ? this.props.t('product.inStock') : 'auf Lager'}];
|
||||
|
||||
for(const product of products){
|
||||
if(isNew(product.neu)){
|
||||
if(!filters.find(filter => filter.id == 2)) filters.push({id:2,name:'Neu'});
|
||||
if(!filters.find(filter => filter.id == 2)) filters.push({id:2,name: this.props.t ? this.props.t('product.new') : 'Neu'});
|
||||
}
|
||||
if(!product.available && product.incomingDate){
|
||||
if(!filters.find(filter => filter.id == 3)) filters.push({id:3,name:'Bald verfügbar'});
|
||||
if(!filters.find(filter => filter.id == 3)) filters.push({id:3,name: this.props.t ? this.props.t('product.comingSoon') : 'Bald verfügbar'});
|
||||
}
|
||||
}
|
||||
return filters
|
||||
@@ -115,19 +151,6 @@ class ProductFilters extends Component {
|
||||
return attributeGroups;
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
if(nextProps.products !== this.props.products) {
|
||||
const uniqueManufacturerArray = this._getUniqueManufacturers(nextProps.products);
|
||||
const availabilityValues = this._getAvailabilityValues(nextProps.products);
|
||||
this.setState({uniqueManufacturerArray, availabilityValues});
|
||||
}
|
||||
if(nextProps.attributes !== this.props.attributes) {
|
||||
const attributeGroups = this._getAttributeGroups(nextProps.attributes);
|
||||
this.setState({attributeGroups});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
generateAttributeFilters = () => {
|
||||
const filters = [];
|
||||
const sortedAttributeGroups = Object.values(this.state.attributeGroups)
|
||||
@@ -186,14 +209,14 @@ class ProductFilters extends Component {
|
||||
color: 'primary.main'
|
||||
}}
|
||||
>
|
||||
{this.props.dataParam}
|
||||
{this.props.categoryName}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
|
||||
{this.props.products.length > 0 && (
|
||||
<><Filter
|
||||
title="Verfügbarkeit"
|
||||
title={this.props.t ? this.props.t('filters.availability') : 'Verfügbarkeit'}
|
||||
options={this.state.availabilityValues}
|
||||
searchParams={this.props.searchParams}
|
||||
products={this.props.products}
|
||||
@@ -236,7 +259,7 @@ class ProductFilters extends Component {
|
||||
{this.generateAttributeFilters()}
|
||||
|
||||
<Filter
|
||||
title="Hersteller"
|
||||
title={this.props.t ? this.props.t('filters.manufacturer') : 'Hersteller'}
|
||||
options={this.state.uniqueManufacturerArray}
|
||||
filterType="manufacturer"
|
||||
products={this.props.products}
|
||||
@@ -257,4 +280,4 @@ class ProductFilters extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(ProductFilters);
|
||||
export default withRouter(withI18n()(ProductFilters));
|
||||
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;
|
||||
@@ -11,6 +11,7 @@ import Chip from '@mui/material/Chip';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Product from './Product.js';
|
||||
import { removeSessionSetting } from '../utils/sessionStorage.js';
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
|
||||
// Sort products by fuzzy similarity to their name/description
|
||||
function sortProductsByFuzzySimilarity(products, searchTerm) {
|
||||
@@ -141,12 +142,12 @@ class ProductList extends Component {
|
||||
onChange={this.handlePageChange}
|
||||
color="primary"
|
||||
size={"large"}
|
||||
siblingCount={window.innerWidth < 600 ? 0 : 1}
|
||||
boundaryCount={window.innerWidth < 600 ? 1 : 1}
|
||||
hideNextButton={false}
|
||||
hidePrevButton={false}
|
||||
showFirstButton={window.innerWidth >= 600}
|
||||
showLastButton={window.innerWidth >= 600}
|
||||
siblingCount={1}
|
||||
boundaryCount={1}
|
||||
hideNextButton={true}
|
||||
hidePrevButton={true}
|
||||
showFirstButton={false}
|
||||
showLastButton={false}
|
||||
sx={{
|
||||
'& .MuiPagination-ul': {
|
||||
flexWrap: 'nowrap',
|
||||
@@ -184,7 +185,7 @@ class ProductList extends Component {
|
||||
px: 2
|
||||
}}>
|
||||
<Typography variant="h6" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||
Entferne Filter um Produkte zu sehen
|
||||
{this.props.t ? this.props.t('product.removeFiltersToSee') : 'Entferne Filter um Produkte zu sehen'}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
@@ -200,14 +201,14 @@ class ProductList extends Component {
|
||||
|
||||
if (!isFiltered) {
|
||||
// No filters applied
|
||||
if (filteredCount === 0) return "0 Produkte";
|
||||
if (filteredCount === 1) return "1 Produkt";
|
||||
return `${filteredCount} Produkte`;
|
||||
if (filteredCount === 0) return this.props.t ? this.props.t('product.countDisplay.noProducts') : "0 Produkte";
|
||||
if (filteredCount === 1) return this.props.t ? this.props.t('product.countDisplay.oneProduct') : "1 Produkt";
|
||||
return this.props.t ? this.props.t('product.countDisplay.multipleProducts', { count: filteredCount }) : `${filteredCount} Produkte`;
|
||||
} else {
|
||||
// Filters applied
|
||||
if (totalCount === 0) return "0 Produkte";
|
||||
if (totalCount === 1) return `${filteredCount} von 1 Produkt`;
|
||||
return `${filteredCount} von ${totalCount} Produkten`;
|
||||
if (totalCount === 0) return this.props.t ? this.props.t('product.countDisplay.noProducts') : "0 Produkte";
|
||||
if (totalCount === 1) return this.props.t ? this.props.t('product.countDisplay.filteredOneProduct', { filtered: filteredCount }) : `${filteredCount} von 1 Produkt`;
|
||||
return this.props.t ? this.props.t('product.countDisplay.filteredProducts', { filtered: filteredCount, total: totalCount }) : `${filteredCount} von ${totalCount} Produkten`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,13 +328,13 @@ class ProductList extends Component {
|
||||
minWidth: { xs: 120, sm: 140 }
|
||||
}}
|
||||
>
|
||||
<InputLabel id="sort-by-label">Sortierung</InputLabel>
|
||||
<InputLabel id="sort-by-label">{this.props.t ? this.props.t('filters.sorting') : 'Sortierung'}</InputLabel>
|
||||
<Select
|
||||
size="small"
|
||||
labelId="sort-by-label"
|
||||
value={(this.state.sortBy==='searchField')&&(window.currentSearchQuery)?this.state.sortBy:this.state.sortBy==='price-low-high'?this.state.sortBy:this.state.sortBy==='price-low-high'?this.state.sortBy:'name'}
|
||||
onChange={this.handleSortChange}
|
||||
label="Sortierung"
|
||||
label={this.props.t ? this.props.t('filters.sorting') : 'Sortierung'}
|
||||
MenuProps={{
|
||||
disableScrollLock: true,
|
||||
anchorOrigin: {
|
||||
@@ -353,10 +354,10 @@ class ProductList extends Component {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="name">Name</MenuItem>
|
||||
{window.currentSearchQuery && <MenuItem value="searchField">Suchbegriff</MenuItem>}
|
||||
<MenuItem value="price-low-high">Preis: Niedrig zu Hoch</MenuItem>
|
||||
<MenuItem value="price-high-low">Preis: Hoch zu Niedrig</MenuItem>
|
||||
<MenuItem value="name">{this.props.t ? this.props.t('sorting.name') : 'Name'}</MenuItem>
|
||||
{window.currentSearchQuery && <MenuItem value="searchField">{this.props.t ? this.props.t('sorting.searchField') : 'Suchbegriff'}</MenuItem>}
|
||||
<MenuItem value="price-low-high">{this.props.t ? this.props.t('sorting.priceLowHigh') : 'Preis: Niedrig zu Hoch'}</MenuItem>
|
||||
<MenuItem value="price-high-low">{this.props.t ? this.props.t('sorting.priceHighLow') : 'Preis: Hoch zu Niedrig'}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
@@ -368,12 +369,12 @@ class ProductList extends Component {
|
||||
minWidth: { xs: 80, sm: 100 }
|
||||
}}
|
||||
>
|
||||
<InputLabel id="products-per-page-label">pro Seite</InputLabel>
|
||||
<InputLabel id="products-per-page-label">{this.props.t ? this.props.t('filters.perPage') : 'pro Seite'}</InputLabel>
|
||||
<Select
|
||||
labelId="products-per-page-label"
|
||||
value={this.state.itemsPerPage}
|
||||
onChange={this.handleProductsPerPageChange}
|
||||
label="pro Seite"
|
||||
label={this.props.t ? this.props.t('filters.perPage') : 'pro Seite'}
|
||||
MenuProps={{
|
||||
disableScrollLock: true,
|
||||
anchorOrigin: {
|
||||
@@ -398,7 +399,7 @@ class ProductList extends Component {
|
||||
>
|
||||
<MenuItem value={20}>20</MenuItem>
|
||||
<MenuItem value={50}>50</MenuItem>
|
||||
<MenuItem value="all">Alle</MenuItem>
|
||||
<MenuItem value="all">{this.props.t ? this.props.t('filters.all') : 'Alle'}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
@@ -429,7 +430,7 @@ class ProductList extends Component {
|
||||
<Stack direction="row" spacing={2} sx={{ display: { xs: 'none', sm: 'flex' }, px: { xs: 1, sm: 0 } }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{/*this.props.dataType == 'category' && (<>Kategorie: {this.props.dataParam}</>)}*/}
|
||||
{this.props.dataType == 'search' && (<>Suchergebnisse für: "{this.props.dataParam}"</>)}
|
||||
{this.props.dataType == 'search' && (this.props.t ? this.props.t('search.searchResultsFor', { query: this.props.dataParam }) : `Suchergebnisse für: "${this.props.dataParam}"`)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{this.getProductCountText()}
|
||||
@@ -462,18 +463,21 @@ class ProductList extends Component {
|
||||
available={product.available}
|
||||
manufacturer={product.manufacturer}
|
||||
vat={product.vat}
|
||||
massMenge={product.massMenge}
|
||||
massEinheit={product.massEinheit}
|
||||
cGrundEinheit={product.cGrundEinheit}
|
||||
fGrundPreis={product.fGrundPreis}
|
||||
incoming={product.incomingDate}
|
||||
neu={product.neu}
|
||||
thc={product.thc}
|
||||
floweringWeeks={product.floweringWeeks}
|
||||
versandklasse={product.versandklasse}
|
||||
weight={product.weight}
|
||||
socket={this.props.socket}
|
||||
socketB={this.props.socketB}
|
||||
pictureList={product.pictureList}
|
||||
availableSupplier={product.availableSupplier}
|
||||
komponenten={product.komponenten}
|
||||
rebate={product.rebate}
|
||||
categoryId={product.kategorien ? product.kategorien.split(',')[0] : undefined}
|
||||
priority={index < 6 ? 'high' : 'auto'}
|
||||
t={this.props.t}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
@@ -495,4 +499,4 @@ class ProductList extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default ProductList;
|
||||
export default withI18n()(ProductList);
|
||||
427
src/components/SharedCarousel.js
Normal file
@@ -0,0 +1,427 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import ChevronLeft from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRight from "@mui/icons-material/ChevronRight";
|
||||
import CategoryBox from "./CategoryBox.js";
|
||||
import ProductCarousel from "./ProductCarousel.js";
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import { withLanguage } from '../i18n/withTranslation.js';
|
||||
|
||||
const ITEM_WIDTH = 130 + 16; // 130px width + 16px gap
|
||||
const AUTO_SCROLL_SPEED = 0.5; // px per frame (~60fps, so ~30px/sec)
|
||||
const AUTOSCROLL_RESTART_DELAY = 5000; // 5 seconds of inactivity before restarting autoscroll
|
||||
const SCROLLBAR_FLASH_DURATION = 3000; // 3 seconds to show the virtual scrollbar
|
||||
|
||||
class SharedCarousel extends React.Component {
|
||||
_isMounted = false;
|
||||
categories = [];
|
||||
originalCategories = [];
|
||||
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();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
|
||||
if (prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
|
||||
this.setState({ categories: [] }, () => {
|
||||
window.categoryService.get(209, this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
|
||||
console.log("response", response);
|
||||
if (response.children && response.children.length > 0) {
|
||||
this.originalCategories = response.children;
|
||||
this.categories = [...response.children, ...response.children];
|
||||
this.setState({ categories: this.categories });
|
||||
this.startAutoScroll();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
this.stopAutoScroll();
|
||||
this.clearInactivityTimer();
|
||||
this.clearScrollbarTimer();
|
||||
}
|
||||
|
||||
startAutoScroll = () => {
|
||||
this.autoScrollActive = true;
|
||||
if (!this.animationFrame) {
|
||||
this.animationFrame = requestAnimationFrame(this.handleAutoScroll);
|
||||
}
|
||||
};
|
||||
|
||||
stopAutoScroll = () => {
|
||||
this.autoScrollActive = false;
|
||||
if (this.animationFrame) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
this.animationFrame = null;
|
||||
}
|
||||
};
|
||||
|
||||
clearInactivityTimer = () => {
|
||||
if (this.inactivityTimer) {
|
||||
clearTimeout(this.inactivityTimer);
|
||||
this.inactivityTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
clearScrollbarTimer = () => {
|
||||
if (this.scrollbarTimer) {
|
||||
clearTimeout(this.scrollbarTimer);
|
||||
this.scrollbarTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
startInactivityTimer = () => {
|
||||
this.clearInactivityTimer();
|
||||
this.inactivityTimer = setTimeout(() => {
|
||||
if (this._isMounted) {
|
||||
this.startAutoScroll();
|
||||
}
|
||||
}, AUTOSCROLL_RESTART_DELAY);
|
||||
};
|
||||
|
||||
showScrollbarFlash = () => {
|
||||
this.clearScrollbarTimer();
|
||||
this.setState({ showScrollbar: true });
|
||||
|
||||
this.scrollbarTimer = setTimeout(() => {
|
||||
if (this._isMounted) {
|
||||
this.setState({ showScrollbar: false });
|
||||
}
|
||||
}, SCROLLBAR_FLASH_DURATION);
|
||||
};
|
||||
|
||||
handleAutoScroll = () => {
|
||||
if (!this.autoScrollActive || this.originalCategories.length === 0) return;
|
||||
|
||||
this.translateX -= AUTO_SCROLL_SPEED;
|
||||
this.updateTrackTransform();
|
||||
|
||||
const originalItemCount = this.originalCategories.length;
|
||||
const maxScroll = ITEM_WIDTH * originalItemCount;
|
||||
|
||||
// Check if we've scrolled past the first set of items
|
||||
if (Math.abs(this.translateX) >= maxScroll) {
|
||||
// Reset to beginning seamlessly
|
||||
this.translateX = 0;
|
||||
this.updateTrackTransform();
|
||||
}
|
||||
|
||||
this.animationFrame = requestAnimationFrame(this.handleAutoScroll);
|
||||
};
|
||||
|
||||
updateTrackTransform = () => {
|
||||
if (this.carouselTrackRef.current) {
|
||||
this.carouselTrackRef.current.style.transform = `translateX(${this.translateX}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
handleLeftClick = () => {
|
||||
this.stopAutoScroll();
|
||||
this.scrollBy(1);
|
||||
this.showScrollbarFlash();
|
||||
this.startInactivityTimer();
|
||||
};
|
||||
|
||||
handleRightClick = () => {
|
||||
this.stopAutoScroll();
|
||||
this.scrollBy(-1);
|
||||
this.showScrollbarFlash();
|
||||
this.startInactivityTimer();
|
||||
};
|
||||
|
||||
scrollBy = (direction) => {
|
||||
if (this.originalCategories.length === 0) return;
|
||||
|
||||
// direction: 1 = left (scroll content right), -1 = right (scroll content left)
|
||||
const originalItemCount = this.originalCategories.length;
|
||||
const maxScroll = ITEM_WIDTH * originalItemCount;
|
||||
|
||||
this.translateX += direction * ITEM_WIDTH;
|
||||
|
||||
// Handle wrap-around when scrolling left (positive translateX)
|
||||
if (this.translateX > 0) {
|
||||
this.translateX = -(maxScroll - ITEM_WIDTH);
|
||||
}
|
||||
// Handle wrap-around when scrolling right (negative translateX beyond limit)
|
||||
else if (Math.abs(this.translateX) >= maxScroll) {
|
||||
this.translateX = 0;
|
||||
}
|
||||
|
||||
this.updateTrackTransform();
|
||||
|
||||
// Force scrollbar to update immediately after wrap-around
|
||||
if (this.state.showScrollbar) {
|
||||
this.forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
renderVirtualScrollbar = () => {
|
||||
if (!this.state.showScrollbar || this.originalCategories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const originalItemCount = this.originalCategories.length;
|
||||
const viewportWidth = 1080; // carousel container max-width
|
||||
const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH);
|
||||
|
||||
// Calculate which item is currently at the left edge (first visible)
|
||||
// Map translateX directly to item index using the same logic as scrollBy
|
||||
let currentItemIndex;
|
||||
|
||||
if (this.translateX === 0) {
|
||||
// At the beginning - item 0 is visible
|
||||
currentItemIndex = 0;
|
||||
} else if (this.translateX > 0) {
|
||||
// Wrapped to show end items (this happens when scrolling left past beginning)
|
||||
const maxScroll = ITEM_WIDTH * originalItemCount;
|
||||
const effectivePosition = maxScroll + this.translateX;
|
||||
currentItemIndex = Math.floor(effectivePosition / ITEM_WIDTH);
|
||||
} else {
|
||||
// Normal negative scrolling - calculate which item is at left edge
|
||||
currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH);
|
||||
}
|
||||
|
||||
// Ensure we stay within bounds
|
||||
currentItemIndex = Math.max(0, Math.min(currentItemIndex, originalItemCount - 1));
|
||||
|
||||
// Calculate scrollbar position: 0% when item 0 is first visible, 100% when last item is first visible
|
||||
const lastPossibleFirstItem = Math.max(0, originalItemCount - itemsInView);
|
||||
const thumbPosition = lastPossibleFirstItem > 0 ? Math.min((currentItemIndex / lastPossibleFirstItem) * 100, 100) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="virtual-scrollbar"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '5px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '200px',
|
||||
height: '4px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: '2px',
|
||||
zIndex: 1000,
|
||||
opacity: this.state.showScrollbar ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease-in-out'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="scrollbar-thumb"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: `${thumbPosition}%`,
|
||||
width: '20px',
|
||||
height: '4px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: '2px',
|
||||
transform: 'translateX(-50%)',
|
||||
transition: 'left 0.2s ease-out'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
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>
|
||||
|
||||
<div
|
||||
className="carousel-wrapper"
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
padding: '0 20px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
{/* Left Arrow */}
|
||||
<IconButton
|
||||
aria-label="Vorherige Kategorien 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 Kategorien 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="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>
|
||||
|
||||
{/* 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 Grid from '@mui/material/Grid';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { Link } from 'react-router-dom';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
|
||||
class ExtrasSelector extends Component {
|
||||
formatPrice(price) {
|
||||
@@ -16,127 +17,178 @@ class ExtrasSelector extends Component {
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
// Render product image using working code from GrowTentKonfigurator
|
||||
renderProductImage(product) {
|
||||
if (!window.smallPicCache) {
|
||||
window.smallPicCache = {};
|
||||
}
|
||||
|
||||
const pictureList = product.pictureList;
|
||||
|
||||
if (!pictureList || pictureList.length === 0 || !pictureList.split(',').length) {
|
||||
return (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="160"
|
||||
image="/assets/images/nopicture.jpg"
|
||||
alt={product.name || 'Produktbild'}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
width: '100%'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const bildId = pictureList.split(',')[0];
|
||||
|
||||
if (window.smallPicCache[bildId]) {
|
||||
return (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="160"
|
||||
image={window.smallPicCache[bildId]}
|
||||
alt={product.name || 'Produktbild'}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
width: '100%'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Load image if not cached
|
||||
if (!this.loadingImages) this.loadingImages = new Set();
|
||||
if (!this.loadingImages.has(bildId)) {
|
||||
this.loadingImages.add(bildId);
|
||||
window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => {
|
||||
if (res.success) {
|
||||
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
|
||||
this.forceUpdate();
|
||||
}
|
||||
this.loadingImages.delete(bildId);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '160px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CircularProgress sx={{ color: '#90ffc0' }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
renderExtraCard(extra) {
|
||||
const { selectedExtras, onExtraToggle, showImage = true } = this.props;
|
||||
const isSelected = selectedExtras.includes(extra.id);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={extra.id}
|
||||
sx={{
|
||||
<Box sx={{
|
||||
width: { xs: '100%', sm: '250px' },
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
border: '2px solid',
|
||||
borderColor: isSelected ? '#2e7d32' : '#e0e0e0',
|
||||
backgroundColor: isSelected ? '#f1f8e9' : '#ffffff',
|
||||
'&:hover': {
|
||||
boxShadow: 5,
|
||||
boxShadow: 6,
|
||||
borderColor: isSelected ? '#2e7d32' : '#90caf9'
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer'
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
onClick={() => onExtraToggle(extra.id)}
|
||||
>
|
||||
onClick={() => onExtraToggle(extra.id)}>
|
||||
{/* Image */}
|
||||
{showImage && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="160"
|
||||
image={extra.image}
|
||||
alt={extra.name}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onExtraToggle(extra.id);
|
||||
}}
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
'&.Mui-checked': { color: '#2e7d32' },
|
||||
padding: 0
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label=""
|
||||
sx={{ margin: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{this.formatPrice(extra.price)}
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
height: { xs: '240px', sm: '180px' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#ffffff'
|
||||
}}>
|
||||
{this.renderProductImage(extra)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Name */}
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{extra.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{extra.description}
|
||||
|
||||
<Typography gutterBottom>
|
||||
{extra.kurzBeschreibung}
|
||||
</Typography>
|
||||
|
||||
{isSelected && (
|
||||
<Box sx={{ mt: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body2" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
✓ Hinzugefügt
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Price with VAT - Same as other sections */}
|
||||
<Typography variant="h6" sx={{
|
||||
color: '#2e7d32',
|
||||
fontWeight: 'bold',
|
||||
mt: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<span>{extra.price ? this.formatPrice(extra.price) : 'Kein Preis'}</span>
|
||||
{extra.vat && (
|
||||
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>
|
||||
(incl. {extra.vat}% MwSt.,*)
|
||||
</small>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Typography>
|
||||
|
||||
{/* Selection Indicator - Separate line */}
|
||||
{isSelected && (
|
||||
<Typography variant="body2" sx={{
|
||||
color: '#2e7d32',
|
||||
fontWeight: 'bold',
|
||||
mt: 1,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
✓ Ausgewählt
|
||||
</Typography>
|
||||
)}
|
||||
<Stack direction="row" spacing={1} justifyContent="center">
|
||||
<IconButton
|
||||
component={Link}
|
||||
to={`/Artikel/${extra.seoName}`}
|
||||
size="small"
|
||||
aria-label="Produktdetails anzeigen"
|
||||
sx={{ mr: 1, color: 'text.secondary' }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ZoomInIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { extras, title, subtitle, groupByCategory = true, gridSize = { xs: 12, sm: 6, md: 4 } } = this.props;
|
||||
|
||||
if (groupByCategory) {
|
||||
// Group extras by category
|
||||
const groupedExtras = extras.reduce((acc, extra) => {
|
||||
if (!acc[extra.category]) {
|
||||
acc[extra.category] = [];
|
||||
}
|
||||
acc[extra.category].push(extra);
|
||||
return acc;
|
||||
}, {});
|
||||
const { extras, title, subtitle, gridSize = { xs: 12, sm: 6, md: 4 } } = this.props;
|
||||
|
||||
if (!extras || !Array.isArray(extras)) {
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{subtitle}
|
||||
Keine Extras verfügbar
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{Object.entries(groupedExtras).map(([category, categoryExtras]) => (
|
||||
<Box key={category} sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ color: '#1976d2' }}>
|
||||
{category}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{categoryExtras.map(extra => (
|
||||
<Grid item {...gridSize} key={extra.id}>
|
||||
{this.renderExtraCard(extra)}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Render without category grouping
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
|
||||
@@ -6,6 +6,10 @@ import CardMedia from '@mui/material/CardMedia';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { Link } from 'react-router-dom';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
|
||||
class ProductSelector extends Component {
|
||||
formatPrice(price) {
|
||||
@@ -65,6 +69,19 @@ class ProductSelector extends Component {
|
||||
✓ Ausgewählt
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={1} justifyContent="center">
|
||||
<IconButton
|
||||
component={Link}
|
||||
to={`/Artikel/${product.seoName}`}
|
||||
size="small"
|
||||
aria-label="Produktdetails anzeigen"
|
||||
sx={{ mr: 1, color: 'text.secondary' }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ZoomInIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -147,7 +164,7 @@ class ProductSelector extends Component {
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import CardContent from '@mui/material/CardContent';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import { withI18n } from '../../i18n/withTranslation.js';
|
||||
|
||||
class TentShapeSelector extends Component {
|
||||
// Generate plant layout based on tent shape
|
||||
@@ -90,7 +91,7 @@ class TentShapeSelector extends Component {
|
||||
onClick={() => onShapeSelect(shape.id)}
|
||||
>
|
||||
<CardContent sx={{ textAlign: 'center', p: 3 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="h4" component="h4" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{shape.name}
|
||||
</Typography>
|
||||
|
||||
@@ -180,12 +181,20 @@ class TentShapeSelector extends Component {
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{shape.description}
|
||||
{this.props.t && shape.descriptionKey ? this.props.t(shape.descriptionKey) : shape.description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Chip
|
||||
label={`${shape.minPlants}-${shape.maxPlants} Pflanzen`}
|
||||
label={this.props.t
|
||||
? (
|
||||
shape.minPlants === 1 && shape.maxPlants === 2 ? this.props.t("kitConfig.plants1to2") :
|
||||
shape.minPlants === 2 && shape.maxPlants === 4 ? this.props.t("kitConfig.plants2to4") :
|
||||
shape.minPlants === 4 && shape.maxPlants === 6 ? this.props.t("kitConfig.plants4to6") :
|
||||
shape.minPlants === 3 && shape.maxPlants === 6 ? this.props.t("kitConfig.plants3to6") :
|
||||
`${shape.minPlants}-${shape.maxPlants} Pflanzen`
|
||||
)
|
||||
: `${shape.minPlants}-${shape.maxPlants} Pflanzen`}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: isSelected ? '#2e7d32' : '#f0f0f0',
|
||||
@@ -205,7 +214,7 @@ class TentShapeSelector extends Component {
|
||||
transition: 'opacity 0.3s ease'
|
||||
}}
|
||||
>
|
||||
✓ Ausgewählt
|
||||
{this.props.t ? this.props.t("kitConfig.selected") : "✓ Ausgewählt"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
@@ -218,7 +227,7 @@ class TentShapeSelector extends Component {
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
@@ -238,4 +247,4 @@ class TentShapeSelector extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default TentShapeSelector;
|
||||
export default withI18n()(TentShapeSelector);
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Component } from 'react';
|
||||
import React, { Component, lazy } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
@@ -8,9 +8,18 @@ import Typography from '@mui/material/Typography';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import LoginComponent from '../LoginComponent.js';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { Suspense } from 'react';
|
||||
//import LoginComponent from '../LoginComponent.js';
|
||||
|
||||
|
||||
const LoginComponent = lazy(() => import(/* webpackChunkName: "login" */ "../LoginComponent.js"));
|
||||
|
||||
|
||||
import CartDropdown from '../CartDropdown.js';
|
||||
import LanguageSwitcher from '../LanguageSwitcher.js';
|
||||
import { isUserLoggedIn } from '../LoginComponent.js';
|
||||
import { withI18n } from '../../i18n/withTranslation.js';
|
||||
|
||||
function getBadgeNumber() {
|
||||
let count = 0;
|
||||
@@ -32,9 +41,8 @@ class ButtonGroup extends Component {
|
||||
|
||||
componentDidMount() {
|
||||
this.cart = () => {
|
||||
// @note Only emit if socket exists, is connected, AND the update didn't come from socket
|
||||
if (this.props.socket && this.props.socket.connected && !this.isUpdatingFromSocket) {
|
||||
this.props.socket.emit('updateCart', window.cart);
|
||||
if (!this.isUpdatingFromSocket) {
|
||||
window.socketManager.emit('updateCart', window.cart);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
@@ -51,19 +59,6 @@ class ButtonGroup extends Component {
|
||||
this.addSocketListeners();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected) {
|
||||
// Socket just connected, add listeners
|
||||
this.addSocketListeners();
|
||||
} else if (wasConnected && !isNowConnected) {
|
||||
// Socket just disconnected, remove listeners
|
||||
this.removeSocketListeners();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('cart', this.cart);
|
||||
@@ -72,16 +67,17 @@ class ButtonGroup extends Component {
|
||||
}
|
||||
|
||||
addSocketListeners = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
// Remove existing listeners first to avoid duplicates
|
||||
this.removeSocketListeners();
|
||||
this.props.socket.on('cartUpdated', this.handleCartUpdated);
|
||||
|
||||
if (window.socketManager) {
|
||||
window.socketManager.on('cartUpdated', this.handleCartUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
removeSocketListeners = () => {
|
||||
if (this.props.socket) {
|
||||
this.props.socket.off('cartUpdated', this.handleCartUpdated);
|
||||
if (window.socketManager) {
|
||||
window.socketManager.off('cartUpdated', this.handleCartUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,19 +112,22 @@ class ButtonGroup extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { socket, navigate } = this.props;
|
||||
const { navigate, t } = this.props;
|
||||
const { isCartOpen } = this.state;
|
||||
const cartItems = Array.isArray(window.cart) ? window.cart : [];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: { xs: 0.5, sm: 1 } }}>
|
||||
|
||||
|
||||
<LoginComponent socket={socket} />
|
||||
<LanguageSwitcher />
|
||||
<Suspense fallback={<CircularProgress size={20} />}>
|
||||
<LoginComponent/>
|
||||
</Suspense>
|
||||
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.toggleCart}
|
||||
aria-label="Warenkorb öffnen"
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<Badge badgeContent={this.state.badgeNumber} color="error">
|
||||
@@ -154,6 +153,7 @@ class ButtonGroup extends Component {
|
||||
<IconButton
|
||||
onClick={this.toggleCart}
|
||||
size="small"
|
||||
aria-label="Warenkorb schließen"
|
||||
sx={{
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
@@ -164,16 +164,16 @@ class ButtonGroup extends Component {
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6">Warenkorb</Typography>
|
||||
<Typography variant="h6">{t ? t('cart.title') : 'Warenkorb'}</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<CartDropdown cartItems={cartItems} socket={socket} onClose={this.toggleCart} onCheckout={()=>{
|
||||
<CartDropdown cartItems={cartItems} onClose={this.toggleCart} onCheckout={()=>{
|
||||
/*open the Drawer inside <LoginComponent */
|
||||
|
||||
if (isUserLoggedIn().isLoggedIn) {
|
||||
this.toggleCart(); // Close the cart drawer
|
||||
navigate('/profile');
|
||||
navigate('/profile#cart');
|
||||
} else if (window.openLoginDrawer) {
|
||||
window.openLoginDrawer(); // Call global function to open login drawer
|
||||
this.toggleCart(); // Close the cart drawer
|
||||
@@ -189,10 +189,11 @@ class ButtonGroup extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper for ButtonGroup to provide navigate function
|
||||
// Wrapper for ButtonGroup to provide navigate function and translations
|
||||
const ButtonGroupWithRouter = (props) => {
|
||||
const navigate = useNavigate();
|
||||
return <ButtonGroup {...props} navigate={navigate} />;
|
||||
const ButtonGroupWithTranslation = withI18n()(ButtonGroup);
|
||||
return <ButtonGroupWithTranslation {...props} navigate={navigate} />;
|
||||
};
|
||||
|
||||
export default ButtonGroupWithRouter;
|
||||
@@ -6,316 +6,137 @@ import Typography from "@mui/material/Typography";
|
||||
import Collapse from "@mui/material/Collapse";
|
||||
import { Link } from "react-router-dom";
|
||||
import HomeIcon from "@mui/icons-material/Home";
|
||||
import FiberNewIcon from '@mui/icons-material/FiberNew';
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { withI18n } from "../../i18n/withTranslation.js";
|
||||
|
||||
class CategoryList extends Component {
|
||||
findCategoryById = (category, targetId) => {
|
||||
if (!category) return null;
|
||||
|
||||
if (category.seoName === targetId) {
|
||||
return category;
|
||||
}
|
||||
|
||||
if (category.children) {
|
||||
for (let child of category.children) {
|
||||
const found = this.findCategoryById(child, targetId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
getPathToCategory = (category, targetId, currentPath = []) => {
|
||||
if (!category) return null;
|
||||
|
||||
const newPath = [...currentPath, category];
|
||||
|
||||
if (category.seoName === targetId) {
|
||||
return newPath;
|
||||
}
|
||||
|
||||
if (category.children) {
|
||||
for (let child of category.children) {
|
||||
const found = this.getPathToCategory(child, targetId, newPath);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
//const { i18n } = props;
|
||||
const categories = window.categoryService.getSync(209);
|
||||
|
||||
// Check for cached data during SSR/initial render
|
||||
let initialState = {
|
||||
categoryTree: null,
|
||||
level1Categories: [], // Children of category 209 (Home) - always shown
|
||||
level2Categories: [], // Children of active level 1 category
|
||||
level3Categories: [], // Children of active level 2 category
|
||||
activePath: [], // Array of active category objects for each level
|
||||
fetchedCategories: false,
|
||||
mobileMenuOpen: false, // State for mobile collapsible menu
|
||||
this.state = {
|
||||
categories: categories && categories.children && categories.children.length > 0 ? categories.children : [],
|
||||
mobileMenuOpen: false,
|
||||
activeCategoryId: null // Will be set properly after categories are loaded
|
||||
};
|
||||
|
||||
// Try to get cached data for SSR
|
||||
try {
|
||||
// @note Check both global.window (SSR) and window (browser) for cache
|
||||
const productCache = (typeof global !== "undefined" && global.window && global.window.productCache) ||
|
||||
(typeof window !== "undefined" && window.productCache);
|
||||
|
||||
if (productCache) {
|
||||
const cacheKey = "categoryTree_209";
|
||||
const cachedData = productCache[cacheKey];
|
||||
if (cachedData && cachedData.categoryTree) {
|
||||
const { categoryTree, timestamp } = cachedData;
|
||||
const cacheAge = Date.now() - timestamp;
|
||||
const tenMinutes = 10 * 60 * 1000;
|
||||
|
||||
// Use cached data if it's fresh
|
||||
if (cacheAge < tenMinutes) {
|
||||
initialState.categoryTree = categoryTree;
|
||||
initialState.fetchedCategories = true;
|
||||
|
||||
// Process category tree to set up navigation
|
||||
const level1Categories =
|
||||
categoryTree && categoryTree.id === 209
|
||||
? categoryTree.children || []
|
||||
: [];
|
||||
initialState.level1Categories = level1Categories;
|
||||
|
||||
// Process active category path if needed
|
||||
if (props.activeCategoryId) {
|
||||
const activeCategory = this.findCategoryById(
|
||||
categoryTree,
|
||||
props.activeCategoryId
|
||||
);
|
||||
if (activeCategory) {
|
||||
const pathToActive = this.getPathToCategory(
|
||||
categoryTree,
|
||||
props.activeCategoryId
|
||||
);
|
||||
initialState.activePath = pathToActive
|
||||
? pathToActive.slice(1)
|
||||
: [];
|
||||
|
||||
if (initialState.activePath.length >= 1) {
|
||||
const level1Category = initialState.activePath[0];
|
||||
initialState.level2Categories = level1Category.children || [];
|
||||
}
|
||||
|
||||
if (initialState.activePath.length >= 2) {
|
||||
const level2Category = initialState.activePath[1];
|
||||
initialState.level3Categories = level2Category.children || [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error reading cache in constructor:", err);
|
||||
}
|
||||
|
||||
this.state = initialState;
|
||||
this.productCategoryCheckInterval = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchCategories();
|
||||
console.log("CategoryList componentDidMount - Debug info:");
|
||||
console.log(" languageContext:", this.props.languageContext);
|
||||
console.log(" i18n.language:", this.props.i18n?.language);
|
||||
console.log(" sessionStorage i18nextLng:", typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('i18nextLng') : 'N/A');
|
||||
console.log(" localStorage i18nextLng:", typeof localStorage !== 'undefined' ? localStorage.getItem('i18nextLng') : 'N/A');
|
||||
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
|
||||
|
||||
// ALWAYS reload categories to ensure correct language
|
||||
console.log("CategoryList componentDidMount: ALWAYS RELOADING categories for language", currentLanguage);
|
||||
this.setState({ categories: [] }); // Clear any cached categories
|
||||
window.categoryService.get(209, currentLanguage).then((response) => {
|
||||
console.log("categoryService.get response for language '" + currentLanguage + "':", response);
|
||||
if (response.children && response.children.length > 0) {
|
||||
console.log("Setting categories with", response.children.length, "items");
|
||||
console.log("First category name:", response.children[0]?.name);
|
||||
this.setState({
|
||||
categories: response.children,
|
||||
activeCategoryId: this.setLevel1CategoryId(this.props.activeCategoryId)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Handle 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();
|
||||
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
|
||||
if (prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
|
||||
this.setState({
|
||||
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 activeCategoryId changes, update subcategories
|
||||
if (
|
||||
prevProps.activeCategoryId !== this.props.activeCategoryId &&
|
||||
this.state.categoryTree
|
||||
) {
|
||||
this.processCategoryTree(this.state.categoryTree);
|
||||
if (prevProps.activeCategoryId !== this.props.activeCategoryId) {
|
||||
this.setLevel1CategoryId(this.props.activeCategoryId);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
setLevel1CategoryId = (input) => {
|
||||
if (input) {
|
||||
const language = this.props.languageContext?.currentLanguage || this.props.i18n.language;
|
||||
const categoryTreeCache = window.categoryService.getSync(209, language);
|
||||
|
||||
// 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 (categoryTreeCache && categoryTreeCache.children) {
|
||||
let level1CategoryId = null;
|
||||
|
||||
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);
|
||||
// Check if input is already a numeric level 1 category ID
|
||||
const inputAsNumber = parseInt(input);
|
||||
if (!isNaN(inputAsNumber)) {
|
||||
// Check if this is already a level 1 category ID
|
||||
const level1Category = categoryTreeCache.children.find(cat => cat.id === inputAsNumber);
|
||||
if (level1Category) {
|
||||
console.log("Input is already a level 1 category ID:", inputAsNumber);
|
||||
level1CategoryId = inputAsNumber;
|
||||
} else {
|
||||
try {
|
||||
const cacheKey = "categoryTree_209";
|
||||
if (windowObj && windowObj.productCache) {
|
||||
windowObj.productCache[cacheKey] = {
|
||||
categoryTree: null,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
// 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);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error writing to cache:", err);
|
||||
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);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
categoryTree: null,
|
||||
level1Categories: [],
|
||||
level2Categories: [],
|
||||
level3Categories: [],
|
||||
activePath: [],
|
||||
activeCategoryId: level1CategoryId
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
processCategoryTree = (categoryTree) => {
|
||||
// Level 1 categories are always the children of category 209 (Home)
|
||||
const level1Categories =
|
||||
categoryTree && categoryTree.id === 209
|
||||
? categoryTree.children || []
|
||||
: [];
|
||||
|
||||
// Build the navigation path and determine what to show at each level
|
||||
let level2Categories = [];
|
||||
let level3Categories = [];
|
||||
let activePath = [];
|
||||
|
||||
if (this.props.activeCategoryId) {
|
||||
const activeCategory = this.findCategoryById(
|
||||
categoryTree,
|
||||
this.props.activeCategoryId
|
||||
);
|
||||
if (activeCategory) {
|
||||
// Build the path from root to active category
|
||||
const pathToActive = this.getPathToCategory(
|
||||
categoryTree,
|
||||
this.props.activeCategoryId
|
||||
);
|
||||
activePath = pathToActive.slice(1); // Remove root (209) from path
|
||||
|
||||
// Determine what to show at each level based on the path depth
|
||||
if (activePath.length >= 1) {
|
||||
// Show children of the level 1 category
|
||||
const level1Category = activePath[0];
|
||||
level2Categories = level1Category.children || [];
|
||||
}
|
||||
|
||||
if (activePath.length >= 2) {
|
||||
// Show children of the level 2 category
|
||||
const level2Category = activePath[1];
|
||||
level3Categories = level2Category.children || [];
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.setState({ activeCategoryId: null });
|
||||
}
|
||||
|
||||
this.setState({
|
||||
categoryTree,
|
||||
level1Categories,
|
||||
level2Categories,
|
||||
level3Categories,
|
||||
activePath,
|
||||
fetchedCategories: true,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
handleMobileMenuToggle = () => {
|
||||
this.setState(prevState => ({
|
||||
@@ -330,28 +151,28 @@ class CategoryList extends Component {
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { level1Categories, activePath, mobileMenuOpen } =
|
||||
this.state;
|
||||
componentWillUnmount() {
|
||||
if (this.productCategoryCheckInterval) {
|
||||
clearInterval(this.productCategoryCheckInterval);
|
||||
this.productCategoryCheckInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
const renderCategoryRow = (categories, level = 1, isMobile = false) => (
|
||||
render() {
|
||||
const { categories, mobileMenuOpen, activeCategoryId } = this.state;
|
||||
|
||||
const renderCategoryRow = (categories, isMobile = false) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
flexWrap: isMobile ? "wrap" : "nowrap",
|
||||
overflowX: isMobile ? "visible" : "auto",
|
||||
flexWrap: "wrap",
|
||||
overflowX: "visible",
|
||||
flexDirection: isMobile ? "column" : "row",
|
||||
py: 0.5, // Add vertical padding to prevent border clipping
|
||||
"&::-webkit-scrollbar": {
|
||||
display: "none",
|
||||
},
|
||||
scrollbarWidth: "none",
|
||||
msOverflowStyle: "none",
|
||||
}}
|
||||
>
|
||||
{level === 1 && (
|
||||
<Button
|
||||
component={Link}
|
||||
to="/"
|
||||
@@ -372,7 +193,7 @@ class CategoryList extends Component {
|
||||
transition: "all 0.2s ease",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
|
||||
position: "relative",
|
||||
...(this.props.activeCategoryId === null && {
|
||||
...(activeCategoryId === null && {
|
||||
bgcolor: "#fff",
|
||||
textShadow: "none",
|
||||
opacity: 1,
|
||||
@@ -396,7 +217,7 @@ class CategoryList extends Component {
|
||||
<HomeIcon sx={{
|
||||
fontSize: "1rem",
|
||||
mr: isMobile ? 1 : 0,
|
||||
color: this.props.activeCategoryId === null ? "#2e7d32" : "inherit"
|
||||
color: activeCategoryId === null ? "#2e7d32" : "inherit"
|
||||
}} />
|
||||
{isMobile && (
|
||||
<Box sx={{ position: "relative", display: "inline-block" }}>
|
||||
@@ -405,38 +226,93 @@ class CategoryList extends Component {
|
||||
className="bold-text"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
color: this.props.activeCategoryId === null ? "#2e7d32" : "transparent",
|
||||
color: activeCategoryId === null ? "#2e7d32" : "transparent",
|
||||
position: "relative",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
Startseite
|
||||
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
|
||||
</Box>
|
||||
{/* Thin text (positioned on top) */}
|
||||
<Box
|
||||
className="thin-text"
|
||||
sx={{
|
||||
fontWeight: "400",
|
||||
color: this.props.activeCategoryId === null ? "transparent" : "inherit",
|
||||
color: activeCategoryId === null ? "transparent" : "inherit",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
Startseite
|
||||
{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>
|
||||
)}
|
||||
{this.state.fetchedCategories && categories.length > 0 ? (
|
||||
</Button>
|
||||
|
||||
|
||||
{categories.length > 0 ? (
|
||||
<>
|
||||
{categories.map((category) => {
|
||||
// Determine if this category is active at this level
|
||||
const isActiveAtThisLevel =
|
||||
activePath[level - 1] &&
|
||||
activePath[level - 1].id === category.id;
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -459,7 +335,7 @@ class CategoryList extends Component {
|
||||
transition: "all 0.2s ease",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
|
||||
position: "relative",
|
||||
...(isActiveAtThisLevel && {
|
||||
...(activeCategoryId === category.id && {
|
||||
bgcolor: "#fff",
|
||||
textShadow: "none",
|
||||
opacity: 1,
|
||||
@@ -483,7 +359,7 @@ class CategoryList extends Component {
|
||||
className="bold-text"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
color: isActiveAtThisLevel ? "#2e7d32" : "transparent",
|
||||
color: activeCategoryId === category.id ? "#2e7d32" : "transparent",
|
||||
position: "relative",
|
||||
zIndex: 2,
|
||||
}}
|
||||
@@ -495,7 +371,7 @@ class CategoryList extends Component {
|
||||
className="thin-text"
|
||||
sx={{
|
||||
fontWeight: "400",
|
||||
color: isActiveAtThisLevel ? "transparent" : "inherit",
|
||||
color: activeCategoryId === category.id ? "transparent" : "inherit",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
@@ -509,15 +385,14 @@ class CategoryList extends Component {
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
level === 1 && !isMobile && (
|
||||
) : (!isMobile && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="inherit"
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
height: "30px", // Match small button height
|
||||
height: "33px", // Match small button height
|
||||
px: 1,
|
||||
fontSize: "0.75rem",
|
||||
opacity: 0.9,
|
||||
@@ -527,6 +402,84 @@ class CategoryList extends Component {
|
||||
</Typography>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
component={Link}
|
||||
to="/Konfigurator"
|
||||
color="inherit"
|
||||
size="small"
|
||||
aria-label="Zur Startseite"
|
||||
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
mx: isMobile ? 0 : 0.5,
|
||||
my: 0.25,
|
||||
minWidth: isMobile ? "100%" : "auto",
|
||||
borderRadius: 1,
|
||||
justifyContent: isMobile ? "flex-start" : "center",
|
||||
transition: "all 0.2s ease",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
|
||||
position: "relative",
|
||||
...(activeCategoryId === null && {
|
||||
bgcolor: "#fff",
|
||||
textShadow: "none",
|
||||
opacity: 1,
|
||||
}),
|
||||
"&:hover": {
|
||||
opacity: 1,
|
||||
bgcolor: "#fff",
|
||||
textShadow: "none",
|
||||
"& .MuiSvgIcon-root": {
|
||||
color: "#2e7d32 !important",
|
||||
},
|
||||
"& .bold-text": {
|
||||
color: "#2e7d32 !important",
|
||||
},
|
||||
"& .thin-text": {
|
||||
color: "transparent !important",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SettingsIcon sx={{
|
||||
fontSize: "1rem",
|
||||
mr: isMobile ? 1 : 0,
|
||||
color: activeCategoryId === null ? "#2e7d32" : "inherit"
|
||||
}} />
|
||||
{isMobile && (
|
||||
<Box sx={{ position: "relative", display: "inline-block" }}>
|
||||
{/* Bold text (always rendered to set width) */}
|
||||
<Box
|
||||
className="bold-text"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
color: activeCategoryId === null ? "#2e7d32" : "transparent",
|
||||
position: "relative",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
|
||||
</Box>
|
||||
{/* Thin text (positioned on top) */}
|
||||
<Box
|
||||
className="thin-text"
|
||||
sx={{
|
||||
fontWeight: "400",
|
||||
color: activeCategoryId === null ? "transparent" : "inherit",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -549,25 +502,7 @@ class CategoryList extends Component {
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg" sx={{ px: 2 }}>
|
||||
{/* Level 1 Categories Row - Always shown */}
|
||||
{renderCategoryRow(level1Categories, 1, false)}
|
||||
|
||||
{/* Level 2 Categories Row - Show when level 1 is selected */}
|
||||
{/* DISABLED FOR NOW
|
||||
{level2Categories.length > 0 && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
{renderCategoryRow(level2Categories, 2, false)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Level 3 Categories Row - Show when level 2 is selected */}
|
||||
{/* DISABLED FOR NOW
|
||||
{level3Categories.length > 0 && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
{renderCategoryRow(level3Categories, 3, false)}
|
||||
</Box>
|
||||
)}
|
||||
*/}
|
||||
{renderCategoryRow(categories, false)}
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
@@ -595,7 +530,10 @@ class CategoryList extends Component {
|
||||
onClick={this.handleMobileMenuToggle}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={mobileMenuOpen ? "Kategorien schließen" : "Kategorien öffnen"}
|
||||
aria-label={this.props.t ?
|
||||
(mobileMenuOpen ? this.props.t('navigation.categoriesClose') : this.props.t('navigation.categoriesOpen')) :
|
||||
(mobileMenuOpen ? "Kategorien schließen" : "Kategorien öffnen")
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
@@ -607,7 +545,7 @@ class CategoryList extends Component {
|
||||
fontWeight: "bold",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.3)"
|
||||
}}>
|
||||
Kategorien
|
||||
{this.props.t ? this.props.t('navigation.categories') : 'Kategorien'}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
|
||||
@@ -618,7 +556,7 @@ class CategoryList extends Component {
|
||||
<Collapse in={mobileMenuOpen}>
|
||||
<Box sx={{ pb: 2 }}>
|
||||
{/* Level 1 Categories - Only level shown in mobile menu */}
|
||||
{renderCategoryRow(level1Categories, 1, true)}
|
||||
{renderCategoryRow(categories, true)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Container>
|
||||
@@ -628,4 +566,4 @@ class CategoryList extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default CategoryList;
|
||||
export default withI18n()(CategoryList);
|
||||
|
||||
@@ -16,9 +16,11 @@ const Logo = () => {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/assets/images/sh.png"
|
||||
src="/assets/images/sh.avif"
|
||||
alt="SH Logo"
|
||||
style={{ height: "45px" }}
|
||||
width="108px"
|
||||
height="45px"
|
||||
style={{ width: "108px", height: "45px" }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -7,16 +7,19 @@ import List from "@mui/material/List";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import KeyboardReturnIcon from "@mui/icons-material/KeyboardReturn";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import SocketContext from "../../contexts/SocketContext.js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LanguageContext } from "../../i18n/withTranslation.js";
|
||||
|
||||
const SearchBar = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const context = React.useContext(SocketContext);
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const { t, i18n } = useTranslation();
|
||||
const languageContext = React.useContext(LanguageContext);
|
||||
|
||||
// State management
|
||||
const [searchQuery, setSearchQuery] = React.useState(
|
||||
@@ -25,7 +28,6 @@ const SearchBar = () => {
|
||||
const [suggestions, setSuggestions] = React.useState([]);
|
||||
const [showSuggestions, setShowSuggestions] = React.useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(-1);
|
||||
const [loadingSuggestions, setLoadingSuggestions] = React.useState(false);
|
||||
|
||||
// Refs for debouncing and timers
|
||||
const debounceTimerRef = React.useRef(null);
|
||||
@@ -58,27 +60,26 @@ const SearchBar = () => {
|
||||
// @note Autocomplete function using getSearchProducts Socket.io API - returns objects with name and seoName
|
||||
const fetchAutocomplete = React.useCallback(
|
||||
(query) => {
|
||||
if (!context || !context.socket || !context.socket.connected || !query || query.length < 2) {
|
||||
if (!query || query.length < 2) {
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
setLoadingSuggestions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingSuggestions(true);
|
||||
const currentLanguage = languageContext?.currentLanguage || i18n?.language || 'de';
|
||||
|
||||
context.socket.emit(
|
||||
window.socketManager.emit(
|
||||
"getSearchProducts",
|
||||
{
|
||||
query: query.trim(),
|
||||
limit: 8,
|
||||
language: currentLanguage,
|
||||
requestTranslation: currentLanguage === 'de' ? false : true,
|
||||
},
|
||||
(response) => {
|
||||
setLoadingSuggestions(false);
|
||||
|
||||
if (response && response.products) {
|
||||
// getSearchProducts returns response.products array
|
||||
const suggestions = response.products.slice(0, 8); // Limit to 8 suggestions
|
||||
const suggestions = response.products.map(p => p.translatedProduct || p).slice(0, 8); // Limit to 8 suggestions
|
||||
setSuggestions(suggestions);
|
||||
setShowSuggestions(suggestions.length > 0);
|
||||
setSelectedIndex(-1); // Reset selection
|
||||
@@ -90,7 +91,7 @@ const SearchBar = () => {
|
||||
}
|
||||
);
|
||||
},
|
||||
[context]
|
||||
[languageContext, i18n]
|
||||
);
|
||||
|
||||
const handleSearchChange = (e) => {
|
||||
@@ -184,6 +185,24 @@ const SearchBar = () => {
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// Get delivery days based on availability
|
||||
const getDeliveryDays = (product) => {
|
||||
if (product.available === 1) {
|
||||
return t('delivery.times.standard2to3Days');
|
||||
} else if (product.incoming === 1 || product.availableSupplier === 1) {
|
||||
return t('delivery.times.supplier7to9Days');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle enter icon click
|
||||
const handleEnterClick = () => {
|
||||
delete window.currentSearchQuery;
|
||||
setShowSuggestions(false);
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchQuery)}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up timers on unmount
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
@@ -225,7 +244,7 @@ const SearchBar = () => {
|
||||
>
|
||||
<TextField
|
||||
ref={inputRef}
|
||||
placeholder="Produkte suchen..."
|
||||
placeholder={t('search.searchProducts')}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
@@ -244,9 +263,22 @@ const SearchBar = () => {
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: loadingSuggestions && (
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<CircularProgress size={16} />
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleEnterClick}
|
||||
aria-label="Suche starten"
|
||||
sx={{
|
||||
p: 0.5,
|
||||
color: "text.secondary",
|
||||
"&:hover": {
|
||||
color: "primary.main",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<KeyboardReturnIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
sx: { borderRadius: 2, bgcolor: "background.paper" },
|
||||
@@ -264,8 +296,6 @@ const SearchBar = () => {
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1300,
|
||||
maxHeight: "300px",
|
||||
overflow: "auto",
|
||||
mt: 0.5,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
@@ -273,12 +303,19 @@ const SearchBar = () => {
|
||||
<List disablePadding>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<ListItem
|
||||
key={suggestion.seoName || index}
|
||||
button
|
||||
key={`${suggestion.seoName || 'suggestion'}-${index}`}
|
||||
component="button"
|
||||
selected={index === selectedIndex}
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
background: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
px: 2, // Add horizontal padding back
|
||||
"&:hover": {
|
||||
backgroundColor: "action.hover",
|
||||
},
|
||||
@@ -293,14 +330,48 @@ const SearchBar = () => {
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="body2" noWrap>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2 }}>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2" noWrap sx={{ mb: 0.5 }}>
|
||||
{suggestion.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||
{getDeliveryDays(suggestion)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'right', flexShrink: 0, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body1" color="primary" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(suggestion.price)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem' }}>
|
||||
{t('product.inclVat', { vat: suggestion.vat })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Box sx={{ p: 1, borderTop: 1, borderColor: 'divider' }}>
|
||||
<IconButton
|
||||
fullWidth
|
||||
onClick={handleEnterClick}
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
py: 1,
|
||||
color: 'primary.main',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ mr: 1 }}>
|
||||
{t('common.more')}
|
||||
</Typography>
|
||||
<KeyboardReturnIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from "react";
|
||||
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
|
||||
const getRequiredFieldError = (fieldName, value) => {
|
||||
const isEmpty = !value || value.trim() === "";
|
||||
@@ -36,7 +37,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Vorname"
|
||||
label={t ? t('checkout.addressFields.firstName') : 'Vorname'}
|
||||
name="firstName"
|
||||
value={address.firstName}
|
||||
onChange={onChange}
|
||||
@@ -49,7 +50,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Nachname"
|
||||
label={t ? t('checkout.addressFields.lastName') : 'Nachname'}
|
||||
name="lastName"
|
||||
value={address.lastName}
|
||||
onChange={onChange}
|
||||
@@ -62,7 +63,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Adresszusatz"
|
||||
label={t ? t('checkout.addressFields.addressSupplement') : 'Adresszusatz'}
|
||||
name="addressAddition"
|
||||
value={address.addressAddition || ""}
|
||||
onChange={onChange}
|
||||
@@ -70,7 +71,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
label="Straße"
|
||||
label={t ? t('checkout.addressFields.street') : 'Straße'}
|
||||
name="street"
|
||||
value={address.street}
|
||||
onChange={onChange}
|
||||
@@ -83,7 +84,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Hausnummer"
|
||||
label={t ? t('checkout.addressFields.houseNumber') : 'Hausnummer'}
|
||||
name="houseNumber"
|
||||
value={address.houseNumber}
|
||||
onChange={onChange}
|
||||
@@ -96,7 +97,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="PLZ"
|
||||
label={t ? t('checkout.addressFields.postalCode') : 'PLZ'}
|
||||
name="postalCode"
|
||||
value={address.postalCode}
|
||||
onChange={onChange}
|
||||
@@ -109,7 +110,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Stadt"
|
||||
label={t ? t('checkout.addressFields.city') : 'Stadt'}
|
||||
name="city"
|
||||
value={address.city}
|
||||
onChange={onChange}
|
||||
@@ -122,7 +123,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Land"
|
||||
label={t ? t('checkout.addressFields.country') : 'Land'}
|
||||
name="country"
|
||||
value={address.country}
|
||||
onChange={onChange}
|
||||
@@ -135,4 +136,4 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AddressForm;
|
||||
export default withI18n()(AddressForm);
|
||||
|
||||
@@ -5,7 +5,7 @@ import CheckoutForm from "./CheckoutForm.js";
|
||||
import PaymentConfirmationDialog from "./PaymentConfirmationDialog.js";
|
||||
import OrderProcessingService from "./OrderProcessingService.js";
|
||||
import CheckoutValidation from "./CheckoutValidation.js";
|
||||
import SocketContext from "../../contexts/SocketContext.js";
|
||||
import { withI18n } from "../../i18n/index.js";
|
||||
|
||||
class CartTab extends Component {
|
||||
constructor(props) {
|
||||
@@ -67,8 +67,7 @@ class CartTab extends Component {
|
||||
|
||||
// @note Add method to fetch and apply order template prefill data
|
||||
fetchOrderTemplate = () => {
|
||||
if (this.context && this.context.socket && this.context.socket.connected) {
|
||||
this.context.socket.emit('getOrderTemplate', (response) => {
|
||||
window.socketManager.emit('getOrderTemplate', (response) => {
|
||||
if (response.success && response.orderTemplate) {
|
||||
const template = response.orderTemplate;
|
||||
|
||||
@@ -116,7 +115,7 @@ class CartTab extends Component {
|
||||
// Determine payment method - respect constraints
|
||||
let prefillPaymentMethod = template.payment_method || "wire";
|
||||
const paymentMethodMap = {
|
||||
"credit_card": "stripe",
|
||||
"credit_card": "mollie",/*stripe*/
|
||||
"bank_transfer": "wire",
|
||||
"cash_on_delivery": "onDelivery",
|
||||
"cash": "cash"
|
||||
@@ -146,7 +145,6 @@ class CartTab extends Component {
|
||||
console.log("No order template available or failed to fetch");
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
@@ -292,7 +290,7 @@ class CartTab extends Component {
|
||||
};
|
||||
|
||||
validateAddressForm = () => {
|
||||
const errors = CheckoutValidation.validateAddressForm(this.state);
|
||||
const errors = CheckoutValidation.validateAddressForm(this.state, this.props.t);
|
||||
this.setState({ addressFormErrors: errors });
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
@@ -322,7 +320,7 @@ class CartTab extends Component {
|
||||
handleCompleteOrder = () => {
|
||||
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) {
|
||||
this.setState({ completionError: validationError });
|
||||
this.validateAddressForm(); // To show field-specific errors
|
||||
@@ -363,6 +361,40 @@ class CartTab extends Component {
|
||||
this.orderService.createStripeIntent(totalAmount, this.loadStripeComponent);
|
||||
return;
|
||||
}
|
||||
// Handle molllie payment differently
|
||||
if (paymentMethod === "mollie") {
|
||||
// Store the cart items used for mollie payment in sessionStorage for later reference
|
||||
try {
|
||||
sessionStorage.setItem('molliePaymentCart', JSON.stringify(cartItems));
|
||||
} catch (error) {
|
||||
console.error("Failed to store mollie payment cart:", error);
|
||||
}
|
||||
|
||||
// Calculate total amount for mollie
|
||||
const subtotal = cartItems.reduce(
|
||||
(total, item) => total + item.price * item.quantity,
|
||||
0
|
||||
);
|
||||
const totalAmount = Math.round((subtotal + deliveryCost) * 100) / 100;
|
||||
|
||||
// Prepare complete order data for Mollie intent creation
|
||||
const mollieOrderData = {
|
||||
amount: totalAmount,
|
||||
items: cartItems,
|
||||
invoiceAddress,
|
||||
deliveryAddress: useSameAddress ? invoiceAddress : deliveryAddress,
|
||||
deliveryMethod,
|
||||
paymentMethod: "mollie",
|
||||
deliveryCost,
|
||||
note,
|
||||
domain: window.location.origin,
|
||||
saveAddressForFuture,
|
||||
};
|
||||
|
||||
this.orderService.createMollieIntent(mollieOrderData);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle regular orders
|
||||
const orderData = {
|
||||
@@ -405,7 +437,7 @@ class CartTab extends Component {
|
||||
const deliveryCost = this.orderService.getDeliveryCost();
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -433,7 +465,6 @@ class CartTab extends Component {
|
||||
{!showPaymentConfirmation && (
|
||||
<CartDropdown
|
||||
cartItems={cartItems}
|
||||
socket={this.context.socket}
|
||||
showDetailedSummary={showStripePayment}
|
||||
deliveryMethod={deliveryMethod}
|
||||
deliveryCost={deliveryCost}
|
||||
@@ -445,7 +476,7 @@ class CartTab extends Component {
|
||||
{isLoadingStripe ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="body1">
|
||||
Zahlungskomponente wird geladen...
|
||||
{this.props.t ? this.props.t('payment.loadingPaymentComponent') : 'Zahlungskomponente wird geladen...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : showStripePayment && StripeComponent ? (
|
||||
@@ -463,7 +494,7 @@ class CartTab extends Component {
|
||||
}
|
||||
}}
|
||||
>
|
||||
← Zurück zur Bestellung
|
||||
{this.props.t ? this.props.t('cart.backToOrder') : '← Zurück zur Bestellung'}
|
||||
</Button>
|
||||
</Box>
|
||||
<StripeComponent clientSecret={stripeClientSecret} />
|
||||
@@ -504,7 +535,4 @@ class CartTab extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
// Set static contextType to access the socket
|
||||
CartTab.contextType = SocketContext;
|
||||
|
||||
export default CartTab;
|
||||
export default withI18n()(CartTab);
|
||||
|
||||
@@ -4,6 +4,7 @@ import AddressForm from "./AddressForm.js";
|
||||
import DeliveryMethodSelector from "./DeliveryMethodSelector.js";
|
||||
import PaymentMethodSelector from "./PaymentMethodSelector.js";
|
||||
import OrderSummary from "./OrderSummary.js";
|
||||
import { withI18n } from "../../i18n/withTranslation.js";
|
||||
|
||||
class CheckoutForm extends Component {
|
||||
render() {
|
||||
@@ -40,7 +41,7 @@ class CheckoutForm extends Component {
|
||||
{paymentMethod !== "cash" && (
|
||||
<>
|
||||
<AddressForm
|
||||
title="Rechnungsadresse"
|
||||
title={this.props.t ? this.props.t('checkout.invoiceAddress') : 'Rechnungsadresse'}
|
||||
address={invoiceAddress}
|
||||
onChange={onInvoiceAddressChange}
|
||||
errors={addressFormErrors}
|
||||
@@ -57,7 +58,7 @@ class CheckoutForm extends Component {
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
Für zukünftige Bestellungen speichern
|
||||
{this.props.t ? this.props.t('checkout.saveForFuture') : 'Für zukünftige Bestellungen speichern'}
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mb: 2 }}
|
||||
@@ -70,13 +71,12 @@ class CheckoutForm extends Component {
|
||||
variant="body1"
|
||||
sx={{ mb: 2, fontWeight: "bold", color: "#2e7d32" }}
|
||||
>
|
||||
Für welchen Termin ist die Abholung der Stecklinge
|
||||
gewünscht?
|
||||
{this.props.t ? this.props.t('checkout.pickupDate') : 'Für welchen Termin ist die Abholung der Stecklinge gewünscht?'}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label="Anmerkung"
|
||||
label={this.props.t ? this.props.t('checkout.note') : 'Anmerkung'}
|
||||
name="note"
|
||||
value={note}
|
||||
onChange={onNoteChange}
|
||||
@@ -93,6 +93,7 @@ class CheckoutForm extends Component {
|
||||
deliveryMethod={deliveryMethod}
|
||||
onChange={onDeliveryMethodChange}
|
||||
isPickupOnly={isPickupOnly || hasStecklinge}
|
||||
cartItems={cartItems}
|
||||
/>
|
||||
|
||||
{(deliveryMethod === "DHL" || deliveryMethod === "DPD") && (
|
||||
@@ -107,7 +108,7 @@ class CheckoutForm extends Component {
|
||||
}
|
||||
label={
|
||||
<Typography variant="body1">
|
||||
Lieferadresse ist identisch mit Rechnungsadresse
|
||||
{this.props.t ? this.props.t('checkout.sameAddress') : 'Lieferadresse ist identisch mit Rechnungsadresse'}
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mb: 2 }}
|
||||
@@ -115,7 +116,7 @@ class CheckoutForm extends Component {
|
||||
|
||||
{!useSameAddress && (
|
||||
<AddressForm
|
||||
title="Lieferadresse"
|
||||
title={this.props.t ? this.props.t('checkout.deliveryAddress') : 'Lieferadresse'}
|
||||
address={deliveryAddress}
|
||||
onChange={onDeliveryAddressChange}
|
||||
errors={addressFormErrors}
|
||||
@@ -150,8 +151,7 @@ class CheckoutForm extends Component {
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
Ich habe die AGBs, die Datenschutzerklärung und die
|
||||
Bestimmungen zum Widerrufsrecht gelesen
|
||||
{this.props.t ? this.props.t('checkout.termsAccept') : 'Ich habe die AGBs, die Datenschutzerklärung und die Bestimmungen zum Widerrufsrecht gelesen'}
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mb: 3, mt: 2 }}
|
||||
@@ -174,12 +174,12 @@ class CheckoutForm extends Component {
|
||||
disabled={isCompletingOrder || !!preSubmitError}
|
||||
>
|
||||
{isCompletingOrder
|
||||
? "Bestellung wird verarbeitet..."
|
||||
: "Bestellung abschließen"}
|
||||
? (this.props.t ? this.props.t('checkout.processingOrder') : 'Bestellung wird verarbeitet...')
|
||||
: (this.props.t ? this.props.t('checkout.completeOrder') : 'Bestellung abschließen')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CheckoutForm;
|
||||
export default withI18n()(CheckoutForm);
|
||||
@@ -1,5 +1,5 @@
|
||||
class CheckoutValidation {
|
||||
static validateAddressForm(state) {
|
||||
static validateAddressForm(state, t = null) {
|
||||
const {
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
@@ -12,15 +12,15 @@ class CheckoutValidation {
|
||||
// Validate invoice address (skip if payment method is "cash")
|
||||
if (paymentMethod !== "cash") {
|
||||
if (!invoiceAddress.firstName)
|
||||
errors.invoiceFirstName = "Vorname erforderlich";
|
||||
errors.invoiceFirstName = t ? t('checkout.validationErrors.firstNameRequired') : "Vorname erforderlich";
|
||||
if (!invoiceAddress.lastName)
|
||||
errors.invoiceLastName = "Nachname erforderlich";
|
||||
if (!invoiceAddress.street) errors.invoiceStreet = "Straße erforderlich";
|
||||
errors.invoiceLastName = t ? t('checkout.validationErrors.lastNameRequired') : "Nachname erforderlich";
|
||||
if (!invoiceAddress.street) errors.invoiceStreet = t ? t('checkout.validationErrors.streetRequired') : "Straße erforderlich";
|
||||
if (!invoiceAddress.houseNumber)
|
||||
errors.invoiceHouseNumber = "Hausnummer erforderlich";
|
||||
errors.invoiceHouseNumber = t ? t('checkout.validationErrors.houseNumberRequired') : "Hausnummer erforderlich";
|
||||
if (!invoiceAddress.postalCode)
|
||||
errors.invoicePostalCode = "PLZ erforderlich";
|
||||
if (!invoiceAddress.city) errors.invoiceCity = "Stadt erforderlich";
|
||||
errors.invoicePostalCode = t ? t('checkout.validationErrors.postalCodeRequired') : "PLZ erforderlich";
|
||||
if (!invoiceAddress.city) errors.invoiceCity = t ? t('checkout.validationErrors.cityRequired') : "Stadt erforderlich";
|
||||
}
|
||||
|
||||
// Validate delivery address for shipping methods that require it
|
||||
@@ -29,37 +29,37 @@ class CheckoutValidation {
|
||||
(deliveryMethod === "DHL" || deliveryMethod === "DPD")
|
||||
) {
|
||||
if (!deliveryAddress.firstName)
|
||||
errors.deliveryFirstName = "Vorname erforderlich";
|
||||
errors.deliveryFirstName = t ? t('checkout.validationErrors.firstNameRequired') : "Vorname erforderlich";
|
||||
if (!deliveryAddress.lastName)
|
||||
errors.deliveryLastName = "Nachname erforderlich";
|
||||
errors.deliveryLastName = t ? t('checkout.validationErrors.lastNameRequired') : "Nachname erforderlich";
|
||||
if (!deliveryAddress.street)
|
||||
errors.deliveryStreet = "Straße erforderlich";
|
||||
errors.deliveryStreet = t ? t('checkout.validationErrors.streetRequired') : "Straße erforderlich";
|
||||
if (!deliveryAddress.houseNumber)
|
||||
errors.deliveryHouseNumber = "Hausnummer erforderlich";
|
||||
errors.deliveryHouseNumber = t ? t('checkout.validationErrors.houseNumberRequired') : "Hausnummer erforderlich";
|
||||
if (!deliveryAddress.postalCode)
|
||||
errors.deliveryPostalCode = "PLZ erforderlich";
|
||||
if (!deliveryAddress.city) errors.deliveryCity = "Stadt erforderlich";
|
||||
errors.deliveryPostalCode = t ? t('checkout.validationErrors.postalCodeRequired') : "PLZ erforderlich";
|
||||
if (!deliveryAddress.city) errors.deliveryCity = t ? t('checkout.validationErrors.cityRequired') : "Stadt erforderlich";
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
static getValidationErrorMessage(state, isAddressOnly = false) {
|
||||
static getValidationErrorMessage(state, isAddressOnly = false, t = null) {
|
||||
const { termsAccepted } = state;
|
||||
|
||||
const addressErrors = this.validateAddressForm(state);
|
||||
const addressErrors = this.validateAddressForm(state, t);
|
||||
|
||||
if (isAddressOnly) {
|
||||
return addressErrors;
|
||||
}
|
||||
|
||||
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
|
||||
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;
|
||||
@@ -82,7 +82,7 @@ class CheckoutValidation {
|
||||
|
||||
// Prefer stripe when available and meets minimum amount
|
||||
if (deliveryMethod === "DHL" || deliveryMethod === "DPD" || deliveryMethod === "Abholung") {
|
||||
return "stripe";
|
||||
return "wire";/*stripe*/
|
||||
}
|
||||
|
||||
// Fall back to wire transfer
|
||||
@@ -106,11 +106,21 @@ class CheckoutValidation {
|
||||
newPaymentMethod = "wire";
|
||||
}
|
||||
|
||||
// Allow mollie for DHL, DPD, and Abholung delivery methods, but check minimum amount
|
||||
if (deliveryMethod !== "DHL" && deliveryMethod !== "DPD" && deliveryMethod !== "Abholung" && paymentMethod === "mollie") {
|
||||
newPaymentMethod = "wire";
|
||||
}
|
||||
|
||||
// Check minimum amount for stripe payments
|
||||
if (paymentMethod === "stripe" && totalAmount < 0.50) {
|
||||
newPaymentMethod = "wire";
|
||||
}
|
||||
|
||||
// Check minimum amount for mollie payments
|
||||
if (paymentMethod === "mollie" && totalAmount < 0.50) {
|
||||
newPaymentMethod = "wire";
|
||||
}
|
||||
|
||||
if (deliveryMethod !== "Abholung" && paymentMethod === "cash") {
|
||||
newPaymentMethod = "wire";
|
||||
}
|
||||
|
||||
@@ -3,34 +3,42 @@ import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Radio from '@mui/material/Radio';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import { withI18n } from '../../i18n/withTranslation.js';
|
||||
|
||||
const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartItems = [], t }) => {
|
||||
// Calculate cart value for free shipping threshold
|
||||
const cartValue = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
|
||||
const isFreeShipping = cartValue >= 100;
|
||||
const remainingForFreeShipping = Math.max(0, 100 - cartValue);
|
||||
|
||||
const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly }) => {
|
||||
const deliveryOptions = [
|
||||
{
|
||||
id: 'DHL',
|
||||
name: 'DHL',
|
||||
description: isPickupOnly ? "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können" : 'Standardversand',
|
||||
price: '6,99 €',
|
||||
description: isPickupOnly ? (t ? t('delivery.descriptions.notAvailable') : "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können") :
|
||||
isFreeShipping ? (t ? t('delivery.descriptions.standardFree') : 'Standardversand - KOSTENLOS ab 100€ Warenwert!') : (t ? t('delivery.descriptions.standard') : 'Standardversand'),
|
||||
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dhl') : '5,90 €'),
|
||||
disabled: isPickupOnly
|
||||
},
|
||||
{
|
||||
id: 'DPD',
|
||||
name: 'DPD',
|
||||
description: isPickupOnly ? "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können" : 'Standardversand',
|
||||
price: '4,90 €',
|
||||
description: isPickupOnly ? (t ? t('delivery.descriptions.notAvailable') : "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können") :
|
||||
isFreeShipping ? (t ? t('delivery.descriptions.standardFree') : 'Standardversand - KOSTENLOS ab 100€ Warenwert!') : (t ? t('delivery.descriptions.standard') : 'Standardversand'),
|
||||
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dpd') : '4,90 €'),
|
||||
disabled: isPickupOnly
|
||||
},
|
||||
{
|
||||
id: 'Sperrgut',
|
||||
name: 'Sperrgut',
|
||||
description: 'Für große und schwere Artikel',
|
||||
price: '28,99 €',
|
||||
name: t ? t('delivery.methods.sperrgutName') : 'Sperrgut',
|
||||
description: t ? t('delivery.descriptions.bulky') : 'Für große und schwere Artikel',
|
||||
price: t ? t('delivery.prices.sperrgut') : '28,99 €',
|
||||
disabled: true,
|
||||
isCheckbox: true
|
||||
},
|
||||
{
|
||||
id: 'Abholung',
|
||||
name: 'Abholung in der Filiale',
|
||||
name: t ? t('delivery.methods.pickup') : 'Abholung in der Filiale',
|
||||
description: '',
|
||||
price: ''
|
||||
}
|
||||
@@ -39,7 +47,7 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly }) => {
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Versandart wählen
|
||||
{t ? t('delivery.selector.title') : 'Versandart wählen'}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
@@ -114,9 +122,44 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly }) => {
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* Free shipping information */}
|
||||
{!isFreeShipping && remainingForFreeShipping > 0 && (
|
||||
<Box sx={{
|
||||
mt: 2,
|
||||
p: 2,
|
||||
backgroundColor: '#f0f8ff',
|
||||
borderRadius: 1,
|
||||
border: '1px solid #2196f3'
|
||||
}}>
|
||||
<Typography variant="body2" color="primary" sx={{ fontWeight: 'medium' }}>
|
||||
{t ? t('delivery.selector.freeShippingInfo') : '💡 Versandkostenfrei ab 100€ Warenwert!'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t ? t('delivery.selector.remainingForFree', { amount: remainingForFreeShipping.toFixed(2).replace('.', ',') }) : `Noch ${remainingForFreeShipping.toFixed(2).replace('.', ',')}€ für kostenlosen Versand hinzufügen.`}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isFreeShipping && (
|
||||
<Box sx={{
|
||||
mt: 2,
|
||||
p: 2,
|
||||
backgroundColor: '#e8f5e8',
|
||||
borderRadius: 1,
|
||||
border: '1px solid #2e7d32'
|
||||
}}>
|
||||
<Typography variant="body2" color="success.main" sx={{ fontWeight: 'medium' }}>
|
||||
{t ? t('delivery.selector.congratsFreeShipping') : '🎉 Glückwunsch! Sie erhalten kostenlosen Versand!'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t ? t('delivery.selector.cartQualifiesFree', { amount: cartValue.toFixed(2).replace('.', ',') }) : `Ihr Warenkorb von ${cartValue.toFixed(2).replace('.', ',')}€ qualifiziert sich für kostenlosen Versand.`}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeliveryMethodSelector;
|
||||
export default withI18n()(DeliveryMethodSelector);
|
||||
@@ -15,22 +15,36 @@ import {
|
||||
TableRow,
|
||||
Paper
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const OrderDetailsDialog = ({ open, onClose, order }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
// Implement order cancellation logic here
|
||||
console.log(`Cancel order: ${order.orderId}`);
|
||||
onClose(); // Close the dialog after action
|
||||
};
|
||||
|
||||
const subtotal = order.items.reduce((acc, item) => acc + item.price * item.quantity_ordered, 0);
|
||||
const total = subtotal + order.delivery_cost;
|
||||
const total = order.items.reduce((acc, item) => acc + item.price * item.quantity_ordered, 0);
|
||||
|
||||
// Calculate VAT breakdown similar to CartDropdown
|
||||
const vatCalculations = order.items.reduce((acc, item) => {
|
||||
@@ -52,10 +66,10 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Bestelldetails: {order.orderId}</DialogTitle>
|
||||
<DialogTitle>{t('orders.details.title', { orderId: order.orderId })}</DialogTitle>
|
||||
<DialogContent>
|
||||
<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_street} {order.shipping_address_house_number}</Typography>
|
||||
<Typography>{order.shipping_address_postal_code} {order.shipping_address_city}</Typography>
|
||||
@@ -63,7 +77,7 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
||||
</Box>
|
||||
|
||||
<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_street} {order.invoice_address_house_number}</Typography>
|
||||
<Typography>{order.invoice_address_postal_code} {order.invoice_address_city}</Typography>
|
||||
@@ -72,28 +86,29 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
||||
|
||||
{/* Order Details Section */}
|
||||
<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>
|
||||
<Typography variant="body2" color="text.secondary">Lieferart:</Typography>
|
||||
<Typography variant="body1">{order.deliveryMethod || order.delivery_method || 'Nicht angegeben'}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t('orders.details.deliveryMethod')}</Typography>
|
||||
<Typography variant="body1">{order.deliveryMethod || order.delivery_method || t('orders.details.notSpecified')}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">Zahlungsart:</Typography>
|
||||
<Typography variant="body1">{order.paymentMethod || order.payment_method || 'Nicht angegeben'}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t('orders.details.paymentMethod')}</Typography>
|
||||
<Typography variant="body1">{getPaymentMethodDisplay(order.paymentMethod || order.payment_method)}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" gutterBottom>Bestellte Artikel</Typography>
|
||||
<Typography variant="h6" gutterBottom>{t('orders.details.orderedItems')}</Typography>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Artikel</TableCell>
|
||||
<TableCell align="right">Menge</TableCell>
|
||||
<TableCell align="right">Preis</TableCell>
|
||||
<TableCell align="right">Gesamt</TableCell>
|
||||
<TableCell>{t('orders.details.item')}</TableCell>
|
||||
<TableCell align="right">{t('orders.details.quantity')}</TableCell>
|
||||
<TableCell align="right">{t('orders.details.price')}</TableCell>
|
||||
<TableCell align="right">{t('product.vatShort')}</TableCell>
|
||||
<TableCell align="right">{t('orders.details.total')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -102,13 +117,13 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell align="right">{item.quantity_ordered}</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>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">Gesamtnettopreis</Typography>
|
||||
<TableCell colSpan={4} align="right">
|
||||
<Typography fontWeight="bold">{t ? t('tax.totalNet') : 'Gesamtnettopreis'}</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">{currencyFormatter.format(vatCalculations.totalNet)}</Typography>
|
||||
@@ -116,36 +131,19 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
||||
</TableRow>
|
||||
{vatCalculations.vat7 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">7% Mehrwertsteuer</TableCell>
|
||||
<TableCell colSpan={4} align="right">{t ? t('tax.vat7') : '7% Mehrwertsteuer'}</TableCell>
|
||||
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat7)}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{vatCalculations.vat19 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">19% Mehrwertsteuer</TableCell>
|
||||
<TableCell colSpan={4} align="right">{t ? t('tax.vat19') : '19% Mehrwertsteuer'}</TableCell>
|
||||
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat19)}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">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 colSpan={4} align="right">
|
||||
<Typography fontWeight="bold">{t ? t('cart.summary.total') : 'Gesamtsumme'}</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">{currencyFormatter.format(total)}</Typography>
|
||||
@@ -159,10 +157,10 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
||||
<DialogActions>
|
||||
{order.status === 'new' && (
|
||||
<Button onClick={handleCancelOrder} color="error">
|
||||
Bestellung stornieren
|
||||
{t('orders.details.cancelOrder')}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>Schließen</Button>
|
||||
<Button onClick={onClose}>{t ? t('common.close') : 'Schließen'}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -49,18 +49,29 @@ class OrderProcessingService {
|
||||
waitForVerifyTokenAndProcessOrder() {
|
||||
// Check if window.cart is already populated (verifyToken already completed)
|
||||
if (Array.isArray(window.cart) && window.cart.length > 0) {
|
||||
if (this.paymentCompletionData && this.paymentCompletionData.paymentType === 'mollie') {
|
||||
this.processMollieOrderWithCart(window.cart);
|
||||
} else {
|
||||
this.processStripeOrderWithCart(window.cart);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for cart event which is dispatched after verifyToken completes
|
||||
this.verifyTokenHandler = () => {
|
||||
if (Array.isArray(window.cart) && window.cart.length > 0) {
|
||||
this.processStripeOrderWithCart([...window.cart]); // Copy the cart
|
||||
const cartCopy = [...window.cart]; // Copy the cart
|
||||
|
||||
// Clear window.cart after copying
|
||||
window.cart = [];
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
|
||||
// Process based on payment type
|
||||
if (this.paymentCompletionData && this.paymentCompletionData.paymentType === 'mollie') {
|
||||
this.processMollieOrderWithCart(cartCopy);
|
||||
} else {
|
||||
this.processStripeOrderWithCart(cartCopy);
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
completionError: "Cart is empty. Please add items to your cart before placing an order."
|
||||
@@ -111,6 +122,21 @@ class OrderProcessingService {
|
||||
});
|
||||
}
|
||||
|
||||
processMollieOrderWithCart(cartItems) {
|
||||
// Clear timeout if it exists
|
||||
if (this.verifyTokenTimeout) {
|
||||
clearTimeout(this.verifyTokenTimeout);
|
||||
this.verifyTokenTimeout = null;
|
||||
}
|
||||
|
||||
// Store cart items in state and process order
|
||||
this.setState({
|
||||
originalCartItems: cartItems
|
||||
}, () => {
|
||||
this.processMollieOrder();
|
||||
});
|
||||
}
|
||||
|
||||
processStripeOrder() {
|
||||
// If no original cart items, don't process
|
||||
if (!this.getState().originalCartItems || this.getState().originalCartItems.length === 0) {
|
||||
@@ -119,26 +145,24 @@ class OrderProcessingService {
|
||||
}
|
||||
|
||||
// If socket is ready, process immediately
|
||||
const context = this.getContext();
|
||||
if (context && context.socket && context.socket.connected) {
|
||||
|
||||
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
|
||||
if (isAuthenticated) {
|
||||
this.sendStripeOrder();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Wait for socket to be ready
|
||||
this.socketHandler = () => {
|
||||
const context = this.getContext();
|
||||
if (context && context.socket && context.socket.connected) {
|
||||
|
||||
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
|
||||
const state = this.getState();
|
||||
|
||||
if (isAuthenticated && state.showPaymentConfirmation && !state.isCompletingOrder) {
|
||||
this.sendStripeOrder();
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
if (this.socketHandler) {
|
||||
window.removeEventListener('cart', this.socketHandler);
|
||||
@@ -187,9 +211,8 @@ class OrderProcessingService {
|
||||
saveAddressForFuture,
|
||||
};
|
||||
|
||||
// Emit stripe order to backend via socket.io
|
||||
const context = this.getContext();
|
||||
context.socket.emit("issueStripeOrder", orderData, (response) => {
|
||||
|
||||
window.socketManager.emit("issueStripeOrder", orderData, (response) => {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
@@ -205,11 +228,24 @@ class OrderProcessingService {
|
||||
});
|
||||
}
|
||||
|
||||
processMollieOrder() {
|
||||
// For Mollie payments, the backend handles order creation automatically
|
||||
// when payment is successful. We just need to show success state.
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
orderCompleted: true,
|
||||
completionError: null,
|
||||
});
|
||||
|
||||
// Clear the cart since order was created by backend
|
||||
window.cart = [];
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
}
|
||||
|
||||
// Process regular (non-Stripe) orders
|
||||
processRegularOrder(orderData) {
|
||||
const context = this.getContext();
|
||||
if (context && context.socket && context.socket.connected) {
|
||||
context.socket.emit("issueOrder", orderData, (response) => {
|
||||
|
||||
window.socketManager.emit("issueOrder", orderData, (response) => {
|
||||
if (response.success) {
|
||||
// Clear the cart
|
||||
window.cart = [];
|
||||
@@ -234,20 +270,12 @@ class OrderProcessingService {
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error("Socket context not available");
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: "Cannot connect to server. Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create Stripe payment intent
|
||||
createStripeIntent(totalAmount, loadStripeComponent) {
|
||||
const context = this.getContext();
|
||||
if (context && context.socket && context.socket.connected) {
|
||||
context.socket.emit(
|
||||
|
||||
window.socketManager.emit(
|
||||
"createStripeIntent",
|
||||
{ amount: totalAmount },
|
||||
(response) => {
|
||||
@@ -262,18 +290,38 @@ class OrderProcessingService {
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Create Mollie payment intent
|
||||
createMollieIntent(mollieOrderData) {
|
||||
window.socketManager.emit(
|
||||
"createMollieIntent",
|
||||
mollieOrderData,
|
||||
(response) => {
|
||||
if (response.success) {
|
||||
// Store pending payment info and redirect
|
||||
localStorage.setItem('pendingPayment', JSON.stringify({
|
||||
paymentId: response.paymentId,
|
||||
amount: mollieOrderData.amount,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
window.location.href = response.checkoutUrl;
|
||||
} else {
|
||||
console.error("Socket context not available");
|
||||
console.error("Error:", response.error);
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: "Cannot connect to server. Please try again later.",
|
||||
completionError: response.error || "Failed to create Mollie payment intent. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate delivery cost
|
||||
getDeliveryCost() {
|
||||
const { deliveryMethod, paymentMethod } = this.getState();
|
||||
const { deliveryMethod, paymentMethod, cartItems } = this.getState();
|
||||
let cost = 0;
|
||||
|
||||
switch (deliveryMethod) {
|
||||
@@ -293,7 +341,16 @@ class OrderProcessingService {
|
||||
cost = 6.99;
|
||||
}
|
||||
|
||||
// Add onDelivery surcharge if selected
|
||||
// Check for free shipping threshold (>= 100€ cart value)
|
||||
// Free shipping applies to DHL, DPD, and Sperrgut deliveries when cart value >= 100€
|
||||
if (cartItems && Array.isArray(cartItems) && deliveryMethod !== "Abholung") {
|
||||
const cartValue = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
|
||||
if (cartValue >= 100) {
|
||||
cost = 0; // Free shipping for orders >= 100€
|
||||
}
|
||||
}
|
||||
|
||||
// Add onDelivery surcharge if selected (still applies even with free shipping)
|
||||
if (paymentMethod === "onDelivery") {
|
||||
cost += 8.99;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
|
||||
const { t } = useTranslation();
|
||||
const currencyFormatter = new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
@@ -30,9 +32,9 @@ const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
|
||||
return acc;
|
||||
}, { totalGross: 0, totalNet: 0, vat7: 0, vat19: 0 });
|
||||
|
||||
// Calculate shipping VAT (19% VAT for shipping costs)
|
||||
const shippingNetPrice = deliveryCost / (1 + 19 / 100);
|
||||
const shippingVat = deliveryCost - shippingNetPrice;
|
||||
// Calculate shipping VAT (19% VAT for shipping costs) - only if there are shipping costs
|
||||
const shippingNetPrice = deliveryCost > 0 ? deliveryCost / (1 + 19 / 100) : 0;
|
||||
const shippingVat = deliveryCost > 0 ? deliveryCost - shippingNetPrice : 0;
|
||||
|
||||
// Combine totals - add shipping VAT to the 19% VAT total
|
||||
const totalVat7 = cartVatCalculations.vat7;
|
||||
@@ -42,20 +44,20 @@ const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
|
||||
return (
|
||||
<Box sx={{ my: 3, p: 2, bgcolor: '#f5f5f5', borderRadius: 1 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Bestellübersicht
|
||||
{t ? t('cart.summary.title') : 'Bestellübersicht'}
|
||||
</Typography>
|
||||
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Waren (netto):</TableCell>
|
||||
<TableCell>{t ? t('cart.summary.goodsNet') : 'Waren (netto):'}</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(cartVatCalculations.totalNet)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{deliveryCost > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>Versandkosten (netto):</TableCell>
|
||||
<TableCell>{t ? t('cart.summary.shippingNet') : 'Versandkosten (netto):'}</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(shippingNetPrice)}
|
||||
</TableCell>
|
||||
@@ -63,7 +65,7 @@ const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
|
||||
)}
|
||||
{totalVat7 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>7% Mehrwertsteuer:</TableCell>
|
||||
<TableCell>{t ? t('tax.vat7') : '7% Mehrwertsteuer'}:</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(totalVat7)}
|
||||
</TableCell>
|
||||
@@ -71,28 +73,37 @@ const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
|
||||
)}
|
||||
{totalVat19 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>19% Mehrwertsteuer (inkl. Versand):</TableCell>
|
||||
<TableCell>{t ? t('tax.vat19WithShipping') : '19% Mehrwertsteuer (inkl. Versand)'}:</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(totalVat19)}
|
||||
</TableCell>
|
||||
</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' }}>
|
||||
{currencyFormatter.format(cartVatCalculations.totalGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{deliveryCost > 0 && (
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Versandkosten:</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>
|
||||
{t ? t('cart.summary.shippingCosts') : 'Versandkosten:'}
|
||||
{deliveryCost === 0 && cartVatCalculations.totalGross < 100 && (
|
||||
<span style={{ color: '#2e7d32', fontSize: '0.8em', marginLeft: '4px' }}>
|
||||
{t ? t('cart.summary.freeFrom100') : '(kostenlos ab 100€)'}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{currencyFormatter.format(deliveryCost)}
|
||||
{deliveryCost === 0 ? (
|
||||
<span style={{ color: '#2e7d32' }}>{t ? t('cart.summary.free') : 'kostenlos'}</span>
|
||||
) : (
|
||||
currencyFormatter.format(deliveryCost)
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<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 }}>
|
||||
{currencyFormatter.format(totalGross)}
|
||||
</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 { withI18n } from "../../i18n/withTranslation.js";
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
@@ -14,45 +15,48 @@ import {
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
Typography,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
} from "@mui/material";
|
||||
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";
|
||||
|
||||
// Constants
|
||||
const statusTranslations = {
|
||||
new: "in Bearbeitung",
|
||||
pending: "Neu",
|
||||
processing: "in Bearbeitung",
|
||||
cancelled: "Storniert",
|
||||
shipped: "Verschickt",
|
||||
delivered: "Geliefert",
|
||||
const getStatusTranslation = (status, t) => {
|
||||
const statusMap = {
|
||||
new: t ? t('orders.status.new') : "in Bearbeitung",
|
||||
pending: t ? t('orders.status.pending') : "Neu",
|
||||
processing: t ? t('orders.status.processing') : "in Bearbeitung",
|
||||
paid: t ? t('orders.status.paid') : "Bezahlt",
|
||||
cancelled: t ? t('orders.status.cancelled') : "Storniert",
|
||||
shipped: t ? t('orders.status.shipped') : "Verschickt",
|
||||
delivered: t ? t('orders.status.delivered') : "Geliefert",
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
};
|
||||
|
||||
const statusEmojis = {
|
||||
"in Bearbeitung": "⚙️",
|
||||
new: "⚙️",
|
||||
pending: "⏳",
|
||||
processing: "🔄",
|
||||
paid: "🏦",
|
||||
cancelled: "❌",
|
||||
Verschickt: "🚚",
|
||||
Geliefert: "✅",
|
||||
Storniert: "❌",
|
||||
Retoure: "↩️",
|
||||
"Teil Retoure": "↪️",
|
||||
"Teil geliefert": "⚡",
|
||||
shipped: "🚚",
|
||||
delivered: "✅",
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
"in Bearbeitung": "#ed6c02", // orange
|
||||
new: "#ed6c02", // orange
|
||||
pending: "#ff9800", // orange for pending
|
||||
processing: "#2196f3", // blue for processing
|
||||
paid: "#2e7d32", // green
|
||||
cancelled: "#d32f2f", // red for cancelled
|
||||
Verschickt: "#2e7d32", // green
|
||||
Geliefert: "#2e7d32", // green
|
||||
Storniert: "#d32f2f", // red
|
||||
Retoure: "#9c27b0", // purple
|
||||
"Teil Retoure": "#9c27b0", // purple
|
||||
"Teil geliefert": "#009688", // teal
|
||||
shipped: "#2e7d32", // green
|
||||
delivered: "#2e7d32", // green
|
||||
};
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
||||
@@ -61,14 +65,16 @@ const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
||||
});
|
||||
|
||||
// Orders Tab Content Component
|
||||
const OrdersTab = ({ orderIdFromHash }) => {
|
||||
const OrdersTab = ({ orderIdFromHash, t }) => {
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedOrder, setSelectedOrder] = useState(null);
|
||||
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 handleViewDetails = useCallback(
|
||||
@@ -77,16 +83,18 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
||||
if (orderToView) {
|
||||
setSelectedOrder(orderToView);
|
||||
setIsDetailsDialogOpen(true);
|
||||
// Update the hash to include the order ID
|
||||
navigate(`/profile#${orderId}`, { replace: true });
|
||||
}
|
||||
},
|
||||
[orders]
|
||||
[orders, navigate]
|
||||
);
|
||||
|
||||
const fetchOrders = useCallback(() => {
|
||||
if (socket && socket.connected) {
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
socket.emit("getOrders", (response) => {
|
||||
window.socketManager.emit("getOrders", (response) => {
|
||||
if (response.success) {
|
||||
setOrders(response.orders);
|
||||
} else {
|
||||
@@ -94,25 +102,13 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
console.log("Socket not connected yet, waiting for connection to fetch orders");
|
||||
setLoading(false); // Stop loading when socket is not connected
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
|
||||
// Monitor socket connection changes
|
||||
useEffect(() => {
|
||||
if (socket && socket.connected && orders.length === 0) {
|
||||
// Socket just connected and we don't have orders yet, fetch them
|
||||
fetchOrders();
|
||||
}
|
||||
}, [socket, socket?.connected, orders.length, fetchOrders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (orderIdFromHash && orders.length > 0) {
|
||||
handleViewDetails(orderIdFromHash);
|
||||
@@ -120,7 +116,7 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
||||
}, [orderIdFromHash, orders, handleViewDetails]);
|
||||
|
||||
const getStatusDisplay = (status) => {
|
||||
return statusTranslations[status] || status;
|
||||
return getStatusTranslation(status, t);
|
||||
};
|
||||
|
||||
const getStatusEmoji = (status) => {
|
||||
@@ -134,7 +130,48 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
||||
const handleCloseDetailsDialog = () => {
|
||||
setIsDetailsDialogOpen(false);
|
||||
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) {
|
||||
@@ -160,22 +197,21 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Bestellnummer</TableCell>
|
||||
<TableCell>Datum</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Artikel</TableCell>
|
||||
<TableCell align="right">Summe</TableCell>
|
||||
<TableCell align="center">Aktionen</TableCell>
|
||||
<TableCell>{t ? t('orders.table.orderNumber') : 'Bestellnummer'}</TableCell>
|
||||
<TableCell>{t ? t('orders.table.date') : 'Datum'}</TableCell>
|
||||
<TableCell>{t ? t('orders.table.status') : 'Status'}</TableCell>
|
||||
<TableCell>{t ? t('orders.table.items') : 'Artikel'}</TableCell>
|
||||
<TableCell align="right">{t ? t('orders.table.total') : 'Summe'}</TableCell>
|
||||
<TableCell align="center">{t ? t('orders.table.actions') : 'Aktionen'}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{orders.map((order) => {
|
||||
const displayStatus = getStatusDisplay(order.status);
|
||||
const subtotal = order.items.reduce(
|
||||
const total = order.items.reduce(
|
||||
(acc, item) => acc + item.price * item.quantity_ordered,
|
||||
0
|
||||
);
|
||||
const total = subtotal + order.delivery_cost;
|
||||
return (
|
||||
<TableRow key={order.orderId} hover>
|
||||
<TableCell>{order.orderId}</TableCell>
|
||||
@@ -188,11 +224,11 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
color: getStatusColor(displayStatus),
|
||||
color: getStatusColor(order.status),
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "1.2rem" }}>
|
||||
{getStatusEmoji(displayStatus)}
|
||||
{getStatusEmoji(order.status)}
|
||||
</span>
|
||||
<Typography
|
||||
variant="body2"
|
||||
@@ -202,9 +238,30 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
||||
{displayStatus}
|
||||
</Typography>
|
||||
</Box>
|
||||
{order.delivery_method === 'DHL' && order.trackingCode && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<a
|
||||
href={`https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${order.trackingCode}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: '0.85rem', color: '#d40511' }}
|
||||
>
|
||||
📦 {t ? t('orders.trackShipment') : 'Sendung verfolgen'}
|
||||
</a>
|
||||
</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{order.items.reduce(
|
||||
{order.items
|
||||
.filter(item => {
|
||||
// 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
|
||||
)}
|
||||
@@ -213,15 +270,30 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
||||
{currencyFormatter.format(total)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Tooltip title="Details anzeigen">
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center' }}>
|
||||
<Tooltip title={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => handleViewDetails(order.orderId)}
|
||||
aria-label={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}
|
||||
>
|
||||
<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>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -231,7 +303,7 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
Sie haben noch keine Bestellungen aufgegeben.
|
||||
{t ? t('orders.noOrders') : 'Sie haben noch keine Bestellungen aufgegeben.'}
|
||||
</Alert>
|
||||
)}
|
||||
<OrderDetailsDialog
|
||||
@@ -239,8 +311,49 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
||||
onClose={handleCloseDetailsDialog}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrdersTab;
|
||||
export default withI18n()(OrdersTab);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { Component } from "react";
|
||||
import { Box, Typography, Button } from "@mui/material";
|
||||
import { withI18n } from "../../i18n/withTranslation.js";
|
||||
|
||||
class PaymentConfirmationDialog extends Component {
|
||||
render() {
|
||||
@@ -28,30 +29,32 @@ class PaymentConfirmationDialog extends Component {
|
||||
color: paymentCompletionData.isSuccessful ? '#2e7d32' : '#d32f2f',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{paymentCompletionData.isSuccessful ? 'Zahlung erfolgreich!' : 'Zahlung fehlgeschlagen'}
|
||||
{paymentCompletionData.isSuccessful ?
|
||||
(this.props.t ? this.props.t('payment.successful') : 'Zahlung erfolgreich!') :
|
||||
(this.props.t ? this.props.t('payment.failed') : 'Zahlung fehlgeschlagen')}
|
||||
</Typography>
|
||||
|
||||
{paymentCompletionData.isSuccessful ? (
|
||||
<>
|
||||
{orderCompleted ? (
|
||||
<Typography variant="body1" sx={{ mt: 2, color: '#2e7d32' }}>
|
||||
🎉 Ihre Bestellung wurde erfolgreich abgeschlossen! Sie können jetzt Ihre Bestellungen einsehen.
|
||||
{this.props.t ? this.props.t('payment.orderCompleted') : '🎉 Ihre Bestellung wurde erfolgreich abgeschlossen! Sie können jetzt Ihre Bestellungen einsehen.'}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body1" sx={{ mt: 2, color: '#2e7d32' }}>
|
||||
Ihre Zahlung wurde erfolgreich verarbeitet. Die Bestellung wird automatisch abgeschlossen.
|
||||
{this.props.t ? this.props.t('payment.orderProcessing') : 'Ihre Zahlung wurde erfolgreich verarbeitet. Die Bestellung wird automatisch abgeschlossen.'}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body1" sx={{ mt: 2, color: '#d32f2f' }}>
|
||||
Ihre Zahlung konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut oder wählen Sie eine andere Zahlungsmethode.
|
||||
{this.props.t ? this.props.t('payment.paymentError') : 'Ihre Zahlung konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut oder wählen Sie eine andere Zahlungsmethode.'}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{isCompletingOrder && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -75,7 +78,7 @@ class PaymentConfirmationDialog extends Component {
|
||||
}
|
||||
}}
|
||||
>
|
||||
Weiter einkaufen
|
||||
{this.props.t ? this.props.t('cart.continueShopping') : 'Weiter einkaufen'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onViewOrders}
|
||||
@@ -85,7 +88,7 @@ class PaymentConfirmationDialog extends Component {
|
||||
'&:hover': { bgcolor: '#1b5e20' }
|
||||
}}
|
||||
>
|
||||
Zu meinen Bestellungen
|
||||
{this.props.t ? this.props.t('payment.viewOrders') : 'Zu meinen Bestellungen'}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
@@ -94,4 +97,4 @@ class PaymentConfirmationDialog extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default PaymentConfirmationDialog;
|
||||
export default withI18n()(PaymentConfirmationDialog);
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useCallback } from "react";
|
||||
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
|
||||
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
|
||||
useEffect(() => {
|
||||
if ((deliveryMethod === "DHL" || deliveryMethod === "DPD") && paymentMethod === "cash") {
|
||||
handlePaymentMethodChange({ target: { value: "stripe" } });
|
||||
handlePaymentMethodChange({ target: { value: "wire" /*stripe*/ } });
|
||||
}
|
||||
}, [deliveryMethod, paymentMethod, handlePaymentMethodChange]);
|
||||
|
||||
@@ -38,11 +39,11 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
|
||||
const paymentOptions = [
|
||||
{
|
||||
id: "wire",
|
||||
name: "Überweisung",
|
||||
description: "Bezahlen Sie per Banküberweisung",
|
||||
name: t ? t('payment.methods.bankTransfer') : "Überweisung",
|
||||
description: t ? t('payment.methods.bankTransferDescription') : "Bezahlen Sie per Banküberweisung",
|
||||
disabled: totalAmount === 0,
|
||||
},
|
||||
{
|
||||
/*{
|
||||
id: "stripe",
|
||||
name: "Karte oder Sofortüberweisung",
|
||||
description: totalAmount < 0.50 && totalAmount > 0
|
||||
@@ -55,18 +56,32 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
|
||||
"/assets/images/mastercard.png",
|
||||
"/assets/images/visa_electron.png",
|
||||
],
|
||||
},
|
||||
},*/
|
||||
/*{
|
||||
id: "mollie",
|
||||
name: t ? t('payment.methods.cardPayment') : "Karte, Sofortüberweisung, Apple Pay, Google Pay, PayPal",
|
||||
description: totalAmount < 0.50 && totalAmount > 0
|
||||
? (t ? t('payment.methods.cardPaymentMinAmount') : "Bezahlen Sie per Karte oder Sofortüberweisung (Mindestbetrag: 0,50 €)")
|
||||
: (t ? t('payment.methods.cardPaymentDescription') : "Bezahlen Sie per Karte oder Sofortüberweisung"),
|
||||
disabled: totalAmount < 0.50 || (deliveryMethod !== "DHL" && deliveryMethod !== "DPD" && deliveryMethod !== "Abholung"),
|
||||
icons: [
|
||||
"/assets/images/giropay.png",
|
||||
"/assets/images/maestro.png",
|
||||
"/assets/images/mastercard.png",
|
||||
"/assets/images/visa_electron.png",
|
||||
],
|
||||
},*/
|
||||
{
|
||||
id: "onDelivery",
|
||||
name: "Nachnahme",
|
||||
description: "Bezahlen Sie bei Lieferung (8,99 € Aufschlag)",
|
||||
name: t ? t('payment.methods.cashOnDelivery') : "Nachnahme",
|
||||
description: t ? t('payment.methods.cashOnDeliveryDescription') : "Bezahlen Sie bei Lieferung (8,99 € Aufschlag)",
|
||||
disabled: totalAmount === 0 || deliveryMethod !== "DHL",
|
||||
icons: ["/assets/images/cash.png"],
|
||||
},
|
||||
{
|
||||
id: "cash",
|
||||
name: "Zahlung in der Filiale",
|
||||
description: "Bei Abholung bezahlen",
|
||||
name: t ? t('payment.methods.cashInStore') : "Zahlung in der Filiale",
|
||||
description: t ? t('payment.methods.cashInStoreDescription') : "Bei Abholung bezahlen",
|
||||
disabled: false, // Always enabled
|
||||
icons: ["/assets/images/cash.png"],
|
||||
},
|
||||
@@ -75,7 +90,7 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Zahlungsart wählen
|
||||
{t ? t('payment.methods.selectPaymentMethod') : 'Zahlungsart wählen'}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
@@ -175,4 +190,4 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentMethodSelector;
|
||||
export default withI18n()(PaymentMethodSelector);
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
IconButton,
|
||||
Snackbar
|
||||
} from '@mui/material';
|
||||
import { ContentCopy } from '@mui/icons-material';
|
||||
import ContentCopy from '@mui/icons-material/ContentCopy';
|
||||
import { withI18n } from '../../i18n/withTranslation.js';
|
||||
|
||||
class SettingsTab extends Component {
|
||||
constructor(props) {
|
||||
@@ -47,7 +48,7 @@ class SettingsTab extends Component {
|
||||
this.setState({ newEmail: user.email || '' });
|
||||
|
||||
// Check if user has an API key
|
||||
this.props.socket.emit('isApiKey', (response) => {
|
||||
window.socketManager.emit('isApiKey', (response) => {
|
||||
if (response.success && response.hasApiKey) {
|
||||
this.setState({
|
||||
hasApiKey: true,
|
||||
@@ -72,38 +73,38 @@ class SettingsTab extends Component {
|
||||
|
||||
// Validation
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
this.setState({ loading: true });
|
||||
|
||||
// Call socket.io endpoint to update password
|
||||
this.props.socket.emit('updatePassword',
|
||||
window.socketManager.emit('updatePassword',
|
||||
{ oldPassword: this.state.currentPassword, newPassword: this.state.newPassword },
|
||||
(response) => {
|
||||
this.setState({ loading: false });
|
||||
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
passwordSuccess: 'Passwort erfolgreich aktualisiert',
|
||||
passwordSuccess: this.props.t ? this.props.t('settings.success.passwordUpdated') : 'Passwort erfolgreich aktualisiert',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
} else {
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
this.setState({ loading: true });
|
||||
|
||||
// Call socket.io endpoint to update email
|
||||
this.props.socket.emit('updateEmail',
|
||||
window.socketManager.emit('updateEmail',
|
||||
{ password: this.state.password, email: this.state.newEmail },
|
||||
(response) => {
|
||||
this.setState({ loading: false });
|
||||
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
emailSuccess: 'E-Mail-Adresse erfolgreich aktualisiert',
|
||||
emailSuccess: this.props.t ? this.props.t('settings.success.emailUpdated') : 'E-Mail-Adresse erfolgreich aktualisiert',
|
||||
password: ''
|
||||
});
|
||||
|
||||
@@ -157,7 +158,7 @@ class SettingsTab extends Component {
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
const user = JSON.parse(storedUser);
|
||||
|
||||
this.props.socket.emit('createApiKey', user.id, (response) => {
|
||||
window.socketManager.emit('createApiKey', user.id, (response) => {
|
||||
this.setState({ loadingApiKey: false });
|
||||
|
||||
if (response.success) {
|
||||
@@ -238,7 +239,7 @@ class SettingsTab extends Component {
|
||||
<Box sx={{ p: { xs: 1, sm: 3 } }}>
|
||||
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
Passwort ändern
|
||||
{this.props.t ? this.props.t('settings.changePassword') : 'Passwort ändern'}
|
||||
</Typography>
|
||||
|
||||
{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}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Aktuelles Passwort"
|
||||
label={this.props.t ? this.props.t('settings.currentPassword') : 'Aktuelles Passwort'}
|
||||
type="password"
|
||||
fullWidth
|
||||
value={this.state.currentPassword}
|
||||
@@ -257,7 +258,7 @@ class SettingsTab extends Component {
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Neues Passwort"
|
||||
label={this.props.t ? this.props.t('settings.newPassword') : 'Neues Passwort'}
|
||||
type="password"
|
||||
fullWidth
|
||||
value={this.state.newPassword}
|
||||
@@ -267,7 +268,7 @@ class SettingsTab extends Component {
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Neues Passwort bestätigen"
|
||||
label={this.props.t ? this.props.t('settings.confirmNewPassword') : 'Neues Passwort bestätigen'}
|
||||
type="password"
|
||||
fullWidth
|
||||
value={this.state.confirmPassword}
|
||||
@@ -282,7 +283,7 @@ class SettingsTab extends Component {
|
||||
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
||||
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>
|
||||
</Box>
|
||||
</Paper>
|
||||
@@ -291,7 +292,7 @@ class SettingsTab extends Component {
|
||||
|
||||
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
E-Mail-Adresse ändern
|
||||
{this.props.t ? this.props.t('settings.changeEmail') : 'E-Mail-Adresse ändern'}
|
||||
</Typography>
|
||||
|
||||
{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}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Passwort"
|
||||
label={this.props.t ? this.props.t('settings.password') : 'Passwort'}
|
||||
type="password"
|
||||
fullWidth
|
||||
value={this.state.password}
|
||||
@@ -310,7 +311,7 @@ class SettingsTab extends Component {
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Neue E-Mail-Adresse"
|
||||
label={this.props.t ? this.props.t('settings.newEmail') : 'Neue E-Mail-Adresse'}
|
||||
type="email"
|
||||
fullWidth
|
||||
value={this.state.newEmail}
|
||||
@@ -325,7 +326,7 @@ class SettingsTab extends Component {
|
||||
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
||||
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>
|
||||
</Box>
|
||||
</Paper>
|
||||
@@ -334,11 +335,11 @@ class SettingsTab extends Component {
|
||||
|
||||
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
API-Schlüssel
|
||||
{this.props.t ? this.props.t('settings.apiKey') : 'API-Schlüssel'}
|
||||
</Typography>
|
||||
|
||||
<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>
|
||||
|
||||
{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.apiKey && this.state.apiKeyDisplay !== '************' && (
|
||||
<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>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
API-Dokumentation: {' '}
|
||||
{this.props.t ? this.props.t('settings.apiDocumentation') : 'API-Dokumentation:'} {' '}
|
||||
<a
|
||||
href={`${window.location.protocol}//${window.location.host}/api/`}
|
||||
target="_blank"
|
||||
@@ -367,7 +368,7 @@ class SettingsTab extends Component {
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mt: 2 }}>
|
||||
<TextField
|
||||
label="API-Schlüssel"
|
||||
label={this.props.t ? this.props.t('settings.apiKey') : 'API-Schlüssel'}
|
||||
value={this.state.apiKeyDisplay}
|
||||
disabled
|
||||
fullWidth
|
||||
@@ -381,11 +382,12 @@ class SettingsTab extends Component {
|
||||
{this.state.apiKeyDisplay !== '************' && this.state.apiKey && (
|
||||
<IconButton
|
||||
onClick={this.handleCopyToClipboard}
|
||||
aria-label={this.props.t ? this.props.t('settings.copyToClipboard') : 'In Zwischenablage kopieren'}
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
'&:hover': { bgcolor: 'rgba(46, 125, 50, 0.1)' }
|
||||
}}
|
||||
title="In Zwischenablage kopieren"
|
||||
title={this.props.t ? this.props.t('settings.copyToClipboard') : 'In Zwischenablage kopieren'}
|
||||
>
|
||||
<ContentCopy />
|
||||
</IconButton>
|
||||
@@ -405,7 +407,7 @@ class SettingsTab extends Component {
|
||||
{this.state.loadingApiKey ? (
|
||||
<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>
|
||||
</Box>
|
||||
@@ -415,7 +417,7 @@ class SettingsTab extends Component {
|
||||
open={this.state.copySnackbarOpen}
|
||||
autoHideDuration={3000}
|
||||
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' }}
|
||||
/>
|
||||
</Box>
|
||||
@@ -423,4 +425,4 @@ class SettingsTab extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default SettingsTab;
|
||||
export default withI18n()(SettingsTab);
|
||||
196
src/config.js
@@ -8,27 +8,205 @@ const config = {
|
||||
siteName: "Growheads.de",
|
||||
brandName: "GrowHeads",
|
||||
currency: "EUR",
|
||||
language: "de-DE",
|
||||
language: "de-DE", // Will be updated dynamically based on i18n
|
||||
country: "DE",
|
||||
|
||||
// Shop Descriptions
|
||||
descriptions: {
|
||||
short: "GrowHeads - Online-Shop für Cannanis-Samen, Stecklinge und Gartenbedarf",
|
||||
long: "GrowHeads - Ihr Online-Shop für hochwertige Samen, Pflanzen und Gartenbedarf zur Cannabis Kultivierung. Entdecken Sie unser großes Sortiment an Saatgut, Pflanzen und Gartenzubehör für Ihren grünen Daumen."
|
||||
// Multilingual configurations
|
||||
languages: {
|
||||
de: {
|
||||
code: "de-DE",
|
||||
name: "Deutsch",
|
||||
shortName: "DE"
|
||||
},
|
||||
en: {
|
||||
code: "en-US",
|
||||
name: "English",
|
||||
shortName: "EN"
|
||||
},
|
||||
es: {
|
||||
code: "es-ES",
|
||||
name: "Español",
|
||||
shortName: "ES"
|
||||
},
|
||||
fr: {
|
||||
code: "fr-FR",
|
||||
name: "Français",
|
||||
shortName: "FR"
|
||||
},
|
||||
it: {
|
||||
code: "it-IT",
|
||||
name: "Italiano",
|
||||
shortName: "IT"
|
||||
},
|
||||
pl: {
|
||||
code: "pl-PL",
|
||||
name: "Polski",
|
||||
shortName: "PL"
|
||||
},
|
||||
hu: {
|
||||
code: "hu-HU",
|
||||
name: "Magyar",
|
||||
shortName: "HU"
|
||||
},
|
||||
sr: {
|
||||
code: "sr-RS",
|
||||
name: "Српски",
|
||||
shortName: "SR"
|
||||
},
|
||||
bg: {
|
||||
code: "bg-BG",
|
||||
name: "Български",
|
||||
shortName: "BG"
|
||||
},
|
||||
ru: {
|
||||
code: "ru-RU",
|
||||
name: "Русский",
|
||||
shortName: "RU"
|
||||
},
|
||||
uk: {
|
||||
code: "uk-UA",
|
||||
name: "Українська",
|
||||
shortName: "UK"
|
||||
},
|
||||
sk: {
|
||||
code: "sk-SK",
|
||||
name: "Slovenčina",
|
||||
shortName: "SK"
|
||||
},
|
||||
sl: {
|
||||
code: "sl-SI",
|
||||
name: "Slovenščina",
|
||||
shortName: "SI"
|
||||
},
|
||||
cs: {
|
||||
code: "cs-CZ",
|
||||
name: "Čeština",
|
||||
shortName: "CS"
|
||||
},
|
||||
ro: {
|
||||
code: "ro-RO",
|
||||
name: "Română",
|
||||
shortName: "RO"
|
||||
},
|
||||
hr: {
|
||||
code: "hr-HR",
|
||||
name: "Hrvatski",
|
||||
shortName: "HR"
|
||||
},
|
||||
sv: {
|
||||
code: "sv-SE",
|
||||
name: "Svenska",
|
||||
shortName: "SE"
|
||||
},
|
||||
tr: {
|
||||
code: "tr-TR",
|
||||
name: "Türkçe",
|
||||
shortName: "TR"
|
||||
},
|
||||
el: {
|
||||
code: "el-GR",
|
||||
name: "Ελληνικά",
|
||||
shortName: "GR"
|
||||
},
|
||||
ar: {
|
||||
code: "ar-EG",
|
||||
name: "العربية",
|
||||
shortName: "EG"
|
||||
},
|
||||
zh: {
|
||||
code: "zh-CN",
|
||||
name: "中文",
|
||||
shortName: "CN"
|
||||
}
|
||||
},
|
||||
|
||||
// Keywords
|
||||
keywords: "Seeds, Steckling, Cannabis, Biobizz, Growheads",
|
||||
// Shop Descriptions - Multilingual
|
||||
descriptions: {
|
||||
de: {
|
||||
short: "GrowHeads - Online-Shop für Cannabis-Samen, Stecklinge und Gartenbedarf",
|
||||
long: "GrowHeads - Ihr Online-Shop für hochwertige Samen, Pflanzen und Gartenbedarf zur Cannabis Kultivierung. Entdecken Sie unser großes Sortiment an Saatgut, Pflanzen und Gartenzubehör für Ihren grünen Daumen."
|
||||
},
|
||||
en: {
|
||||
short: "GrowHeads - Online Shop for Cannabis Seeds, Cuttings and Garden Supplies",
|
||||
long: "GrowHeads - Your online shop for high-quality seeds, plants and garden supplies for cannabis cultivation. Discover our large assortment of seeds, plants and garden accessories for your green thumb."
|
||||
},
|
||||
es: {
|
||||
short: "GrowHeads - Tienda Online de Semillas de Cannabis, Esquejes y Suministros de Jardín",
|
||||
long: "GrowHeads - Tu tienda online de semillas, plantas y suministros de jardín de alta calidad para el cultivo de cannabis. Descubre nuestro gran surtido de semillas, plantas y accesorios de jardín para tu pulgar verde."
|
||||
},
|
||||
fr: {
|
||||
short: "GrowHeads - Boutique en ligne de Graines de Cannabis, Boutures et Fournitures de Jardinage",
|
||||
long: "GrowHeads - Votre boutique en ligne pour des graines, plantes et fournitures de jardinage de haute qualité pour la culture du cannabis. Découvrez notre large assortiment de graines, plantes et accessoires de jardinage pour votre pouce vert."
|
||||
},
|
||||
it: {
|
||||
short: "GrowHeads - Negozio Online di Semi di Cannabis, Talee e Forniture da Giardino",
|
||||
long: "GrowHeads - Il tuo negozio online per semi, piante e forniture da giardino di alta qualità per la coltivazione di cannabis. Scopri il nostro vasto assortimento di semi, piante e accessori da giardino per il tuo pollice verde."
|
||||
},
|
||||
pl: {
|
||||
short: "GrowHeads - Sklep Online z Nasionami Konopi, Sadzonkami i Artykułami Ogrodniczymi",
|
||||
long: "GrowHeads - Twój sklep online z wysokiej jakości nasionami, roślinami i artykułami ogrodniczymi do uprawy konopi. Odkryj nasz duży asortyment nasion, roślin i akcesoriów ogrodniczych dla Twojego zielonego kciuka."
|
||||
},
|
||||
hu: {
|
||||
short: "GrowHeads - Online Bolt Kannabisz Magokhoz, Dugványokhoz és Kerti Kellékekhez",
|
||||
long: "GrowHeads - Az Ön online boltja minőségi magokhoz, növényekhez és kerti kellékekhez a kannabisz termesztéshez. Fedezze fel nagy választékunkat magokból, növényekből és kerti kiegészítőkből a zöld hüvelykujjához."
|
||||
},
|
||||
sr: {
|
||||
short: "GrowHeads - Онлајн продавница за семена канабиса, резнице и вртларски прибор",
|
||||
long: "GrowHeads - Ваша онлајн продавница за висококвалитетна семена, биљке и вртларски прибор за узгајање канабиса. Откријте наш велики асортиман семена, биљака и вртларских додатака за ваш зелени палац."
|
||||
},
|
||||
bg: {
|
||||
short: "GrowHeads - Онлайн магазин за семена на канабис, резници и градински принадлежности",
|
||||
long: "GrowHeads - Вашият онлайн магазин за висококачествени семена, растения и градински принадлежности за отглеждане на канабис. Открийте нашия голям асортимент от семена, растения и градински аксесоари за вашия зелен палец."
|
||||
},
|
||||
ru: {
|
||||
short: "GrowHeads - Интернет-магазин семян каннабиса, черенков и садовых принадлежностей",
|
||||
long: "GrowHeads - Ваш интернет-магазин высококачественных семян, растений и садовых принадлежностей для выращивания каннабиса. Откройте для себя наш большой ассортимент семян, растений и садовых аксессуаров для вашего зеленого пальца."
|
||||
},
|
||||
uk: {
|
||||
short: "GrowHeads - Інтернет-магазин насіння канабісу, живців та садових приладдя",
|
||||
long: "GrowHeads - Ваш інтернет-магазин високоякісного насіння, рослин та садових приладдя для вирощування канабісу. Відкрийте для себе наш великий асортимент насіння, рослин та садових аксесуарів для вашого зеленого пальця."
|
||||
},
|
||||
sk: {
|
||||
short: "GrowHeads - Online obchod so semenami konopy, sadenicami a záhradnými potrebami",
|
||||
long: "GrowHeads - Váš online obchod s vysoko kvalitnými semenami, rastlinami a záhradnými potrebami na pestovanie konopy. Objavte náš veľký sortiment semien, rastlín a záhradných doplnkov pre váš zelený palec."
|
||||
},
|
||||
cs: {
|
||||
short: "GrowHeads - Online obchod se semeny konopí, sazenicemi a zahradními potřebami",
|
||||
long: "GrowHeads - Váš online obchod s vysoce kvalitními semeny, rostlinami a zahradními potřebami pro pěstování konopí. Objevte náš velký sortiment semen, rostlin a zahradních doplňků pro váš zelený palec."
|
||||
},
|
||||
ro: {
|
||||
short: "GrowHeads - Magazin Online de Semințe de Cannabis, Butași și Articole de Grădinărit",
|
||||
long: "GrowHeads - Magazinul dumneavoastră online pentru semințe, plante și articole de grădinărit de înaltă calitate pentru cultivarea de cannabis. Descoperiți sortimentul nostru mare de semințe, plante și accesorii de grădinărit pentru degetul verde."
|
||||
}
|
||||
},
|
||||
|
||||
// Keywords - Multilingual
|
||||
keywords: {
|
||||
de: "Seeds, Steckling, Cannabis, Biobizz, Growheads",
|
||||
en: "Seeds, Cuttings, Cannabis, Biobizz, Growheads",
|
||||
es: "Semillas, Esquejes, Cannabis, Biobizz, Growheads",
|
||||
fr: "Graines, Boutures, Cannabis, Biobizz, Growheads",
|
||||
it: "Semi, Talee, Cannabis, Biobizz, Growheads",
|
||||
pl: "Nasiona, Sadzonki, Konopie, Biobizz, Growheads",
|
||||
hu: "Magok, Dugványok, Kannabisz, Biobizz, Growheads",
|
||||
sr: "Семена, Резнице, Канабис, Biobizz, Growheads",
|
||||
bg: "Семена, Резници, Канабис, Biobizz, Growheads",
|
||||
ru: "Семена, Черенки, Каннабис, Biobizz, Growheads",
|
||||
uk: "Насіння, Живці, Канабіс, Biobizz, Growheads",
|
||||
sk: "Semená, Sadenky, Konope, Biobizz, Growheads",
|
||||
cs: "Semena, Sazenice, Konopí, Biobizz, Growheads",
|
||||
ro: "Semințe, Butași, Cannabis, Biobizz, Growheads"
|
||||
},
|
||||
|
||||
// Shipping
|
||||
shipping: {
|
||||
defaultCost: "4.99 EUR",
|
||||
defaultCost: "5.90 EUR",
|
||||
defaultService: "Standard"
|
||||
},
|
||||
|
||||
// Images
|
||||
images: {
|
||||
logo: "/assets/images/sh.png",
|
||||
logo: "/assets/images/sh.avif",
|
||||
placeholder: "/assets/images/nopicture.jpg"
|
||||
},
|
||||
|
||||
|
||||
31
src/context/CategoryContext.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import React, { createContext, useState, useContext } from 'react';
|
||||
|
||||
const CategoryContext = createContext({
|
||||
currentCategory: null,
|
||||
setCurrentCategory: () => {}
|
||||
});
|
||||
|
||||
export const useCategory = () => useContext(CategoryContext);
|
||||
|
||||
export const withCategory = (Component) => {
|
||||
return (props) => {
|
||||
const categoryContext = useCategory();
|
||||
return <Component {...props} categoryContext={categoryContext} />;
|
||||
};
|
||||
};
|
||||
|
||||
export const CategoryContextProvider = ({ children }) => {
|
||||
const [currentCategory, setCurrentCategory] = useState(null);
|
||||
|
||||
const setCurrentCategoryWithLog = (category) => {
|
||||
console.log('CategoryContext: Setting current category to:', category);
|
||||
setCurrentCategory(category);
|
||||
};
|
||||
|
||||
return (
|
||||
<CategoryContext.Provider value={{ currentCategory, setCurrentCategory: setCurrentCategoryWithLog }}>
|
||||
{children}
|
||||
</CategoryContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||