Compare commits
111 Commits
mollie
...
1fd6ed85b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -56,6 +56,8 @@ yarn-error.log*
|
|||||||
# Local configuration
|
# Local configuration
|
||||||
src/config.local.js
|
src/config.local.js
|
||||||
|
|
||||||
|
taxonomy-with-ids.de-DE*
|
||||||
|
|
||||||
# Local development notes
|
# Local development notes
|
||||||
dev-notes.md
|
dev-notes.md
|
||||||
dev-notes.local.md
|
dev-notes.local.md
|
||||||
21
.vscode/launch.json
vendored
21
.vscode/launch.json
vendored
@@ -3,20 +3,31 @@
|
|||||||
// This will install dependencies before starting the dev server
|
// This will install dependencies before starting the dev server
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
|
|
||||||
{
|
{
|
||||||
"type": "node-terminal",
|
"type": "node-terminal",
|
||||||
"name": "Start with API propxy to seedheads.de (Install Deps)",
|
"name": "Start with API propxy to seedheads.de (Install Deps)",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"command": "npm run start:seedheads",
|
"command": "npm run start:seedheads",
|
||||||
"preLaunchTask": "npm: install",
|
"preLaunchTask": "npm: install",
|
||||||
"cwd": "${workspaceFolder}"
|
"env": {
|
||||||
}, {
|
"NODE_ENV": "development"
|
||||||
"type": "node-terminal",
|
},
|
||||||
|
"envFile": "${workspaceFolder}/.env",
|
||||||
|
"skipFiles": [
|
||||||
|
"<node_internals>/**"
|
||||||
|
]
|
||||||
|
},{
|
||||||
"name": "Start",
|
"name": "Start",
|
||||||
|
"type": "node-terminal",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"command": "npm run start",
|
"command": "npm run start",
|
||||||
"cwd": "${workspaceFolder}"
|
"env": {
|
||||||
|
"NODE_ENV": "development"
|
||||||
|
},
|
||||||
|
"envFile": "${workspaceFolder}/.env",
|
||||||
|
"skipFiles": [
|
||||||
|
"<node_internals>/**"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
209
MULTILINGUAL_IMPLEMENTATION.md
Normal file
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!
|
||||||
1734
package-lock.json
generated
1734
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -13,7 +13,14 @@
|
|||||||
"lint": "eslint src/**/*.{js,jsx}",
|
"lint": "eslint src/**/*.{js,jsx}",
|
||||||
"prerender": "node prerender.cjs",
|
"prerender": "node prerender.cjs",
|
||||||
"prerender:prod": "cross-env NODE_ENV=production node prerender.cjs",
|
"prerender:prod": "cross-env NODE_ENV=production node prerender.cjs",
|
||||||
"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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -27,10 +34,15 @@
|
|||||||
"@stripe/react-stripe-js": "^3.7.0",
|
"@stripe/react-stripe-js": "^3.7.0",
|
||||||
"@stripe/stripe-js": "^7.3.1",
|
"@stripe/stripe-js": "^7.3.1",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
|
"country-flag-icons": "^1.5.19",
|
||||||
"html-react-parser": "^5.2.5",
|
"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": "^19.1.0",
|
||||||
"react-chartjs-2": "^5.3.0",
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-i18next": "^15.6.0",
|
||||||
"react-router-dom": "^7.6.2",
|
"react-router-dom": "^7.6.2",
|
||||||
"sharp": "^0.34.2",
|
"sharp": "^0.34.2",
|
||||||
"socket.io-client": "^4.7.5"
|
"socket.io-client": "^4.7.5"
|
||||||
@@ -64,6 +76,8 @@
|
|||||||
"webpack-bundle-analyzer": "^4.10.2",
|
"webpack-bundle-analyzer": "^4.10.2",
|
||||||
"webpack-cli": "^6.0.1",
|
"webpack-cli": "^6.0.1",
|
||||||
"webpack-dev-server": "^5.2.2",
|
"webpack-dev-server": "^5.2.2",
|
||||||
"webpack-node-externals": "^3.0.0"
|
"webpack-node-externals": "^3.0.0",
|
||||||
|
"xmldom": "^0.6.0",
|
||||||
|
"xpath": "^0.0.34"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
208
prerender-single-product.cjs
Normal file
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: ["polling", "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);
|
||||||
|
});
|
||||||
@@ -27,6 +27,74 @@ const io = require("socket.io-client");
|
|||||||
const os = require("os");
|
const os = require("os");
|
||||||
const { Worker, isMainThread, parentPort, workerData } = require("worker_threads");
|
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
|
// Import split modules
|
||||||
const config = require("./prerender/config.cjs");
|
const config = require("./prerender/config.cjs");
|
||||||
|
|
||||||
@@ -35,7 +103,6 @@ const shopConfig = require("./src/config.js").default;
|
|||||||
const { renderPage } = require("./prerender/renderer.cjs");
|
const { renderPage } = require("./prerender/renderer.cjs");
|
||||||
const {
|
const {
|
||||||
collectAllCategories,
|
collectAllCategories,
|
||||||
writeCombinedCssFile,
|
|
||||||
} = require("./prerender/utils.cjs");
|
} = require("./prerender/utils.cjs");
|
||||||
const {
|
const {
|
||||||
generateProductMetaTags,
|
generateProductMetaTags,
|
||||||
@@ -107,6 +174,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
|||||||
const actualSeoName = productDetails.product.seoName || productSeoName;
|
const actualSeoName = productDetails.product.seoName || productSeoName;
|
||||||
const productComponent = React.createElement(PrerenderProduct, {
|
const productComponent = React.createElement(PrerenderProduct, {
|
||||||
productData: productDetails,
|
productData: productDetails,
|
||||||
|
t: global.i18n.t.bind(global.i18n),
|
||||||
});
|
});
|
||||||
|
|
||||||
const filename = `Artikel/${actualSeoName}`;
|
const filename = `Artikel/${actualSeoName}`;
|
||||||
@@ -133,7 +201,8 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
|||||||
combinedMetaTags,
|
combinedMetaTags,
|
||||||
true,
|
true,
|
||||||
config,
|
config,
|
||||||
true // Suppress logs during parallel rendering to avoid interfering with progress bar
|
true, // Suppress logs during parallel rendering to avoid interfering with progress bar
|
||||||
|
productDetails // Pass product data for cache population
|
||||||
);
|
);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -572,8 +641,7 @@ const renderApp = async (categoryData, socket) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the combined CSS file after all pages are rendered
|
// No longer writing combined CSS file - each page has its own embedded CSS
|
||||||
writeCombinedCssFile(config.globalCssCollection, config.outputDir);
|
|
||||||
|
|
||||||
// Generate XML sitemap with all rendered pages
|
// Generate XML sitemap with all rendered pages
|
||||||
console.log("\n🗺️ Generating XML sitemap...");
|
console.log("\n🗺️ Generating XML sitemap...");
|
||||||
@@ -630,6 +698,26 @@ const renderApp = async (categoryData, socket) => {
|
|||||||
console.log(` - File verification: ⚠️ ${verifyError.message}`);
|
console.log(` - File verification: ⚠️ ${verifyError.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate XML against Google Shopping schema
|
||||||
|
try {
|
||||||
|
const ProductsXmlValidator = require('./scripts/validate-products-xml.cjs');
|
||||||
|
const validator = new ProductsXmlValidator(productsXmlPath);
|
||||||
|
const validationResults = await validator.validate();
|
||||||
|
|
||||||
|
if (validationResults.valid) {
|
||||||
|
console.log(` - Schema validation: ✅ Valid Google Shopping RSS 2.0`);
|
||||||
|
} else {
|
||||||
|
console.log(` - Schema validation: ⚠️ ${validationResults.summary.errorCount} errors, ${validationResults.summary.warningCount} warnings`);
|
||||||
|
|
||||||
|
// Show first few errors for quick debugging
|
||||||
|
if (validationResults.errors.length > 0) {
|
||||||
|
console.log(` - First error: ${validationResults.errors[0].message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (validationError) {
|
||||||
|
console.log(` - Schema validation: ⚠️ Validation failed: ${validationError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ Error generating products.xml: ${error.message}`);
|
console.error(`❌ Error generating products.xml: ${error.message}`);
|
||||||
console.log("\n⚠️ Skipping products.xml generation due to errors");
|
console.log("\n⚠️ Skipping products.xml generation due to errors");
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
|||||||
const imageBuffer = await fetchProductImage(socket, bildId);
|
const imageBuffer = await fetchProductImage(socket, bildId);
|
||||||
|
|
||||||
// If overlay exists, apply it to the image
|
// If overlay exists, apply it to the image
|
||||||
if (fs.existsSync(overlayPath)) {
|
if (false && fs.existsSync(overlayPath)) {
|
||||||
try {
|
try {
|
||||||
// Get image dimensions to center the overlay
|
// Get image dimensions to center the overlay
|
||||||
const baseImage = sharp(Buffer.from(imageBuffer));
|
const baseImage = sharp(Buffer.from(imageBuffer));
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ const renderPage = (
|
|||||||
metaTags = "",
|
metaTags = "",
|
||||||
needsRouter = false,
|
needsRouter = false,
|
||||||
config,
|
config,
|
||||||
suppressLogs = false
|
suppressLogs = false,
|
||||||
|
productData = null
|
||||||
) => {
|
) => {
|
||||||
const {
|
const {
|
||||||
isProduction,
|
isProduction,
|
||||||
@@ -26,7 +27,7 @@ const renderPage = (
|
|||||||
globalCssCollection,
|
globalCssCollection,
|
||||||
webpackEntrypoints,
|
webpackEntrypoints,
|
||||||
} = config;
|
} = config;
|
||||||
const { writeCombinedCssFile, optimizeCss } = require("./utils.cjs");
|
const { optimizeCss } = require("./utils.cjs");
|
||||||
|
|
||||||
// @note Set prerender fallback flag in global environment for CategoryBox during SSR
|
// @note Set prerender fallback flag in global environment for CategoryBox during SSR
|
||||||
if (typeof global !== "undefined" && global.window) {
|
if (typeof global !== "undefined" && global.window) {
|
||||||
@@ -51,26 +52,20 @@ const renderPage = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
let renderedMarkup;
|
let renderedMarkup;
|
||||||
|
let pageSpecificCss = ""; // Declare outside try block for broader scope
|
||||||
|
|
||||||
try {
|
try {
|
||||||
renderedMarkup = ReactDOMServer.renderToString(pageElement);
|
renderedMarkup = ReactDOMServer.renderToString(pageElement);
|
||||||
const emotionChunks = extractCriticalToChunks(renderedMarkup);
|
const emotionChunks = extractCriticalToChunks(renderedMarkup);
|
||||||
|
|
||||||
// Collect CSS from this page
|
// Collect CSS from this page for direct inlining (no global accumulation)
|
||||||
if (emotionChunks.styles.length > 0) {
|
if (emotionChunks.styles.length > 0) {
|
||||||
const oldSize = globalCssCollection.size;
|
|
||||||
|
|
||||||
emotionChunks.styles.forEach((style) => {
|
emotionChunks.styles.forEach((style) => {
|
||||||
if (style.css) {
|
if (style.css) {
|
||||||
globalCssCollection.add(style.css);
|
pageSpecificCss += style.css + "\n";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (!suppressLogs) console.log(` - CSS rules: ${emotionChunks.styles.length}`);
|
||||||
// Check if new styles were added
|
|
||||||
if (globalCssCollection.size > oldSize) {
|
|
||||||
// Write CSS file immediately when new styles are added
|
|
||||||
writeCombinedCssFile(globalCssCollection, outputDir);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ Rendering failed for ${filename}:`, error);
|
console.error(`❌ Rendering failed for ${filename}:`, error);
|
||||||
@@ -126,26 +121,12 @@ const renderPage = (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Read and inline prerender CSS to eliminate render-blocking request
|
// Inline page-specific CSS directly (no shared prerender.css file)
|
||||||
try {
|
if (pageSpecificCss.trim()) {
|
||||||
const prerenderCssPath = path.resolve(__dirname, "..", outputDir, "prerender.css");
|
// Use advanced CSS optimization on page-specific CSS
|
||||||
if (fs.existsSync(prerenderCssPath)) {
|
const optimizedPageCss = optimizeCss(pageSpecificCss);
|
||||||
const prerenderCssContent = fs.readFileSync(prerenderCssPath, "utf8");
|
inlinedCss += optimizedPageCss;
|
||||||
// Use advanced CSS optimization
|
if (!suppressLogs) console.log(` ✅ Inlined page-specific CSS (${Math.round(optimizedPageCss.length / 1024)}KB)`);
|
||||||
const optimizedPrerenderCss = optimizeCss(prerenderCssContent);
|
|
||||||
inlinedCss += optimizedPrerenderCss;
|
|
||||||
if (!suppressLogs) console.log(` ✅ Inlined prerender CSS (${Math.round(optimizedPrerenderCss.length / 1024)}KB)`);
|
|
||||||
} else {
|
|
||||||
// Fallback to external loading if prerender.css doesn't exist yet
|
|
||||||
additionalTags += `<link rel="preload" href="/prerender.css" as="style" onload="this.onload=null;this.rel='stylesheet'">`;
|
|
||||||
additionalTags += `<noscript><link rel="stylesheet" href="/prerender.css"></noscript>`;
|
|
||||||
if (!suppressLogs) console.log(` ⚠️ prerender.css not found for inlining, using async loading`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Fallback to external loading
|
|
||||||
additionalTags += `<link rel="preload" href="/prerender.css" as="style" onload="this.onload=null;this.rel='stylesheet'">`;
|
|
||||||
additionalTags += `<noscript><link rel="stylesheet" href="/prerender.css"></noscript>`;
|
|
||||||
if (!suppressLogs) console.log(` ⚠️ Error reading prerender.css: ${error.message}, using async loading`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add JavaScript files
|
// Add JavaScript files
|
||||||
@@ -182,6 +163,11 @@ const renderPage = (
|
|||||||
content: ${JSON.stringify(renderedMarkup)},
|
content: ${JSON.stringify(renderedMarkup)},
|
||||||
timestamp: ${Date.now()}
|
timestamp: ${Date.now()}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// DEBUG: Multiple alerts throughout the loading process
|
||||||
|
// Debug alerts removed
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -203,6 +189,22 @@ const renderPage = (
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.)
|
||||||
|
const productDetailCacheData = JSON.stringify(productData);
|
||||||
|
productDetailCacheScript = `
|
||||||
|
<script>
|
||||||
|
// Populate window.productDetailCache with complete product data for SPA hydration
|
||||||
|
if (!window.productDetailCache) {
|
||||||
|
window.productDetailCache = {};
|
||||||
|
}
|
||||||
|
window.productDetailCache['${productData.product.seoName}'] = ${productDetailCacheData};
|
||||||
|
</script>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// Combine all CSS (global + inlined) into a single optimized style tag
|
// Combine all CSS (global + inlined) into a single optimized style tag
|
||||||
const combinedCss = globalCss + (inlinedCss ? '\n' + inlinedCss : '');
|
const combinedCss = globalCss + (inlinedCss ? '\n' + inlinedCss : '');
|
||||||
const combinedCssTag = combinedCss ? `<style type="text/css">${combinedCss}</style>` : '';
|
const combinedCssTag = combinedCss ? `<style type="text/css">${combinedCss}</style>` : '';
|
||||||
@@ -214,7 +216,7 @@ const renderPage = (
|
|||||||
|
|
||||||
template = template.replace(
|
template = template.replace(
|
||||||
"</head>",
|
"</head>",
|
||||||
`${resourceHints}${combinedCssTag}${additionalTags}${metaTags}${prerenderFallbackScript}${productCacheScript}</head>`
|
`${resourceHints}${combinedCssTag}${additionalTags}${metaTags}${prerenderFallbackScript}${productCacheScript}${productDetailCacheScript}</head>`
|
||||||
);
|
);
|
||||||
|
|
||||||
const rootDivRegex = /<div id="root"[\s\S]*?>[\s\S]*?<\/div>/;
|
const rootDivRegex = /<div id="root"[\s\S]*?>[\s\S]*?<\/div>/;
|
||||||
@@ -222,8 +224,10 @@ const renderPage = (
|
|||||||
|
|
||||||
let newHtml;
|
let newHtml;
|
||||||
if (rootDivRegex.test(template)) {
|
if (rootDivRegex.test(template)) {
|
||||||
|
if (!suppressLogs) console.log(` 📝 Root div found, replacing with ${renderedMarkup.length} chars of markup`);
|
||||||
newHtml = template.replace(rootDivRegex, replacementHtml);
|
newHtml = template.replace(rootDivRegex, replacementHtml);
|
||||||
} else {
|
} else {
|
||||||
|
if (!suppressLogs) console.log(` ⚠️ No root div found, appending to body`);
|
||||||
newHtml = template.replace("<body>", `<body>${replacementHtml}`);
|
newHtml = template.replace("<body>", `<body>${replacementHtml}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +248,9 @@ const renderPage = (
|
|||||||
console.log(` - Total inlined CSS: ${Math.round(combinedCss.length / 1024)}KB`);
|
console.log(` - Total inlined CSS: ${Math.round(combinedCss.length / 1024)}KB`);
|
||||||
console.log(` - Render-blocking CSS eliminated: ${inlinedCss ? 'YES' : 'NO'}`);
|
console.log(` - Render-blocking CSS eliminated: ${inlinedCss ? 'YES' : 'NO'}`);
|
||||||
console.log(` - Fallback content saved to window.__PRERENDER_FALLBACK__`);
|
console.log(` - Fallback content saved to window.__PRERENDER_FALLBACK__`);
|
||||||
|
if (productDetailCacheScript) {
|
||||||
|
console.log(` - Product detail cache populated for SPA hydration`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -11,6 +11,102 @@ Crawl-delay: 0
|
|||||||
return robotsTxt;
|
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 generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||||
const currentDate = new Date().toISOString();
|
const currentDate = new Date().toISOString();
|
||||||
|
|
||||||
@@ -23,124 +119,131 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
const getGoogleProductCategory = (categoryId) => {
|
const getGoogleProductCategory = (categoryId) => {
|
||||||
const categoryMappings = {
|
const categoryMappings = {
|
||||||
// Seeds & Plants
|
// Seeds & Plants
|
||||||
689: "Home & Garden > Plants > Seeds",
|
689: "543561", // Seeds (Saatgut)
|
||||||
706: "Home & Garden > Plants", // Stecklinge (cuttings)
|
706: "543561", // Stecklinge (cuttings) – ebenfalls Pflanzen/Saatgut
|
||||||
376: "Home & Garden > Plants > Plant & Herb Growing Kits", // Grow-Sets
|
376: "2802", // Grow-Sets – Pflanzen- & Kräuteranbausets
|
||||||
|
|
||||||
// Headshop & Accessories
|
// Headshop & Accessories
|
||||||
709: "Arts & Entertainment > Hobbies & Creative Arts", // Headshop
|
709: "4082", // Headshop – Rauchzubehör
|
||||||
711: "Arts & Entertainment > Hobbies & Creative Arts", // Bongs
|
711: "4082", // Headshop > Bongs – Rauchzubehör
|
||||||
714: "Arts & Entertainment > Hobbies & Creative Arts", // Zubehör
|
714: "4082", // Headshop > Bongs > Zubehör – Rauchzubehör
|
||||||
748: "Arts & Entertainment > Hobbies & Creative Arts", // Köpfe
|
748: "4082", // Headshop > Bongs > Köpfe – Rauchzubehör
|
||||||
749: "Arts & Entertainment > Hobbies & Creative Arts", // Chillums / Diffusoren / Kupplungen
|
749: "4082", // Headshop > Bongs > Chillums/Diffusoren/Kupplungen – Rauchzubehör
|
||||||
896: "Electronics > Electronics Accessories", // Vaporizer
|
896: "3151", // Headshop > Vaporizer – Vaporizer
|
||||||
710: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Grinder
|
710: "5109", // Headshop > Grinder – Gewürzmühlen (Küchenhelfer)
|
||||||
|
|
||||||
// Measuring & Packaging
|
// Measuring & Packaging
|
||||||
186: "Business & Industrial", // Wiegen & Verpacken
|
186: "5631", // Headshop > Wiegen & Verpacken – Aufbewahrung/Zubehör
|
||||||
187: "Business & Industrial > Science & Laboratory > Lab Equipment", // Waagen
|
187: "4767", // Headshop > Waagen – Personenwaagen (Medizinisch)
|
||||||
346: "Home & Garden > Kitchen & Dining > Food Storage", // Vakuumbeutel
|
346: "7118", // Headshop > Vakuumbeutel – Vakuumierer-Beutel
|
||||||
355: "Home & Garden > Kitchen & Dining > Food Storage", // Boveda & Integra Boost
|
355: "606", // Headshop > Boveda & Integra Boost – Luftentfeuchter (nächstmögliche)
|
||||||
407: "Home & Garden > Kitchen & Dining > Food Storage", // Grove Bags
|
407: "3561", // Headshop > Grove Bags – Aufbewahrungsbehälter
|
||||||
449: "Home & Garden > Kitchen & Dining > Food Storage", // Cliptütchen
|
449: "1496", // Headshop > Cliptütchen – Lebensmittelverpackungsmaterial
|
||||||
539: "Home & Garden > Kitchen & Dining > Food Storage", // Gläser & Dosen
|
539: "3110", // Headshop > Gläser & Dosen – Lebensmittelbehälter
|
||||||
|
|
||||||
// Lighting & Equipment
|
// Lighting & Equipment
|
||||||
694: "Home & Garden > Lighting", // Lampen
|
694: "3006", // Lampen – Lampen (Beleuchtung)
|
||||||
261: "Home & Garden > Lighting", // Lampenzubehör
|
261: "3006", // Zubehör > Lampenzubehör – Lampen
|
||||||
|
|
||||||
// Plants & Growing
|
// Plants & Growing
|
||||||
691: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger
|
691: "500033", // Dünger – Dünger
|
||||||
692: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger - Zubehör
|
692: "5633", // Zubehör > Dünger-Zubehör – Zubehör für Gartenarbeit
|
||||||
693: "Sporting Goods > Outdoor Recreation > Camping & Hiking > Tents", // Zelte
|
693: "5655", // Zelte – Zelte
|
||||||
|
|
||||||
// Pots & Containers
|
// Pots & Containers
|
||||||
219: "Home & Garden > Decor > Planters & Pots", // Töpfe
|
219: "113", // Töpfe – Blumentöpfe & Pflanzgefäße
|
||||||
220: "Home & Garden > Decor > Planters & Pots", // Untersetzer
|
220: "3173", // Töpfe > Untersetzer – Gartentopfuntersetzer und Trays
|
||||||
301: "Home & Garden > Decor > Planters & Pots", // Stofftöpfe
|
301: "113", // Töpfe > Stofftöpfe – (Blumentöpfe/Pflanzgefäße)
|
||||||
317: "Home & Garden > Decor > Planters & Pots", // Air-Pot
|
317: "113", // Töpfe > Air-Pot – (Blumentöpfe/Pflanzgefäße)
|
||||||
364: "Home & Garden > Decor > Planters & Pots", // Kunststofftöpfe
|
364: "113", // Töpfe > Kunststofftöpfe – (Blumentöpfe/Pflanzgefäße)
|
||||||
292: "Home & Garden > Decor > Planters & Pots", // Trays & Fluttische
|
292: "3568", // Bewässerung > Trays & Fluttische – Bewässerungssysteme
|
||||||
|
|
||||||
// Ventilation & Climate
|
// Ventilation & Climate
|
||||||
703: "Home & Garden > Outdoor Power Tools", // Abluft-Sets
|
703: "2802", // Grow-Sets > Abluft-Sets – (verwendet Pflanzen-Kräuter-Anbausets)
|
||||||
247: "Home & Garden > Outdoor Power Tools", // Belüftung
|
247: "1700", // Belüftung – Ventilatoren (Klimatisierung)
|
||||||
214: "Home & Garden > Outdoor Power Tools", // Umluft-Ventilatoren
|
214: "1700", // Belüftung > Umluft-Ventilatoren – Ventilatoren
|
||||||
308: "Home & Garden > Outdoor Power Tools", // Ab- und Zuluft
|
308: "1700", // Belüftung > Ab- und Zuluft – Ventilatoren
|
||||||
609: "Home & Garden > Outdoor Power Tools", // Schalldämpfer
|
609: "1700", // Belüftung > Ab- und Zuluft > Schalldämpfer – Ventilatoren
|
||||||
248: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Aktivkohlefilter
|
248: "1700", // Belüftung > Aktivkohlefilter – Ventilatoren (nächstmögliche)
|
||||||
392: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Zuluftfilter
|
392: "1700", // Belüftung > Ab- und Zuluft > Zuluftfilter – Ventilatoren
|
||||||
658: "Home & Garden > Climate Control > Dehumidifiers", // Luftbe- und entfeuchter
|
658: "606", // Belüftung > Luftbe- und -entfeuchter – Luftentfeuchter
|
||||||
310: "Home & Garden > Climate Control > Heating", // Heizmatten
|
310: "2802", // Anzucht > Heizmatten – Pflanzen- & Kräuteranbausets
|
||||||
379: "Home & Garden > Household Supplies > Air Fresheners", // Geruchsneutralisation
|
379: "5631", // Belüftung > Geruchsneutralisation – Haushaltsbedarf: Aufbewahrung
|
||||||
|
|
||||||
// Irrigation & Watering
|
// Irrigation & Watering
|
||||||
221: "Home & Garden > Lawn & Garden > Watering Equipment", // Bewässerung
|
221: "3568", // Bewässerung – Bewässerungssysteme (Gesamt)
|
||||||
250: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
|
250: "6318", // Bewässerung > Schläuche – Gartenschläuche
|
||||||
297: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpen
|
297: "500100", // Bewässerung > Pumpen – Bewässerung-/Sprinklerpumpen
|
||||||
354: "Home & Garden > Lawn & Garden > Watering Equipment", // Sprüher
|
354: "3780", // Bewässerung > Sprüher – Sprinkler & Sprühköpfe
|
||||||
372: "Home & Garden > Lawn & Garden > Watering Equipment", // AutoPot
|
372: "3568", // Bewässerung > AutoPot – Bewässerungssysteme
|
||||||
389: "Home & Garden > Lawn & Garden > Watering Equipment", // Blumat
|
389: "3568", // Bewässerung > Blumat – Bewässerungssysteme
|
||||||
405: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
|
405: "6318", // Bewässerung > Schläuche – Gartenschläuche
|
||||||
425: "Home & Garden > Lawn & Garden > Watering Equipment", // Wassertanks
|
425: "3568", // Bewässerung > Wassertanks – Bewässerungssysteme
|
||||||
480: "Home & Garden > Lawn & Garden > Watering Equipment", // Tropfer
|
480: "3568", // Bewässerung > Tropfer – Bewässerungssysteme
|
||||||
519: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpsprüher
|
519: "3568", // Bewässerung > Pumpsprüher – Bewässerungssysteme
|
||||||
|
|
||||||
// Growing Media & Soils
|
// Growing Media & Soils
|
||||||
242: "Home & Garden > Lawn & Garden > Fertilizers", // Böden
|
242: "543677", // Böden – Gartenerde
|
||||||
243: "Home & Garden > Lawn & Garden > Fertilizers", // Erde
|
243: "543677", // Böden > Erde – Gartenerde
|
||||||
269: "Home & Garden > Lawn & Garden > Fertilizers", // Kokos
|
269: "543677", // Böden > Kokos – Gartenerde
|
||||||
580: "Home & Garden > Lawn & Garden > Fertilizers", // Perlite & Blähton
|
580: "543677", // Böden > Perlite & Blähton – Gartenerde
|
||||||
|
|
||||||
// Propagation & Starting
|
// Propagation & Starting
|
||||||
286: "Home & Garden > Plants", // Anzucht
|
286: "2802", // Anzucht – Pflanzen- & Kräuteranbausets
|
||||||
298: "Home & Garden > Plants", // Steinwolltrays
|
298: "2802", // Anzucht > Steinwolltrays – Pflanzen- & Kräuteranbausets
|
||||||
421: "Home & Garden > Plants", // Vermehrungszubehör
|
421: "2802", // Anzucht > Vermehrungszubehör – Pflanzen- & Kräuteranbausets
|
||||||
489: "Home & Garden > Plants", // EazyPlug & Jiffy
|
489: "2802", // Anzucht > EazyPlug & Jiffy – Pflanzen- & Kräuteranbausets
|
||||||
359: "Home & Garden > Outdoor Structures > Greenhouses", // Gewächshäuser
|
359: "3103", // Anzucht > Gewächshäuser – Gewächshäuser
|
||||||
|
|
||||||
// Tools & Equipment
|
// Tools & Equipment
|
||||||
373: "Home & Garden > Tools > Hand Tools", // GrowTool
|
373: "3568", // Bewässerung > GrowTool – Bewässerungssysteme
|
||||||
403: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Messbecher & mehr
|
403: "3999", // Bewässerung > Messbecher & mehr – Messbecher & Dosierlöffel
|
||||||
259: "Home & Garden > Tools > Hand Tools", // Pressen
|
259: "756", // Zubehör > Ernte & Verarbeitung > Pressen – Nudelmaschinen
|
||||||
280: "Home & Garden > Tools > Hand Tools", // Erntescheeren
|
280: "2948", // Zubehör > Ernte & Verarbeitung > Erntescheeren – Küchenmesser
|
||||||
258: "Home & Garden > Tools", // Ernte & Verarbeitung
|
258: "684", // Zubehör > Ernte & Verarbeitung – Abfallzerkleinerer
|
||||||
278: "Home & Garden > Tools", // Extraktion
|
278: "5057", // Zubehör > Ernte & Verarbeitung > Extraktion – Slush-Eis-Maschinen
|
||||||
302: "Home & Garden > Tools", // Erntemaschinen
|
302: "7332", // Zubehör > Ernte & Verarbeitung > Erntemaschinen – Gartenmaschinen
|
||||||
|
|
||||||
// Hardware & Plumbing
|
// Hardware & Plumbing
|
||||||
222: "Hardware > Plumbing", // PE-Teile
|
222: "3568", // Bewässerung > PE-Teile – Bewässerungssysteme
|
||||||
374: "Hardware > Plumbing > Plumbing Fittings", // Verbindungsteile
|
374: "1700", // Belüftung > Ab- und Zuluft > Verbindungsteile – Ventilatoren
|
||||||
|
|
||||||
// Electronics & Control
|
// Electronics & Control
|
||||||
314: "Electronics > Electronics Accessories", // Steuergeräte
|
314: "1700", // Belüftung > Steuergeräte – Ventilatoren
|
||||||
408: "Electronics > Electronics Accessories", // GrowControl
|
408: "1700", // Belüftung > Steuergeräte > GrowControl – Ventilatoren
|
||||||
344: "Business & Industrial > Science & Laboratory > Lab Equipment", // Messgeräte
|
344: "1207", // Zubehör > Messgeräte – Messwerkzeuge & Messwertgeber
|
||||||
555: "Business & Industrial > Science & Laboratory > Lab Equipment > Microscopes", // Mikroskope
|
555: "4555", // Zubehör > Anbauzubehör > Mikroskope – Mikroskope
|
||||||
|
|
||||||
// Camping & Outdoor
|
// Camping & Outdoor
|
||||||
226: "Sporting Goods > Outdoor Recreation > Camping & Hiking", // Zeltzubehör
|
226: "5655", // Zubehör > Zeltzubehör – Zelte
|
||||||
|
|
||||||
// Plant Care & Protection
|
// Plant Care & Protection
|
||||||
239: "Home & Garden > Lawn & Garden > Pest Control", // Pflanzenschutz
|
239: "4085", // Zubehör > Anbauzubehör > Pflanzenschutz – Herbizide
|
||||||
240: "Home & Garden > Plants", // Anbauzubehör
|
240: "5633", // Zubehör > Anbauzubehör – Zubehör für Gartenarbeit
|
||||||
|
|
||||||
// Office & Media
|
// Office & Media
|
||||||
424: "Office Supplies > Labels", // Etiketten & Schilder
|
424: "4377", // Zubehör > Anbauzubehör > Etiketten & Schilder – Etiketten & Anhängerschilder
|
||||||
387: "Media > Books", // Literatur
|
387: "543541", // Zubehör > Anbauzubehör > Literatur – Bücher
|
||||||
|
|
||||||
// General categories
|
// General categories
|
||||||
705: "Home & Garden", // Set-Konfigurator
|
705: "2802", // Grow-Sets > Set-Konfigurator – (ebenfalls Pflanzen-Anbausets)
|
||||||
686: "Home & Garden", // Zubehör
|
686: "1700", // Belüftung > Aktivkohlefilter > Zubehör – Ventilatoren
|
||||||
741: "Home & Garden", // Zubehör
|
741: "1700", // Belüftung > Ab- und Zuluft > Zubehör – Ventilatoren
|
||||||
294: "Home & Garden", // Zubehör
|
294: "3568", // Bewässerung > Zubehör – Bewässerungssysteme
|
||||||
695: "Home & Garden", // Zubehör
|
695: "5631", // Zubehör – Haushaltsbedarf: Aufbewahrung
|
||||||
293: "Home & Garden", // Trockennetze
|
293: "5631", // Zubehör > Ernte & Verarbeitung > Trockennetze – Haushaltsbedarf: Aufbewahrung
|
||||||
4: "Home & Garden", // Sonstiges
|
4: "5631", // Zubehör > Anbauzubehör > Sonstiges – Haushaltsbedarf: Aufbewahrung
|
||||||
450: "Home & Garden", // Restposten
|
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"?>
|
let productsXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
@@ -150,7 +253,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
<link>${baseUrl}</link>
|
<link>${baseUrl}</link>
|
||||||
<description>${config.descriptions.short}</description>
|
<description>${config.descriptions.short}</description>
|
||||||
<lastBuildDate>${currentDate}</lastBuildDate>
|
<lastBuildDate>${currentDate}</lastBuildDate>
|
||||||
<language>${config.language}</language>`;
|
<language>de-DE</language>`;
|
||||||
|
|
||||||
// Helper function to clean text content of problematic characters
|
// Helper function to clean text content of problematic characters
|
||||||
const cleanTextContent = (text) => {
|
const cleanTextContent = (text) => {
|
||||||
@@ -197,6 +300,10 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
let processedCount = 0;
|
let processedCount = 0;
|
||||||
let skippedCount = 0;
|
let skippedCount = 0;
|
||||||
|
|
||||||
|
// Track products with missing data for logging
|
||||||
|
const productsNeedingWeight = [];
|
||||||
|
const productsNeedingDescription = [];
|
||||||
|
|
||||||
// Category IDs to skip (seeds, plants, headshop items)
|
// Category IDs to skip (seeds, plants, headshop items)
|
||||||
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710];
|
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710];
|
||||||
|
|
||||||
@@ -216,23 +323,96 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip products without GTIN
|
// Skip products with excluded terms in title or description
|
||||||
|
const productTitle = (product.name || "").toLowerCase();
|
||||||
|
const productDescription = (product.description || "").toLowerCase();
|
||||||
|
|
||||||
|
const excludedTerms = {
|
||||||
|
title: ['canna', 'hash', 'marijuana', 'marihuana'],
|
||||||
|
description: ['cannabis']
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check title for excluded terms
|
||||||
|
if (excludedTerms.title.some(term => productTitle.includes(term))) {
|
||||||
|
skippedCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check description for excluded terms
|
||||||
|
if (excludedTerms.description.some(term => productDescription.includes(term))) {
|
||||||
|
skippedCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip products without GTIN or with invalid GTIN
|
||||||
if (!product.gtin || !product.gtin.toString().trim()) {
|
if (!product.gtin || !product.gtin.toString().trim()) {
|
||||||
skippedCount++;
|
skippedCount++;
|
||||||
return;
|
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;
|
||||||
|
|
||||||
|
for (let i = 0; i < length - 1; i++) {
|
||||||
|
// Even/odd multiplier depends on GTIN length
|
||||||
|
let multiplier = 1;
|
||||||
|
if (length === 8) {
|
||||||
|
multiplier = (i % 2 === 0) ? 3 : 1;
|
||||||
|
} else {
|
||||||
|
multiplier = ((length - i) % 2 === 0) ? 3 : 1;
|
||||||
|
}
|
||||||
|
sum += digits[i] * multiplier;
|
||||||
|
}
|
||||||
|
const checkDigit = (10 - (sum % 10)) % 10;
|
||||||
|
return checkDigit === digits[length - 1];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isValidGTIN(gtinString)) {
|
||||||
|
skippedCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip products without pictures
|
// Skip products without pictures
|
||||||
if (!product.pictureList || !product.pictureList.trim()) {
|
if (!product.pictureList || !product.pictureList.trim()) {
|
||||||
skippedCount++;
|
skippedCount++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean description for feed (remove HTML tags and limit length)
|
// Check if product has weight data - validate BEFORE building XML
|
||||||
const rawDescription = product.description
|
if (!product.weight || isNaN(product.weight)) {
|
||||||
? cleanTextContent(product.description).substring(0, 500)
|
// Track products without weight
|
||||||
: `${product.name || 'Product'} - Art.-Nr.: ${product.articleNumber || 'N/A'}`;
|
productsNeedingWeight.push({
|
||||||
|
id: product.articleNumber || product.seoName,
|
||||||
|
name: product.name || 'Unnamed',
|
||||||
|
url: `/Artikel/${product.seoName}`
|
||||||
|
});
|
||||||
|
skippedCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if description is missing or too short (less than 20 characters) - skip if insufficient
|
||||||
|
const originalDescription = product.description ? cleanTextContent(product.description) : '';
|
||||||
|
if (!originalDescription || originalDescription.length < 20) {
|
||||||
|
productsNeedingDescription.push({
|
||||||
|
id: product.articleNumber || product.seoName,
|
||||||
|
name: product.name || 'Unnamed',
|
||||||
|
currentDescription: originalDescription || 'NONE',
|
||||||
|
url: `/Artikel/${product.seoName}`
|
||||||
|
});
|
||||||
|
skippedCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean description for feed (remove HTML tags and limit length)
|
||||||
|
const rawDescription = cleanTextContent(product.description).substring(0, 500);
|
||||||
const cleanDescription = escapeXml(rawDescription) || "Produktbeschreibung nicht verfügbar";
|
const cleanDescription = escapeXml(rawDescription) || "Produktbeschreibung nicht verfügbar";
|
||||||
|
|
||||||
// Clean product name
|
// Clean product name
|
||||||
@@ -263,6 +443,12 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
// Generate availability
|
// Generate availability
|
||||||
const availability = product.available ? "in stock" : "out of stock";
|
const availability = product.available ? "in stock" : "out of stock";
|
||||||
|
|
||||||
|
// Skip products that are out of stock
|
||||||
|
if (!product.available) {
|
||||||
|
skippedCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Generate price (ensure it's a valid number)
|
// Generate price (ensure it's a valid number)
|
||||||
const price = product.price && !isNaN(product.price)
|
const price = product.price && !isNaN(product.price)
|
||||||
? `${parseFloat(product.price).toFixed(2)} ${config.currency}`
|
? `${parseFloat(product.price).toFixed(2)} ${config.currency}`
|
||||||
@@ -274,8 +460,8 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate GTIN/EAN if available
|
// Generate GTIN/EAN if available (use the already validated gtinString)
|
||||||
const gtin = product.gtin ? escapeXml(product.gtin.toString().trim()) : null;
|
const gtin = gtinString ? escapeXml(gtinString) : null;
|
||||||
|
|
||||||
// Generate product ID (using articleNumber or seoName)
|
// Generate product ID (using articleNumber or seoName)
|
||||||
const rawProductId = product.articleNumber || product.seoName || `product_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
|
const rawProductId = product.articleNumber || product.seoName || `product_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
|
||||||
@@ -286,7 +472,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
const googleCategory = getGoogleProductCategory(categoryId);
|
const googleCategory = getGoogleProductCategory(categoryId);
|
||||||
const escapedGoogleCategory = escapeXml(googleCategory);
|
const escapedGoogleCategory = escapeXml(googleCategory);
|
||||||
|
|
||||||
// Build item XML with proper formatting
|
// Build item XML with proper formatting (all validation passed, safe to write XML)
|
||||||
productsXml += `
|
productsXml += `
|
||||||
<item>
|
<item>
|
||||||
<g:id>${productId}</g:id>
|
<g:id>${productId}</g:id>
|
||||||
@@ -312,10 +498,21 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
<g:gtin>${gtin}</g:gtin>`;
|
<g:gtin>${gtin}</g:gtin>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add weight if available
|
// Add weight (we know it exists at this point since we validated it earlier)
|
||||||
if (product.weight && !isNaN(product.weight)) {
|
// Convert from kg to grams (multiply by 1000)
|
||||||
|
const weightInGrams = parseFloat(product.weight) * 1000;
|
||||||
productsXml += `
|
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 += `
|
productsXml += `
|
||||||
@@ -335,6 +532,47 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
|||||||
|
|
||||||
console.log(` 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
|
console.log(` 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
|
||||||
|
|
||||||
|
// 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 missing weight log
|
||||||
|
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(`\n ⚠️ Products missing weight (${productsNeedingWeight.length}) - saved to: ${weightLogPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write missing description log
|
||||||
|
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(`\n ⚠️ Products with insufficient description (${productsNeedingDescription.length}) - saved to: ${descLogPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (productsNeedingWeight.length === 0 && productsNeedingDescription.length === 0) {
|
||||||
|
console.log(` ✅ All products have adequate weight and description data`);
|
||||||
|
}
|
||||||
|
|
||||||
return productsXml;
|
return productsXml;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const generateProductMetaTags = (product, baseUrl, config) => {
|
const generateProductMetaTags = (product, baseUrl, config) => {
|
||||||
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
|
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
|
||||||
|
|
||||||
const imageUrl =
|
const imageUrl =
|
||||||
product.pictureList && product.pictureList.trim()
|
product.pictureList && product.pictureList.trim()
|
||||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 85 KiB |
BIN
public/assets/images/filiale1.jpg
Normal file
BIN
public/assets/images/filiale1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
BIN
public/assets/images/filiale2.jpg
Normal file
BIN
public/assets/images/filiale2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
BIN
public/assets/images/presse.jpg
Normal file
BIN
public/assets/images/presse.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
BIN
public/assets/images/purpl.jpg
Normal file
BIN
public/assets/images/purpl.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="de" data-i18n-lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|||||||
344
scripts/validate-products-xml.cjs
Normal file
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;
|
||||||
129
src/App.js
129
src/App.js
@@ -14,23 +14,32 @@ import Fab from "@mui/material/Fab";
|
|||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import SmartToyIcon from "@mui/icons-material/SmartToy";
|
import SmartToyIcon from "@mui/icons-material/SmartToy";
|
||||||
import PaletteIcon from "@mui/icons-material/Palette";
|
import PaletteIcon from "@mui/icons-material/Palette";
|
||||||
import BugReportIcon from "@mui/icons-material/BugReport";
|
|
||||||
|
|
||||||
import SocketProvider from "./providers/SocketProvider.js";
|
import SocketProvider from "./providers/SocketProvider.js";
|
||||||
import SocketContext from "./contexts/SocketContext.js";
|
import SocketContext from "./contexts/SocketContext.js";
|
||||||
|
import { CarouselProvider } from "./contexts/CarouselContext.js";
|
||||||
import config from "./config.js";
|
import config from "./config.js";
|
||||||
import ScrollToTop from "./components/ScrollToTop.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 TelemetryService from './services/telemetryService.js';
|
||||||
|
|
||||||
import Header from "./components/Header.js";
|
import Header from "./components/Header.js";
|
||||||
import Footer from "./components/Footer.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
|
// TEMPORARILY DISABLE ALL LAZY LOADING TO ELIMINATE CircularProgress
|
||||||
const Content = lazy(() => import(/* webpackChunkName: "content" */ "./components/Content.js"));
|
import Content from "./components/Content.js";
|
||||||
const ProductDetailWithSocket = lazy(() => import(/* webpackChunkName: "product-detail" */ "./components/ProductDetailWithSocket.js"));
|
import ProductDetailWithSocket from "./components/ProductDetailWithSocket.js";
|
||||||
const ProfilePageWithSocket = lazy(() => import(/* webpackChunkName: "profile" */ "./pages/ProfilePage.js"));
|
import ProfilePageWithSocket from "./pages/ProfilePage.js";
|
||||||
const ResetPassword = lazy(() => import(/* webpackChunkName: "reset-password" */ "./pages/ResetPassword.js"));
|
import ResetPassword from "./pages/ResetPassword.js";
|
||||||
|
// 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"));
|
||||||
|
|
||||||
// Lazy load admin pages - only loaded when admin users access them
|
// Lazy load admin pages - only loaded when admin users access them
|
||||||
const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js"));
|
const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js"));
|
||||||
@@ -40,7 +49,7 @@ const ServerLogsPage = lazy(() => import(/* webpackChunkName: "admin-logs" */ ".
|
|||||||
// Lazy load legal pages - rarely accessed
|
// Lazy load legal pages - rarely accessed
|
||||||
const Datenschutz = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Datenschutz.js"));
|
const Datenschutz = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Datenschutz.js"));
|
||||||
const AGB = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/AGB.js"));
|
const AGB = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/AGB.js"));
|
||||||
const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js"));
|
//const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); <Route path="/404" element={<NotFound404 />} />
|
||||||
const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js"));
|
const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js"));
|
||||||
const Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
|
const Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
|
||||||
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
|
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
|
||||||
@@ -50,6 +59,13 @@ const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./page
|
|||||||
const GrowTentKonfigurator = lazy(() => import(/* webpackChunkName: "konfigurator" */ "./pages/GrowTentKonfigurator.js"));
|
const GrowTentKonfigurator = lazy(() => import(/* webpackChunkName: "konfigurator" */ "./pages/GrowTentKonfigurator.js"));
|
||||||
const ChatAssistant = lazy(() => import(/* webpackChunkName: "chat" */ "./components/ChatAssistant.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"));
|
||||||
|
|
||||||
// Import theme from separate file to reduce main bundle size
|
// Import theme from separate file to reduce main bundle size
|
||||||
import defaultTheme from "./theme.js";
|
import defaultTheme from "./theme.js";
|
||||||
// Lazy load theme customizer for development only
|
// Lazy load theme customizer for development only
|
||||||
@@ -94,11 +110,15 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
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") {
|
if (location.pathname !== "/profile") {
|
||||||
navigate(`/profile${location.hash}`, { replace: true });
|
navigate(`/profile${location.hash}`, { replace: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [location, navigate]);
|
}, [location, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -139,28 +159,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
setThemeCustomizerOpen(!isThemeCustomizerOpen);
|
setThemeCustomizerOpen(!isThemeCustomizerOpen);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handler to open GitHub issue reporting
|
|
||||||
const handleReportIssue = () => {
|
|
||||||
const issueTitle = encodeURIComponent("Fehlerbericht");
|
|
||||||
const issueBody = encodeURIComponent(
|
|
||||||
`**Seite:** ${window.location.href}
|
|
||||||
**Browser:** ${navigator.userAgent.split(' ')[0]}
|
|
||||||
**Datum:** ${new Date().toLocaleDateString('de-DE')}
|
|
||||||
|
|
||||||
**Problem:**
|
|
||||||
[Beschreibe kurz das Problem]
|
|
||||||
|
|
||||||
**So ist es passiert:**
|
|
||||||
1.
|
|
||||||
2.
|
|
||||||
|
|
||||||
**Was sollte passieren:**
|
|
||||||
[Was erwartet wurde]`
|
|
||||||
);
|
|
||||||
|
|
||||||
const githubIssueUrl = `https://github.com/Growheads-de/shopFrontEnd/issues/new?title=${issueTitle}&body=${issueBody}`;
|
|
||||||
window.open(githubIssueUrl, '_blank');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if we're in development mode
|
// Check if we're in development mode
|
||||||
const isDevelopment = process.env.NODE_ENV === "development";
|
const isDevelopment = process.env.NODE_ENV === "development";
|
||||||
@@ -184,6 +183,14 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
<Header active categoryId={categoryId} key={authVersion} />
|
<Header active categoryId={categoryId} key={authVersion} />
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
<Suspense fallback={
|
<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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -194,10 +201,14 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
>
|
>
|
||||||
<CircularProgress color="primary" />
|
<CircularProgress color="primary" />
|
||||||
</Box>
|
</Box>
|
||||||
|
)
|
||||||
}>
|
}>
|
||||||
|
<CarouselProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Home page with text only */}
|
{/* Main pages using unified component */}
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<MainPageLayout />} />
|
||||||
|
<Route path="/aktionen" element={<MainPageLayout />} />
|
||||||
|
<Route path="/filiale" element={<MainPageLayout />} />
|
||||||
|
|
||||||
{/* Category page - Render Content in parallel */}
|
{/* Category page - Render Content in parallel */}
|
||||||
<Route
|
<Route
|
||||||
@@ -216,6 +227,9 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
{/* Profile page */}
|
{/* Profile page */}
|
||||||
<Route path="/profile" element={<ProfilePageWithSocket />} />
|
<Route path="/profile" element={<ProfilePageWithSocket />} />
|
||||||
|
|
||||||
|
{/* Payment success page for Mollie redirects */}
|
||||||
|
<Route path="/payment/success" element={<PaymentSuccess />} />
|
||||||
|
|
||||||
{/* Reset password page */}
|
{/* Reset password page */}
|
||||||
<Route
|
<Route
|
||||||
path="/resetPassword"
|
path="/resetPassword"
|
||||||
@@ -234,7 +248,6 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
{/* Legal pages */}
|
{/* Legal pages */}
|
||||||
<Route path="/datenschutz" element={<Datenschutz />} />
|
<Route path="/datenschutz" element={<Datenschutz />} />
|
||||||
<Route path="/agb" element={<AGB />} />
|
<Route path="/agb" element={<AGB />} />
|
||||||
<Route path="/404" element={<NotFound404 />} />
|
|
||||||
<Route path="/sitemap" element={<Sitemap />} />
|
<Route path="/sitemap" element={<Sitemap />} />
|
||||||
<Route path="/impressum" element={<Impressum />} />
|
<Route path="/impressum" element={<Impressum />} />
|
||||||
<Route
|
<Route
|
||||||
@@ -244,16 +257,31 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
<Route path="/widerrufsrecht" element={<Widerrufsrecht />} />
|
<Route path="/widerrufsrecht" element={<Widerrufsrecht />} />
|
||||||
|
|
||||||
{/* Grow Tent Configurator */}
|
{/* Grow Tent Configurator */}
|
||||||
<Route path="/Konfigurator" element={<GrowTentKonfigurator />} />
|
<Route path="/Konfigurator" element={<GrowTentKonfigurator socket={socket} socketB={socketB} />} />
|
||||||
|
|
||||||
|
{/* Separate pages that are truly different */}
|
||||||
|
<Route path="/presseverleih" element={<PresseverleihPage />} />
|
||||||
|
<Route path="/thc-test" element={<ThcTestPage />} />
|
||||||
|
|
||||||
{/* Fallback for undefined routes */}
|
{/* Fallback for undefined routes */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</CarouselProvider>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Box>
|
</Box>
|
||||||
{/* Conditionally render the Chat Assistant */}
|
{/* Conditionally render the Chat Assistant */}
|
||||||
{isChatOpen && (
|
{isChatOpen && (
|
||||||
<Suspense fallback={<CircularProgress size={20} />}>
|
<Suspense fallback={
|
||||||
|
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: window.__PRERENDER_FALLBACK__.content,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
)
|
||||||
|
}>
|
||||||
<ChatAssistant
|
<ChatAssistant
|
||||||
open={isChatOpen}
|
open={isChatOpen}
|
||||||
onClose={handleChatClose}
|
onClose={handleChatClose}
|
||||||
@@ -279,7 +307,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
</Fab>
|
</Fab>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* GitHub Issue Reporter FAB */}
|
{/* GitHub Issue Reporter FAB
|
||||||
<Tooltip title="Fehler oder Problem melden" placement="left">
|
<Tooltip title="Fehler oder Problem melden" placement="left">
|
||||||
<Fab
|
<Fab
|
||||||
color="error"
|
color="error"
|
||||||
@@ -294,7 +322,7 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
>
|
>
|
||||||
<BugReportIcon sx={{ fontSize: "1.2rem" }} />
|
<BugReportIcon sx={{ fontSize: "1.2rem" }} />
|
||||||
</Fab>
|
</Fab>
|
||||||
</Tooltip>
|
</Tooltip>*/}
|
||||||
|
|
||||||
{/* Development-only Theme Customizer FAB */}
|
{/* Development-only Theme Customizer FAB */}
|
||||||
{isDevelopment && (
|
{isDevelopment && (
|
||||||
@@ -317,7 +345,17 @@ const AppContent = ({ currentTheme, onThemeChange }) => {
|
|||||||
|
|
||||||
{/* Development-only Theme Customizer Dialog */}
|
{/* Development-only Theme Customizer Dialog */}
|
||||||
{isDevelopment && isThemeCustomizerOpen && (
|
{isDevelopment && isThemeCustomizerOpen && (
|
||||||
<Suspense fallback={<CircularProgress size={20} />}>
|
<Suspense fallback={
|
||||||
|
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: window.__PRERENDER_FALLBACK__.content,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
)
|
||||||
|
}>
|
||||||
<ThemeCustomizerDialog
|
<ThemeCustomizerDialog
|
||||||
open={isThemeCustomizerOpen}
|
open={isThemeCustomizerOpen}
|
||||||
onClose={() => setThemeCustomizerOpen(false)}
|
onClose={() => setThemeCustomizerOpen(false)}
|
||||||
@@ -343,12 +381,25 @@ const App = () => {
|
|||||||
setDynamicTheme(createTheme(newTheme));
|
setDynamicTheme(createTheme(newTheme));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Make config globally available for language switching
|
||||||
|
useEffect(() => {
|
||||||
|
window.shopConfig = config;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<LanguageProvider i18n={i18n}>
|
||||||
<ThemeProvider theme={dynamicTheme}>
|
<ThemeProvider theme={dynamicTheme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<SocketProvider
|
<SocketProvider
|
||||||
url={config.apiBaseUrl}
|
url={config.apiBaseUrl}
|
||||||
fallback={
|
fallback={
|
||||||
|
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: window.__PRERENDER_FALLBACK__.content,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -359,6 +410,7 @@ const App = () => {
|
|||||||
>
|
>
|
||||||
<CircularProgress color="primary" />
|
<CircularProgress color="primary" />
|
||||||
</Box>
|
</Box>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AppContent
|
<AppContent
|
||||||
@@ -367,6 +419,7 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
</SocketProvider>
|
</SocketProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</LanguageProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { Box, AppBar, Toolbar, Container} from '@mui/material';
|
|||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import Footer from './components/Footer.js';
|
import Footer from './components/Footer.js';
|
||||||
import { Logo, CategoryList } from './components/header/index.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) => (
|
const PrerenderAppContent = (socket) => (
|
||||||
<Box
|
<Box
|
||||||
@@ -44,9 +45,11 @@ const PrerenderAppContent = (socket) => (
|
|||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
|
<CarouselProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<MainPageLayout />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</CarouselProvider>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Footer/>
|
<Footer/>
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ const {
|
|||||||
} = require('@mui/material');
|
} = require('@mui/material');
|
||||||
const Footer = require('./components/Footer.js').default;
|
const Footer = require('./components/Footer.js').default;
|
||||||
const { Logo, CategoryList } = require('./components/header/index.js');
|
const { Logo, CategoryList } = require('./components/header/index.js');
|
||||||
const Home = require('./pages/Home.js').default;
|
const MainPageLayout = require('./components/MainPageLayout.js').default;
|
||||||
|
const { CarouselProvider } = require('./contexts/CarouselContext.js');
|
||||||
|
|
||||||
class PrerenderHome extends React.Component {
|
class PrerenderHome extends React.Component {
|
||||||
render() {
|
render() {
|
||||||
@@ -62,7 +63,7 @@ class PrerenderHome extends React.Component {
|
|||||||
React.createElement(
|
React.createElement(
|
||||||
Box,
|
Box,
|
||||||
{ sx: { flexGrow: 1 } },
|
{ sx: { flexGrow: 1 } },
|
||||||
React.createElement(Home)
|
React.createElement(CarouselProvider, null, React.createElement(MainPageLayout))
|
||||||
),
|
),
|
||||||
React.createElement(Footer)
|
React.createElement(Footer)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class PrerenderKonfigurator extends Component {
|
|||||||
15%
|
15%
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
|
{/* Note: This is a prerender file - translation key would be: product.discount.from3Products */}
|
||||||
ab 3 Produkten
|
ab 3 Produkten
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -74,6 +75,7 @@ class PrerenderKonfigurator extends Component {
|
|||||||
24%
|
24%
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
|
{/* Note: This is a prerender file - translation key would be: product.discount.from5Products */}
|
||||||
ab 5 Produkten
|
ab 5 Produkten
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -82,11 +84,13 @@ class PrerenderKonfigurator extends Component {
|
|||||||
36%
|
36%
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
|
{/* Note: This is a prerender file - translation key would be: product.discount.from7Products */}
|
||||||
ab 7 Produkten
|
ab 7 Produkten
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" sx={{ color: '#666', mt: 1, display: 'block' }}>
|
<Typography variant="caption" sx={{ color: '#666', mt: 1, display: 'block' }}>
|
||||||
|
{/* Note: This is a prerender file - translation key would be: product.discount.moreProductsMoreSavings */}
|
||||||
Je mehr Produkte du auswählst, desto mehr sparst du!
|
Je mehr Produkte du auswählst, desto mehr sparst du!
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
92
src/PrerenderNotFound.js
Normal file
92
src/PrerenderNotFound.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
const React = require('react');
|
||||||
|
const {
|
||||||
|
Box,
|
||||||
|
AppBar,
|
||||||
|
Toolbar,
|
||||||
|
Container
|
||||||
|
} = require('@mui/material');
|
||||||
|
const Footer = require('./components/Footer.js').default;
|
||||||
|
const { Logo } = require('./components/header/index.js');
|
||||||
|
const NotFound404 = require('./pages/NotFound404.js').default;
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { default: PrerenderNotFound };
|
||||||
@@ -9,10 +9,19 @@ const {
|
|||||||
Chip,
|
Chip,
|
||||||
Stack,
|
Stack,
|
||||||
AppBar,
|
AppBar,
|
||||||
Toolbar
|
Toolbar,
|
||||||
|
Button
|
||||||
} = require('@mui/material');
|
} = require('@mui/material');
|
||||||
const Footer = require('./components/Footer.js').default;
|
const Footer = require('./components/Footer.js').default;
|
||||||
const { Logo } = require('./components/header/index.js');
|
const { Logo } = require('./components/header/index.js');
|
||||||
|
const ProductImage = require('./components/ProductImage.js').default;
|
||||||
|
|
||||||
|
// Utility function to clean product names by removing trailing number in parentheses
|
||||||
|
const cleanProductName = (name) => {
|
||||||
|
if (!name) return "";
|
||||||
|
// Remove patterns like " (1)", " (3)", " (10)" at the end of the string
|
||||||
|
return name.replace(/\s*\(\d+\)\s*$/, "").trim();
|
||||||
|
};
|
||||||
|
|
||||||
class PrerenderProduct extends React.Component {
|
class PrerenderProduct extends React.Component {
|
||||||
render() {
|
render() {
|
||||||
@@ -20,12 +29,17 @@ class PrerenderProduct extends React.Component {
|
|||||||
|
|
||||||
if (!productData) {
|
if (!productData) {
|
||||||
return React.createElement(
|
return React.createElement(
|
||||||
Container,
|
Box,
|
||||||
{ maxWidth: 'lg', sx: { py: 4 } },
|
{ sx: { p: 4, textAlign: "center" } },
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Typography,
|
Typography,
|
||||||
{ variant: 'h4', component: 'h1', gutterBottom: true },
|
{ variant: 'h5', gutterBottom: true },
|
||||||
'Product not found'
|
'Produkt nicht gefunden'
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Typography,
|
||||||
|
null,
|
||||||
|
'Das gesuchte Produkt existiert nicht oder wurde entfernt.'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -36,6 +50,12 @@ class PrerenderProduct extends React.Component {
|
|||||||
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.jpg`
|
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.jpg`
|
||||||
: '/assets/images/nopicture.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(
|
return React.createElement(
|
||||||
Box,
|
Box,
|
||||||
{
|
{
|
||||||
@@ -53,91 +73,475 @@ class PrerenderProduct extends React.Component {
|
|||||||
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
|
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Toolbar,
|
Toolbar,
|
||||||
{ sx: { minHeight: 64 } },
|
{ sx: { minHeight: 64, py: { xs: 0.5, sm: 0 } } },
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Container,
|
Container,
|
||||||
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center' } },
|
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center', px: { xs: 0, sm: 3 } } },
|
||||||
React.createElement(Logo)
|
// Desktop: simple layout, Mobile: column layout with SearchBar space
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{
|
||||||
|
sx: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
flexDirection: { xs: 'column', sm: 'row' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// First row: Logo and invisible placeholders to match SPA layout
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{
|
||||||
|
sx: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: { xs: 'space-between', sm: 'flex-start' }, // Match SPA layout
|
||||||
|
minHeight: { xs: 52, sm: 'auto' },
|
||||||
|
px: { xs: 0, sm: 0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
React.createElement(Logo),
|
||||||
|
// Invisible SearchBar placeholder on desktop to match SPA spacing
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{
|
||||||
|
sx: {
|
||||||
|
display: { xs: 'none', sm: 'block' },
|
||||||
|
flexGrow: 1,
|
||||||
|
mx: { xs: 0, sm: 2, md: 4 },
|
||||||
|
visibility: 'hidden',
|
||||||
|
height: 40 // Match SearchBar height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
// Invisible ButtonGroup placeholder to match SPA spacing
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{
|
||||||
|
sx: {
|
||||||
|
display: { xs: 'flex', sm: 'flex' },
|
||||||
|
alignItems: { xs: 'flex-end', sm: 'center' },
|
||||||
|
transform: { xs: 'translateY(4px) translateX(9px)', sm: 'none' },
|
||||||
|
ml: { xs: 0, sm: 0 },
|
||||||
|
visibility: 'hidden',
|
||||||
|
width: { xs: 'auto', sm: '120px' }, // Approximate ButtonGroup width
|
||||||
|
height: 40
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// Second row: SearchBar placeholder only on mobile
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{
|
||||||
|
sx: {
|
||||||
|
display: { xs: 'block', sm: 'none' },
|
||||||
|
width: '100%',
|
||||||
|
mt: { xs: 1, sm: 0 },
|
||||||
|
mb: { xs: 0.5, sm: 0 },
|
||||||
|
px: { xs: 0, sm: 0 },
|
||||||
|
height: 41, // Small TextField height
|
||||||
|
visibility: 'hidden'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{ sx: { flexGrow: 1 } },
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Container,
|
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',
|
maxWidth: "lg",
|
||||||
height: '400',
|
sx: {
|
||||||
image: mainImage,
|
p: { xs: 2, md: 2 },
|
||||||
alt: product.name,
|
pb: { xs: 4, md: 8 },
|
||||||
sx: { objectFit: 'contain', p: 2 }
|
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
|
// Right side - action buttons (exact replica with invisible versions)
|
||||||
React.createElement(
|
|
||||||
Grid,
|
|
||||||
{ item: true, xs: 12, md: 6 },
|
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Stack,
|
Stack,
|
||||||
{ spacing: 3 },
|
{ direction: 'column', spacing: 1, sx: { flexShrink: 0 } },
|
||||||
|
// "Frage zum Artikel" button - exact replica but invisible
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Typography,
|
Button,
|
||||||
{ variant: 'h3', component: 'h1', gutterBottom: true },
|
{
|
||||||
product.name
|
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(
|
React.createElement(
|
||||||
Typography,
|
Button,
|
||||||
{ variant: 'h6', color: 'text.secondary' },
|
{
|
||||||
'Artikelnummer: '+product.articleNumber+' '+(product.gtin ? ` | GTIN: ${product.gtin}` : "")
|
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,
|
Box,
|
||||||
{ sx: { mt: 1 } },
|
{ sx: { mb: 2 } },
|
||||||
React.createElement(
|
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,
|
Typography,
|
||||||
{ variant: 'body2', color: 'text.secondary' },
|
{ 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(
|
React.createElement(
|
||||||
Typography,
|
Typography,
|
||||||
{
|
{
|
||||||
variant: 'body1',
|
variant: "h4",
|
||||||
color: product.available ? 'success.main' : 'error.main',
|
color: "primary",
|
||||||
fontWeight: 'medium',
|
sx: { fontWeight: "bold" }
|
||||||
sx: { mt: 1 }
|
|
||||||
},
|
},
|
||||||
product.available ? '✅ Verfügbar' : '❌ Nicht verfügbar'
|
priceWithTax
|
||||||
)
|
|
||||||
),
|
),
|
||||||
product.description && React.createElement(
|
// VAT info (exact match to SPA - direct Typography, no wrapper)
|
||||||
Box,
|
|
||||||
{ sx: { mt: 2 } },
|
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Typography,
|
Typography,
|
||||||
{ variant: 'h6', gutterBottom: true },
|
{ variant: 'body2', color: 'text.secondary' },
|
||||||
'Beschreibung'
|
(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(
|
React.createElement(
|
||||||
'div',
|
'div',
|
||||||
{
|
{
|
||||||
@@ -145,45 +549,11 @@ class PrerenderProduct extends React.Component {
|
|||||||
style: {
|
style: {
|
||||||
fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
|
fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
|
||||||
fontSize: '1rem',
|
fontSize: '1rem',
|
||||||
lineHeight: '1.5',
|
lineHeight: '1.7',
|
||||||
color: '#33691E'
|
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'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import AddIcon from "@mui/icons-material/Add";
|
|||||||
import RemoveIcon from "@mui/icons-material/Remove";
|
import RemoveIcon from "@mui/icons-material/Remove";
|
||||||
import ShoppingCartIcon from "@mui/icons-material/ShoppingCart";
|
import ShoppingCartIcon from "@mui/icons-material/ShoppingCart";
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import { withI18n } from "../i18n/withTranslation.js";
|
||||||
|
|
||||||
if (!Array.isArray(window.cart)) window.cart = [];
|
if (!Array.isArray(window.cart)) window.cart = [];
|
||||||
|
|
||||||
@@ -51,11 +52,14 @@ class AddToCartButton extends Component {
|
|||||||
seoName: this.props.seoName,
|
seoName: this.props.seoName,
|
||||||
pictureList: this.props.pictureList,
|
pictureList: this.props.pictureList,
|
||||||
price: this.props.price,
|
price: this.props.price,
|
||||||
|
fGrundPreis: this.props.fGrundPreis,
|
||||||
|
cGrundEinheit: this.props.cGrundEinheit,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
weight: this.props.weight,
|
weight: this.props.weight,
|
||||||
vat: this.props.vat,
|
vat: this.props.vat,
|
||||||
versandklasse: this.props.versandklasse,
|
versandklasse: this.props.versandklasse,
|
||||||
availableSupplier: this.props.availableSupplier,
|
availableSupplier: this.props.availableSupplier,
|
||||||
|
komponenten: this.props.komponenten,
|
||||||
available: this.props.available
|
available: this.props.available
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -150,12 +154,17 @@ class AddToCartButton extends Component {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Ab{" "}
|
{this.props.t ? this.props.t('cart.availableFrom', {
|
||||||
{new Date(incoming).toLocaleDateString("de-DE", {
|
date: new Date(incoming).toLocaleDateString("de-DE", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
})}
|
})
|
||||||
|
}) : `Ab ${new Date(incoming).toLocaleDateString("de-DE", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}`}
|
||||||
</Button>
|
</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>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -259,7 +270,7 @@ class AddToCartButton extends Component {
|
|||||||
<AddIcon />
|
<AddIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<Tooltip title="Aus dem Warenkorb entfernen" arrow>
|
<Tooltip title={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'} arrow>
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={this.handleClearCart}
|
onClick={this.handleClearCart}
|
||||||
@@ -272,7 +283,7 @@ class AddToCartButton extends Component {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{this.props.cartButton && (
|
{this.props.cartButton && (
|
||||||
<Tooltip title="Warenkorb öffnen" arrow>
|
<Tooltip title={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'} arrow>
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={this.toggleCart}
|
onClick={this.toggleCart}
|
||||||
@@ -302,7 +313,7 @@ class AddToCartButton extends Component {
|
|||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Out of Stock
|
{this.props.t ? this.props.t('product.outOfStock') : 'Out of Stock'}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -327,7 +338,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>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -404,7 +417,7 @@ class AddToCartButton extends Component {
|
|||||||
<AddIcon />
|
<AddIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<Tooltip title="Aus dem Warenkorb entfernen" arrow>
|
<Tooltip title={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'} arrow>
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={this.handleClearCart}
|
onClick={this.handleClearCart}
|
||||||
@@ -417,7 +430,7 @@ class AddToCartButton extends Component {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{this.props.cartButton && (
|
{this.props.cartButton && (
|
||||||
<Tooltip title="Warenkorb öffnen" arrow>
|
<Tooltip title={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'} arrow>
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={this.toggleCart}
|
onClick={this.toggleCart}
|
||||||
@@ -436,4 +449,4 @@ class AddToCartButton extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AddToCartButton;
|
export default withI18n()(AddToCartButton);
|
||||||
|
|||||||
242
src/components/ArticleAvailabilityForm.js
Normal file
242
src/components/ArticleAvailabilityForm.js
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
if (this.props.socket) {
|
||||||
|
this.props.socket.emit('availability_inquiry_submit', availabilityData);
|
||||||
|
|
||||||
|
// Set up response handler
|
||||||
|
this.props.socket.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 || 'Ein Fehler ist aufgetreten'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper id="availability-form" sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
|
||||||
|
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
|
||||||
|
Verfügbarkeit anfragen
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Dieser Artikel ist derzeit nicht verfügbar. Gerne informieren wir Sie, sobald er wieder lieferbar ist.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<Alert severity="success" sx={{ mb: 3 }}>
|
||||||
|
Vielen Dank für Ihre Anfrage! Wir werden Sie {notificationMethod === 'email' ? 'per E-Mail' : 'über Telegram'} informieren, sobald der Artikel wieder verfügbar ist.
|
||||||
|
</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="Name"
|
||||||
|
value={name}
|
||||||
|
onChange={this.handleInputChange('name')}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="Ihr Name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl component="fieldset" disabled={loading}>
|
||||||
|
<FormLabel component="legend" sx={{ mb: 1 }}>
|
||||||
|
Wie möchten Sie benachrichtigt werden?
|
||||||
|
</FormLabel>
|
||||||
|
<RadioGroup
|
||||||
|
value={notificationMethod}
|
||||||
|
onChange={this.handleNotificationMethodChange}
|
||||||
|
row
|
||||||
|
>
|
||||||
|
<FormControlLabel
|
||||||
|
value="email"
|
||||||
|
control={<Radio />}
|
||||||
|
label="E-Mail"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
value="telegram"
|
||||||
|
control={<Radio />}
|
||||||
|
label="Telegram Bot"
|
||||||
|
/>
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{notificationMethod === 'email' && (
|
||||||
|
<TextField
|
||||||
|
label="E-Mail"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={this.handleInputChange('email')}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="ihre.email@example.com"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notificationMethod === 'telegram' && (
|
||||||
|
<TextField
|
||||||
|
label="Telegram ID"
|
||||||
|
value={telegramId}
|
||||||
|
onChange={this.handleInputChange('telegramId')}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="@IhrTelegramName oder Telegram ID"
|
||||||
|
helperText="Geben Sie Ihren Telegram-Benutzernamen (mit @) oder Ihre Telegram-ID ein"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Nachricht (optional)"
|
||||||
|
value={message}
|
||||||
|
onChange={this.handleInputChange('message')}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="Zusätzliche Informationen oder Fragen..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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 }} />
|
||||||
|
Wird gesendet...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Verfügbarkeit anfragen'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(ArticleAvailabilityForm);
|
||||||
235
src/components/ArticleQuestionForm.js
Normal file
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);
|
||||||
|
|
||||||
|
if (this.props.socket) {
|
||||||
|
this.props.socket.emit('article_question_submit', questionData);
|
||||||
|
|
||||||
|
// Set up response handler
|
||||||
|
this.props.socket.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 || 'Ein Fehler ist aufgetreten'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear messages after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setState({ success: false, error: null });
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
error: 'Fehler beim Verarbeiten der Fotos'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
|
||||||
|
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
|
||||||
|
Frage zum Artikel
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Haben Sie eine Frage zu diesem Artikel? Wir helfen Ihnen gerne weiter.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<Alert severity="success" sx={{ mb: 3 }}>
|
||||||
|
Vielen Dank für Ihre Frage! Wir werden uns schnellstmöglich bei Ihnen melden.
|
||||||
|
</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="Name"
|
||||||
|
value={name}
|
||||||
|
onChange={this.handleInputChange('name')}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="Ihr Name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="E-Mail"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={this.handleInputChange('email')}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="ihre.email@example.com"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Ihre Frage"
|
||||||
|
value={question}
|
||||||
|
onChange={this.handleInputChange('question')}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="Beschreiben Sie Ihre Frage zu diesem Artikel..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PhotoUpload
|
||||||
|
ref={this.photoUploadRef}
|
||||||
|
onChange={this.handlePhotosChange}
|
||||||
|
disabled={loading}
|
||||||
|
maxFiles={3}
|
||||||
|
label="Fotos zur Frage anhängen (optional)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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 }} />
|
||||||
|
Wird gesendet...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Frage senden'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(ArticleQuestionForm);
|
||||||
264
src/components/ArticleRatingForm.js
Normal file
264
src/components/ArticleRatingForm.js
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
if (this.props.socket) {
|
||||||
|
this.props.socket.emit('article_rating_submit', ratingData);
|
||||||
|
|
||||||
|
// Set up response handler
|
||||||
|
this.props.socket.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 || 'Ein Fehler ist aufgetreten'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear messages after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setState({ success: false, error: null });
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
error: 'Fehler beim Verarbeiten der Fotos'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
|
||||||
|
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
|
||||||
|
Artikel Bewerten
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Teilen Sie Ihre Erfahrungen mit diesem Artikel und helfen Sie anderen Kunden bei der Entscheidung.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<Alert severity="success" sx={{ mb: 3 }}>
|
||||||
|
Vielen Dank für Ihre Bewertung! Sie wird nach Prüfung veröffentlicht.
|
||||||
|
</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="Name"
|
||||||
|
value={name}
|
||||||
|
onChange={this.handleInputChange('name')}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="Ihr Name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="E-Mail"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={this.handleInputChange('email')}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="ihre.email@example.com"
|
||||||
|
helperText="Ihre E-Mail wird nicht veröffentlicht"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
|
Bewertung *
|
||||||
|
</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 ? `${rating} von 5 Sternen` : 'Bitte bewerten'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Ihre Bewertung (optional)"
|
||||||
|
value={review}
|
||||||
|
onChange={this.handleInputChange('review')}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="Beschreiben Sie Ihre Erfahrungen mit diesem Artikel..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PhotoUpload
|
||||||
|
ref={this.photoUploadRef}
|
||||||
|
onChange={this.handlePhotosChange}
|
||||||
|
disabled={loading}
|
||||||
|
maxFiles={5}
|
||||||
|
label="Fotos zur Bewertung anhängen (optional)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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 }} />
|
||||||
|
Wird gesendet...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Bewertung abgeben'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(ArticleRatingForm);
|
||||||
@@ -8,6 +8,7 @@ import TableBody from '@mui/material/TableBody';
|
|||||||
import TableCell from '@mui/material/TableCell';
|
import TableCell from '@mui/material/TableCell';
|
||||||
import TableRow from '@mui/material/TableRow';
|
import TableRow from '@mui/material/TableRow';
|
||||||
import CartItem from './CartItem.js';
|
import CartItem from './CartItem.js';
|
||||||
|
import { withI18n } from '../i18n/withTranslation.js';
|
||||||
|
|
||||||
|
|
||||||
class CartDropdown extends Component {
|
class CartDropdown extends Component {
|
||||||
@@ -53,8 +54,8 @@ class CartDropdown extends Component {
|
|||||||
currency: 'EUR'
|
currency: 'EUR'
|
||||||
});
|
});
|
||||||
|
|
||||||
const shippingNetPrice = deliveryCost / (1 + 19 / 100);
|
const shippingNetPrice = deliveryCost > 0 ? deliveryCost / (1 + 19 / 100) : 0;
|
||||||
const shippingVat = deliveryCost - shippingNetPrice;
|
const shippingVat = deliveryCost > 0 ? deliveryCost - shippingNetPrice : 0;
|
||||||
const totalVat7 = priceCalculations.vat7;
|
const totalVat7 = priceCalculations.vat7;
|
||||||
const totalVat19 = priceCalculations.vat19 + shippingVat;
|
const totalVat19 = priceCalculations.vat19 + shippingVat;
|
||||||
const totalGross = priceCalculations.totalGross + deliveryCost;
|
const totalGross = priceCalculations.totalGross + deliveryCost;
|
||||||
@@ -63,7 +64,7 @@ class CartDropdown extends Component {
|
|||||||
<>
|
<>
|
||||||
<Box sx={{ bgcolor: 'primary.main', color: 'white', p: 2 }}>
|
<Box sx={{ bgcolor: 'primary.main', color: 'white', p: 2 }}>
|
||||||
<Typography variant="h6">
|
<Typography variant="h6">
|
||||||
{cartItems.length} {cartItems.length === 1 ? 'Produkt' : 'Produkte'}
|
{cartItems.length} {cartItems.length === 1 ? (this.props.t ? this.props.t('cart.itemCount.singular') : 'Produkt') : (this.props.t ? this.props.t('cart.itemCount.plural') : 'Produkte')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ class CartDropdown extends Component {
|
|||||||
{/* Display total weight if greater than 0 */}
|
{/* Display total weight if greater than 0 */}
|
||||||
{totalWeight > 0 && (
|
{totalWeight > 0 && (
|
||||||
<Typography variant="subtitle2" sx={{ px: 2, mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ px: 2, mb: 1 }}>
|
||||||
Gesamtgewicht: {totalWeight.toFixed(2)} kg
|
{this.props.t ? this.props.t('cart.summary.totalWeight', { weight: totalWeight.toFixed(2) }) : `Gesamtgewicht: ${totalWeight.toFixed(2)} kg`}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ class CartDropdown extends Component {
|
|||||||
// Detailed summary with shipping costs
|
// Detailed summary with shipping costs
|
||||||
<>
|
<>
|
||||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||||
Bestellübersicht
|
{this.props.t ? this.props.t('cart.summary.title') : 'Bestellübersicht'}
|
||||||
</Typography>
|
</Typography>
|
||||||
{deliveryMethod && (
|
{deliveryMethod && (
|
||||||
<Typography variant="body2" sx={{ mb: 1, color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ mb: 1, color: 'text.secondary' }}>
|
||||||
@@ -104,14 +105,14 @@ class CartDropdown extends Component {
|
|||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Waren (netto):</TableCell>
|
<TableCell>{this.props.t ? this.props.t('cart.summary.goodsNet') : 'Waren (netto):'}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{currencyFormatter.format(priceCalculations.totalNet)}
|
{currencyFormatter.format(priceCalculations.totalNet)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{deliveryCost > 0 && (
|
{deliveryCost > 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Versandkosten (netto):</TableCell>
|
<TableCell>{this.props.t ? this.props.t('cart.summary.shippingNet') : 'Versandkosten (netto):'}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{currencyFormatter.format(shippingNetPrice)}
|
{currencyFormatter.format(shippingNetPrice)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -119,7 +120,7 @@ class CartDropdown extends Component {
|
|||||||
)}
|
)}
|
||||||
{totalVat7 > 0 && (
|
{totalVat7 > 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>7% Mehrwertsteuer:</TableCell>
|
<TableCell>{this.props.t ? this.props.t('tax.vat7') : '7% Mehrwertsteuer'}:</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{currencyFormatter.format(totalVat7)}
|
{currencyFormatter.format(totalVat7)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -127,28 +128,37 @@ class CartDropdown extends Component {
|
|||||||
)}
|
)}
|
||||||
{totalVat19 > 0 && (
|
{totalVat19 > 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>19% Mehrwertsteuer (inkl. Versand):</TableCell>
|
<TableCell>{this.props.t ? this.props.t('tax.vat19WithShipping') : '19% Mehrwertsteuer (inkl. Versand)'}:</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{currencyFormatter.format(totalVat19)}
|
{currencyFormatter.format(totalVat19)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtsumme Waren:</TableCell>
|
<TableCell sx={{ fontWeight: 'bold' }}>{this.props.t ? this.props.t('cart.summary.totalGoods') : 'Gesamtsumme Waren:'}</TableCell>
|
||||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||||
{currencyFormatter.format(priceCalculations.totalGross)}
|
{currencyFormatter.format(priceCalculations.totalGross)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{deliveryCost > 0 && (
|
|
||||||
<TableRow>
|
<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' }}>
|
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
|
||||||
<TableRow sx={{ borderTop: '1px solid #e0e0e0' }}>
|
<TableRow sx={{ borderTop: '1px solid #e0e0e0' }}>
|
||||||
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>Gesamtsumme:</TableCell>
|
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>{this.props.t ? this.props.t('cart.summary.total') : 'Gesamtsumme:'}</TableCell>
|
||||||
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
|
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
|
||||||
{currencyFormatter.format(totalGross)}
|
{currencyFormatter.format(totalGross)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -161,14 +171,14 @@ class CartDropdown extends Component {
|
|||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Gesamtnettopreis:</TableCell>
|
<TableCell>{this.props.t ? this.props.t('tax.totalNet') : 'Gesamtnettopreis'}:</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.totalNet)}
|
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.totalNet)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{priceCalculations.vat7 > 0 && (
|
{priceCalculations.vat7 > 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>7% Mehrwertsteuer:</TableCell>
|
<TableCell>{this.props.t ? this.props.t('tax.vat7') : '7% Mehrwertsteuer'}:</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.vat7)}
|
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.vat7)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -176,14 +186,14 @@ class CartDropdown extends Component {
|
|||||||
)}
|
)}
|
||||||
{priceCalculations.vat19 > 0 && (
|
{priceCalculations.vat19 > 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>19% Mehrwertsteuer:</TableCell>
|
<TableCell>{this.props.t ? this.props.t('tax.vat19') : '19% Mehrwertsteuer'}:</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.vat19)}
|
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.vat19)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
<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' }}>
|
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.totalGross)}
|
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.totalGross)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -201,7 +211,7 @@ class CartDropdown extends Component {
|
|||||||
fullWidth
|
fullWidth
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
Weiter einkaufen
|
{this.props.t ? this.props.t('cart.continueShopping') : 'Weiter einkaufen'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -213,7 +223,7 @@ class CartDropdown extends Component {
|
|||||||
sx={{ mt: 2 }}
|
sx={{ mt: 2 }}
|
||||||
onClick={onCheckout}
|
onClick={onCheckout}
|
||||||
>
|
>
|
||||||
Weiter zur Kasse
|
{this.props.t ? this.props.t('cart.proceedToCheckout') : 'Weiter zur Kasse'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -223,4 +233,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 Box from '@mui/material/Box';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import AddToCartButton from './AddToCartButton.js';
|
import AddToCartButton from './AddToCartButton.js';
|
||||||
|
import { withI18n } from '../i18n/withTranslation.js';
|
||||||
|
|
||||||
class CartItem extends Component {
|
class CartItem extends Component {
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ class CartItem extends Component {
|
|||||||
this.setState({image:window.tinyPicCache[picid],loading:false, error: false})
|
this.setState({image:window.tinyPicCache[picid],loading:false, error: false})
|
||||||
}else{
|
}else{
|
||||||
this.setState({image: null, loading: true, error: false});
|
this.setState({image: null, loading: true, error: false});
|
||||||
if(this.props.socket){
|
if(this.props.socket && this.props.socket.connected){
|
||||||
this.props.socket.emit('getPic', { bildId:picid, size:'tiny' }, (res) => {
|
this.props.socket.emit('getPic', { bildId:picid, size:'tiny' }, (res) => {
|
||||||
if(res.success){
|
if(res.success){
|
||||||
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||||
@@ -116,7 +117,7 @@ class CartItem extends Component {
|
|||||||
)}
|
)}
|
||||||
{item.versandklasse && item.versandklasse != 'standard' && item.versandklasse != 'kostenlos' && (
|
{item.versandklasse && item.versandklasse != 'standard' && item.versandklasse != 'kostenlos' && (
|
||||||
<Typography variant="body2" color="warning.main" fontWeight="medium" component="div">
|
<Typography variant="body2" color="warning.main" fontWeight="medium" component="div">
|
||||||
{item.versandklasse}
|
{item.versandklasse == 'nur Abholung' ? this.props.t('delivery.descriptions.pickupOnly') : item.versandklasse}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{item.vat && (
|
{item.vat && (
|
||||||
@@ -126,9 +127,9 @@ class CartItem extends Component {
|
|||||||
fontStyle="italic"
|
fontStyle="italic"
|
||||||
component="div"
|
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))
|
(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>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -146,11 +147,14 @@ class CartItem extends Component {
|
|||||||
display: "block"
|
display: "block"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{this.props.id.toString().endsWith("steckling") ? "Lieferzeit: 14 Tage" :
|
{this.props.id.toString().endsWith("steckling") ?
|
||||||
item.available == 1 ? "Lieferzeit: 2-3 Tage" :
|
(this.props.t ? this.props.t('delivery.times.cutting14Days') : "Lieferzeit: 14 Tage") :
|
||||||
item.availableSupplier == 1 ? "Lieferzeit: 7-9 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>
|
</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>
|
||||||
</Box>
|
</Box>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@@ -159,4 +163,4 @@ class CartItem extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CartItem;
|
export default withI18n()(CartItem);
|
||||||
@@ -16,7 +16,7 @@ const CategoryBox = ({
|
|||||||
name,
|
name,
|
||||||
seoName,
|
seoName,
|
||||||
bgcolor,
|
bgcolor,
|
||||||
fontSize = '0.8rem',
|
fontSize = '1.2rem',
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [imageUrl, setImageUrl] = useState(null);
|
const [imageUrl, setImageUrl] = useState(null);
|
||||||
@@ -186,7 +186,7 @@ const CategoryBox = ({
|
|||||||
fontFamily: 'SwashingtonCP, "Times New Roman", Georgia, serif',
|
fontFamily: 'SwashingtonCP, "Times New Roman", Georgia, serif',
|
||||||
fontWeight: 'normal',
|
fontWeight: 'normal',
|
||||||
lineHeight: '1.2',
|
lineHeight: '1.2',
|
||||||
padding: '0 8px'
|
padding: '12px 8px'
|
||||||
}}>
|
}}>
|
||||||
{name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import CategoryBox from './CategoryBox.js';
|
|||||||
|
|
||||||
import { useParams, useSearchParams } from 'react-router-dom';
|
import { useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js';
|
import { getAllSettingsWithPrefix } 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);
|
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ function getCachedCategoryData(categoryId) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
function getFilteredProducts(unfilteredProducts, attributes) {
|
function getFilteredProducts(unfilteredProducts, attributes, t) {
|
||||||
const attributeSettings = getAllSettingsWithPrefix('filter_attribute_');
|
const attributeSettings = getAllSettingsWithPrefix('filter_attribute_');
|
||||||
const manufacturerSettings = getAllSettingsWithPrefix('filter_manufacturer_');
|
const manufacturerSettings = getAllSettingsWithPrefix('filter_manufacturer_');
|
||||||
const availabilitySettings = getAllSettingsWithPrefix('filter_availability_');
|
const availabilitySettings = getAllSettingsWithPrefix('filter_availability_');
|
||||||
@@ -149,17 +150,17 @@ function getFilteredProducts(unfilteredProducts, attributes) {
|
|||||||
|
|
||||||
// Check for "auf Lager" filter (in stock) - it's active when filter_availability is NOT set to '1'
|
// Check for "auf Lager" filter (in stock) - it's active when filter_availability is NOT set to '1'
|
||||||
if (availabilityFilter !== '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
|
// Check for "Neu" filter (new) - only show if there are actually new products and filter is active
|
||||||
if (availabilityFilters.includes('2') && hasNewProducts) {
|
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
|
// 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) {
|
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};
|
return {filteredProducts,activeAttributeFilters:activeAttributeFiltersWithNames,activeManufacturerFilters:activeManufacturerFiltersWithNames,activeAvailabilityFilters};
|
||||||
@@ -256,7 +257,8 @@ class Content extends Component {
|
|||||||
unfilteredProducts: unfilteredProducts,
|
unfilteredProducts: unfilteredProducts,
|
||||||
...getFilteredProducts(
|
...getFilteredProducts(
|
||||||
unfilteredProducts,
|
unfilteredProducts,
|
||||||
response.attributes
|
response.attributes,
|
||||||
|
this.props.t
|
||||||
),
|
),
|
||||||
categoryName: response.categoryName || response.name || null,
|
categoryName: response.categoryName || response.name || null,
|
||||||
dataType: response.dataType,
|
dataType: response.dataType,
|
||||||
@@ -385,7 +387,8 @@ class Content extends Component {
|
|||||||
this.setState({
|
this.setState({
|
||||||
...getFilteredProducts(
|
...getFilteredProducts(
|
||||||
this.state.unfilteredProducts,
|
this.state.unfilteredProducts,
|
||||||
this.state.attributes
|
this.state.attributes,
|
||||||
|
this.props.t
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -602,7 +605,7 @@ class Content extends Component {
|
|||||||
{(this.getCurrentCategoryId() == 706 || this.getCurrentCategoryId() == 689) &&
|
{(this.getCurrentCategoryId() == 706 || this.getCurrentCategoryId() == 689) &&
|
||||||
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
|
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
|
||||||
<Typography variant="h6" sx={{mt:3}}>
|
<Typography variant="h6" sx={{mt:3}}>
|
||||||
Andere Kategorien
|
{this.props.t ? this.props.t('navigation.otherCategories') : 'Andere Kategorien'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
@@ -647,7 +650,7 @@ class Content extends Component {
|
|||||||
p: 2,
|
p: 2,
|
||||||
}}>
|
}}>
|
||||||
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
|
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
|
||||||
Seeds
|
{this.props.t('sections.seeds')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -694,7 +697,7 @@ class Content extends Component {
|
|||||||
p: 2,
|
p: 2,
|
||||||
}}>
|
}}>
|
||||||
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
|
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
|
||||||
Stecklinge
|
{this.props.t('sections.stecklinge')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -723,4 +726,4 @@ class Content extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(Content);
|
export default withRouter(withI18n()(Content));
|
||||||
@@ -6,6 +6,7 @@ import Link from '@mui/material/Link';
|
|||||||
import { Link as RouterLink } from 'react-router-dom';
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
|
import { withI18n } from '../i18n/withTranslation.js';
|
||||||
|
|
||||||
// Styled component for the router links
|
// Styled component for the router links
|
||||||
const StyledRouterLink = styled(RouterLink)(() => ({
|
const StyledRouterLink = styled(RouterLink)(() => ({
|
||||||
@@ -229,9 +230,9 @@ class Footer extends Component {
|
|||||||
alignItems={{ xs: 'center', md: 'left' }}
|
alignItems={{ xs: 'center', md: 'left' }}
|
||||||
flexWrap="wrap"
|
flexWrap="wrap"
|
||||||
>
|
>
|
||||||
<StyledRouterLink to="/datenschutz">Datenschutz</StyledRouterLink>
|
<StyledRouterLink to="/datenschutz">{this.props.t ? this.props.t('footer.legal.datenschutz') : 'Datenschutz'}</StyledRouterLink>
|
||||||
<StyledRouterLink to="/agb">AGB</StyledRouterLink>
|
<StyledRouterLink to="/agb">{this.props.t ? this.props.t('footer.legal.agb') : 'AGB'}</StyledRouterLink>
|
||||||
<StyledRouterLink to="/sitemap">Sitemap</StyledRouterLink>
|
<StyledRouterLink to="/sitemap">{this.props.t ? this.props.t('footer.legal.sitemap') : 'Sitemap'}</StyledRouterLink>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
@@ -241,12 +242,12 @@ class Footer extends Component {
|
|||||||
alignItems={{ xs: 'center', md: 'left' }}
|
alignItems={{ xs: 'center', md: 'left' }}
|
||||||
flexWrap="wrap"
|
flexWrap="wrap"
|
||||||
>
|
>
|
||||||
<StyledRouterLink to="/impressum">Impressum</StyledRouterLink>
|
<StyledRouterLink to="/impressum">{this.props.t ? this.props.t('footer.legal.impressum') : 'Impressum'}</StyledRouterLink>
|
||||||
<StyledRouterLink to="/batteriegesetzhinweise">Batteriegesetzhinweise</StyledRouterLink>
|
<StyledRouterLink to="/batteriegesetzhinweise">{this.props.t ? this.props.t('footer.legal.batteriegesetzhinweise') : 'Batteriegesetzhinweise'}</StyledRouterLink>
|
||||||
<StyledRouterLink to="/widerrufsrecht">Widerrufsrecht</StyledRouterLink>
|
<StyledRouterLink to="/widerrufsrecht">{this.props.t ? this.props.t('footer.legal.widerrufsrecht') : 'Widerrufsrecht'}</StyledRouterLink>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Payment Methods Section */}
|
{/* Payment Methods Section
|
||||||
<Stack
|
<Stack
|
||||||
direction="column"
|
direction="column"
|
||||||
spacing={1}
|
spacing={1}
|
||||||
@@ -263,7 +264,7 @@ class Footer extends Component {
|
|||||||
<Box component="img" src="/assets/images/cards.png" alt="Cash" sx={{ height: { xs: 80, md: 95 } }} />
|
<Box component="img" src="/assets/images/cards.png" alt="Cash" sx={{ height: { xs: 80, md: 95 } }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
*/}
|
||||||
{/* Google Services Badge Section */}
|
{/* Google Services Badge Section */}
|
||||||
<Stack
|
<Stack
|
||||||
direction="column"
|
direction="column"
|
||||||
@@ -274,9 +275,9 @@ class Footer extends Component {
|
|||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
spacing={{ xs: 1, md: 2 }}
|
spacing={{ xs: 1, md: 2 }}
|
||||||
sx={{pb: '10px'}}
|
sx={{pt: '10px', height: { xs: 50, md: 60 }, transform: 'translateY(-3px)'}}
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
alignItems="center"
|
alignItems="flex-end"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href="https://reviewthis.biz/growheads"
|
href="https://reviewthis.biz/growheads"
|
||||||
@@ -285,7 +286,10 @@ class Footer extends Component {
|
|||||||
sx={{
|
sx={{
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
zIndex: 9999
|
zIndex: 9999,
|
||||||
|
display: 'inline-block',
|
||||||
|
height: { xs: 57, md: 67 },
|
||||||
|
lineHeight: 1
|
||||||
}}
|
}}
|
||||||
onMouseEnter={this.handleReviewsMouseEnter}
|
onMouseEnter={this.handleReviewsMouseEnter}
|
||||||
onMouseLeave={this.handleReviewsMouseLeave}
|
onMouseLeave={this.handleReviewsMouseLeave}
|
||||||
@@ -311,7 +315,10 @@ class Footer extends Component {
|
|||||||
sx={{
|
sx={{
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
zIndex: 9999
|
zIndex: 9999,
|
||||||
|
display: 'inline-block',
|
||||||
|
height: { xs: 47, md: 67 },
|
||||||
|
lineHeight: 1
|
||||||
}}
|
}}
|
||||||
onMouseEnter={this.handleMapsMouseEnter}
|
onMouseEnter={this.handleMapsMouseEnter}
|
||||||
onMouseLeave={this.handleMapsMouseLeave}
|
onMouseLeave={this.handleMapsMouseLeave}
|
||||||
@@ -338,7 +345,7 @@ class Footer extends Component {
|
|||||||
{/* Copyright Section */}
|
{/* Copyright Section */}
|
||||||
<Box sx={{ pb:'20px',textAlign: 'center', filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))', opacity: 0.7 }}>
|
<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 }}>
|
<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>
|
||||||
<Typography variant="body2" sx={{ fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
|
<Typography variant="body2" sx={{ fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
|
||||||
© {new Date().getFullYear()} <StyledDomainLink href="https://growheads.de" target="_blank" rel="noopener noreferrer">GrowHeads.de</StyledDomainLink>
|
© {new Date().getFullYear()} <StyledDomainLink href="https://growheads.de" target="_blank" rel="noopener noreferrer">GrowHeads.de</StyledDomainLink>
|
||||||
@@ -351,4 +358,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 Button from '@mui/material/Button';
|
||||||
import GoogleIcon from '@mui/icons-material/Google';
|
import GoogleIcon from '@mui/icons-material/Google';
|
||||||
import GoogleAuthContext from '../contexts/GoogleAuthContext.js';
|
import GoogleAuthContext from '../contexts/GoogleAuthContext.js';
|
||||||
|
import { withI18n } from '../i18n/index.js';
|
||||||
|
|
||||||
class GoogleLoginButton extends Component {
|
class GoogleLoginButton extends Component {
|
||||||
static contextType = GoogleAuthContext;
|
static contextType = GoogleAuthContext;
|
||||||
@@ -186,7 +187,7 @@ class GoogleLoginButton extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { disabled, style, className, text = 'Mit Google anmelden' } = this.props;
|
const { disabled, style, className, text = (this.props.t ? this.props.t('auth.loginWithGoogle') : 'Mit Google anmelden') } = this.props;
|
||||||
const { isInitializing, isPrompting } = this.state;
|
const { isInitializing, isPrompting } = this.state;
|
||||||
const isLoading = isInitializing || isPrompting || (this.context && !this.context.isLoaded);
|
const isLoading = isInitializing || isPrompting || (this.context && !this.context.isLoaded);
|
||||||
|
|
||||||
@@ -205,4 +206,4 @@ class GoogleLoginButton extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default GoogleLoginButton;
|
export default withI18n(GoogleLoginButton);
|
||||||
@@ -38,7 +38,7 @@ class Header extends Component {
|
|||||||
render() {
|
render() {
|
||||||
// Get socket directly from context in render method
|
// Get socket directly from context in render method
|
||||||
const {socket,socketB} = this.context;
|
const {socket,socketB} = this.context;
|
||||||
const { isHomePage, isProfilePage } = this.props;
|
const { isHomePage, isProfilePage, isAktionenPage, isFilialePage } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar position="sticky" color="primary" elevation={0} sx={{ zIndex: 1100 }}>
|
<AppBar position="sticky" color="primary" elevation={0} sx={{ zIndex: 1100 }}>
|
||||||
@@ -94,7 +94,7 @@ class Header extends Component {
|
|||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
{(isHomePage || this.props.categoryId || isProfilePage) && <CategoryList categoryId={209} activeCategoryId={this.props.categoryId} socket={socket} socketB={socketB} />}
|
{(isHomePage || this.props.categoryId || isProfilePage || isAktionenPage || isFilialePage) && <CategoryList categoryId={209} activeCategoryId={this.props.categoryId} socket={socket} socketB={socketB} />}
|
||||||
</AppBar>
|
</AppBar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -105,10 +105,12 @@ const HeaderWithContext = (props) => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isHomePage = location.pathname === '/';
|
const isHomePage = location.pathname === '/';
|
||||||
const isProfilePage = location.pathname === '/profile';
|
const isProfilePage = location.pathname === '/profile';
|
||||||
|
const isAktionenPage = location.pathname === '/aktionen';
|
||||||
|
const isFilialePage = location.pathname === '/filiale';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SocketContext.Consumer>
|
<SocketContext.Consumer>
|
||||||
{({socket,socketB}) => <Header {...props} socket={socket} socketB={socketB} isHomePage={isHomePage} isProfilePage={isProfilePage} />}
|
{({socket,socketB}) => <Header {...props} socket={socket} socketB={socketB} isHomePage={isHomePage} isProfilePage={isProfilePage} isAktionenPage={isAktionenPage} isFilialePage={isFilialePage} />}
|
||||||
</SocketContext.Consumer>
|
</SocketContext.Consumer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ import LoupeIcon from '@mui/icons-material/Loupe';
|
|||||||
class Images extends Component {
|
class Images extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { mainPic:0,pics:[]};
|
this.state = { mainPic:0,pics:[], needsSocketRetry: false };
|
||||||
|
|
||||||
console.log('Images constructor',props);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
@@ -24,6 +22,15 @@ class Images extends Component {
|
|||||||
if (prevProps.fullscreenOpen !== this.props.fullscreenOpen) {
|
if (prevProps.fullscreenOpen !== this.props.fullscreenOpen) {
|
||||||
this.updatePics();
|
this.updatePics();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retry loading images if socket just became available
|
||||||
|
const wasConnected = prevProps.socketB && prevProps.socketB.connected;
|
||||||
|
const isNowConnected = this.props.socketB && this.props.socketB.connected;
|
||||||
|
|
||||||
|
if (!wasConnected && isNowConnected && this.state.needsSocketRetry) {
|
||||||
|
this.setState({ needsSocketRetry: false });
|
||||||
|
this.updatePics();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePics = (newMainPic = this.state.mainPic) => {
|
updatePics = (newMainPic = this.state.mainPic) => {
|
||||||
@@ -51,10 +58,10 @@ class Images extends Component {
|
|||||||
pics.push(window.smallPicCache[bildId]);
|
pics.push(window.smallPicCache[bildId]);
|
||||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||||
}else if(window.tinyPicCache[bildId]){
|
}else if(window.tinyPicCache[bildId]){
|
||||||
pics.push(bildId);
|
pics.push(window.tinyPicCache[bildId]);
|
||||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||||
}else{
|
}else{
|
||||||
pics.push(bildId);
|
pics.push(`/assets/images/prod${bildId}.jpg`);
|
||||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||||
}
|
}
|
||||||
}else{
|
}else{
|
||||||
@@ -69,7 +76,8 @@ class Images extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('pics',pics);
|
console.log('DEBUG: pics array contents:', pics);
|
||||||
|
console.log('DEBUG: pics array types:', pics.map(p => typeof p + ': ' + p));
|
||||||
this.setState({ pics, mainPic: newMainPic });
|
this.setState({ pics, mainPic: newMainPic });
|
||||||
}else{
|
}else{
|
||||||
if(this.state.pics.length > 0) this.setState({ pics:[], mainPic: newMainPic });
|
if(this.state.pics.length > 0) this.setState({ pics:[], mainPic: newMainPic });
|
||||||
@@ -77,6 +85,13 @@ class Images extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadPic = (size,bildId,index) => {
|
loadPic = (size,bildId,index) => {
|
||||||
|
// Check if socketB is available and connected before emitting
|
||||||
|
if (!this.props.socketB || !this.props.socketB.connected) {
|
||||||
|
console.log("Images: socketB not available, will retry when connected");
|
||||||
|
this.setState({ needsSocketRetry: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.props.socketB.emit('getPic', { bildId, size }, (res) => {
|
this.props.socketB.emit('getPic', { bildId, size }, (res) => {
|
||||||
if(res.success){
|
if(res.success){
|
||||||
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||||
@@ -101,10 +116,57 @@ class Images extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
// Prerender detection - if no sockets, render simple CardMedia with static path
|
||||||
|
if (!this.props.socketB) {
|
||||||
|
const getImagePath = (pictureList) => {
|
||||||
|
if (!pictureList || !pictureList.trim()) {
|
||||||
|
return '/assets/images/nopicture.jpg';
|
||||||
|
}
|
||||||
|
return `/assets/images/prod${pictureList.split(',')[0].trim()}.jpg`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{this.state.pics[this.state.mainPic] && (
|
<Box sx={{ position: 'relative', display: 'inline-block', width: '499px', maxWidth: '100%' }}>
|
||||||
<Box sx={{ position: 'relative', display: 'inline-block' }}>
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
height="400"
|
||||||
|
image={getImagePath(this.props.pictureList)}
|
||||||
|
sx={{
|
||||||
|
objectFit: 'contain',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'transform 0.2s ease-in-out',
|
||||||
|
width: '499px',
|
||||||
|
maxWidth: '100%',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.02)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={2} sx={{ justifyContent: 'flex-start', mt: 1, mb: 1 }}>
|
||||||
|
{/* Empty thumbnail gallery for prerender - reserves the mt+mb spacing (16px) */}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPA version - full functionality with static fallback
|
||||||
|
const getImageSrc = () => {
|
||||||
|
// 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()}.jpg`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box sx={{ position: 'relative', display: 'inline-block', width: '499px', maxWidth: '100%' }}>
|
||||||
<CardMedia
|
<CardMedia
|
||||||
component="img"
|
component="img"
|
||||||
height="400"
|
height="400"
|
||||||
@@ -112,11 +174,13 @@ class Images extends Component {
|
|||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'transform 0.2s ease-in-out',
|
transition: 'transform 0.2s ease-in-out',
|
||||||
|
width: '499px',
|
||||||
|
maxWidth: '100%',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
transform: 'scale(1.02)'
|
transform: 'scale(1.02)'
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
image={this.state.pics[this.state.mainPic]}
|
image={getImageSrc()}
|
||||||
onClick={this.props.onOpenFullscreen}
|
onClick={this.props.onOpenFullscreen}
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -137,7 +201,6 @@ class Images extends Component {
|
|||||||
<LoupeIcon fontSize="small" />
|
<LoupeIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
<Stack direction="row" spacing={2} sx={{ justifyContent: 'flex-start', mt: 1,mb: 1 }}>
|
<Stack direction="row" spacing={2} sx={{ justifyContent: 'flex-start', mt: 1,mb: 1 }}>
|
||||||
{this.state.pics.filter(pic => pic !== null && pic !== this.state.pics[this.state.mainPic]).map((pic, filterIndex) => {
|
{this.state.pics.filter(pic => pic !== null && pic !== this.state.pics[this.state.mainPic]).map((pic, filterIndex) => {
|
||||||
// Find the original index in the full pics array
|
// Find the original index in the full pics array
|
||||||
|
|||||||
269
src/components/LanguageSwitcher.js
Normal file
269
src/components/LanguageSwitcher.js
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
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 = (language) => {
|
||||||
|
const { languageContext } = this.props;
|
||||||
|
if (languageContext) {
|
||||||
|
languageContext.changeLanguage(language);
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
'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 all flags
|
||||||
|
languageContext.availableLanguages.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',
|
||||||
|
'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',
|
||||||
|
'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, availableLanguages } = 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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{availableLanguages.map((language) => (
|
||||||
|
<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,7 @@ import GoogleLoginButton from './GoogleLoginButton.js';
|
|||||||
import CartSyncDialog from './CartSyncDialog.js';
|
import CartSyncDialog from './CartSyncDialog.js';
|
||||||
import { localAndArchiveServer, mergeCarts } from '../utils/cartUtils.js';
|
import { localAndArchiveServer, mergeCarts } from '../utils/cartUtils.js';
|
||||||
import config from '../config.js';
|
import config from '../config.js';
|
||||||
|
import { withI18n } from '../i18n/withTranslation.js';
|
||||||
|
|
||||||
// Lazy load GoogleAuthProvider
|
// Lazy load GoogleAuthProvider
|
||||||
const GoogleAuthProvider = lazy(() => import('../providers/GoogleAuthProvider.js'));
|
const GoogleAuthProvider = lazy(() => import('../providers/GoogleAuthProvider.js'));
|
||||||
@@ -510,7 +511,7 @@ export class LoginComponent extends Component {
|
|||||||
color={isAdmin ? 'secondary' : 'inherit'}
|
color={isAdmin ? 'secondary' : 'inherit'}
|
||||||
sx={{ my: 1, mx: 1.5 }}
|
sx={{ my: 1, mx: 1.5 }}
|
||||||
>
|
>
|
||||||
Profil
|
{this.props.t ? this.props.t('auth.profile') : 'Profil'}
|
||||||
</Button>
|
</Button>
|
||||||
<Menu
|
<Menu
|
||||||
disableScrollLock={true}
|
disableScrollLock={true}
|
||||||
@@ -526,14 +527,28 @@ export class LoginComponent extends Component {
|
|||||||
horizontal: 'right',
|
horizontal: 'right',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem component={Link} to="/profile" onClick={this.handleUserMenuClose}>Profil</MenuItem>
|
<MenuItem component={Link} to="/profile" onClick={this.handleUserMenuClose}>
|
||||||
<MenuItem component={Link} to="/profile#cart" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Bestellabschluss</MenuItem>
|
{this.props.t ? this.props.t('auth.menu.profile') : 'Profil'}
|
||||||
<MenuItem component={Link} to="/profile#orders" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Bestellungen</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem component={Link} to="/profile#settings" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Einstellungen</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 />
|
<Divider />
|
||||||
{isAdmin ? <MenuItem component={Link} to="/admin" onClick={this.handleUserMenuClose}>Admin Dashboard</MenuItem> : null}
|
{isAdmin ? <MenuItem component={Link} to="/admin" onClick={this.handleUserMenuClose}>
|
||||||
{isAdmin ? <MenuItem component={Link} to="/admin/users" onClick={this.handleUserMenuClose}>Admin Users</MenuItem> : null}
|
{this.props.t ? this.props.t('auth.menu.adminDashboard') : 'Admin Dashboard'}
|
||||||
<MenuItem onClick={this.handleLogout}>Abmelden</MenuItem>
|
</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>
|
</Menu>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -543,7 +558,7 @@ export class LoginComponent extends Component {
|
|||||||
onClick={this.handleOpen}
|
onClick={this.handleOpen}
|
||||||
sx={{ my: 1, mx: 1.5 }}
|
sx={{ my: 1, mx: 1.5 }}
|
||||||
>
|
>
|
||||||
Login
|
{this.props.t ? this.props.t('auth.login') : 'Login'}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -558,7 +573,10 @@ export class LoginComponent extends Component {
|
|||||||
<DialogTitle sx={{ bgcolor: 'white', pb: 0 }}>
|
<DialogTitle sx={{ bgcolor: 'white', pb: 0 }}>
|
||||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||||
<Typography variant="h6" color="#2e7d32" fontWeight="bold">
|
<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>
|
</Typography>
|
||||||
<IconButton edge="end" onClick={this.handleClose} aria-label="close">
|
<IconButton edge="end" onClick={this.handleClose} aria-label="close">
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
@@ -578,14 +596,14 @@ export class LoginComponent extends Component {
|
|||||||
textColor="inherit"
|
textColor="inherit"
|
||||||
>
|
>
|
||||||
<Tab
|
<Tab
|
||||||
label="ANMELDEN"
|
label={this.props.t ? this.props.t('auth.login').toUpperCase() : "ANMELDEN"}
|
||||||
sx={{
|
sx={{
|
||||||
color: tabValue === 0 ? '#2e7d32' : 'inherit',
|
color: tabValue === 0 ? '#2e7d32' : 'inherit',
|
||||||
fontWeight: 'bold'
|
fontWeight: 'bold'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tab
|
<Tab
|
||||||
label="REGISTRIEREN"
|
label={this.props.t ? this.props.t('auth.register').toUpperCase() : "REGISTRIEREN"}
|
||||||
sx={{
|
sx={{
|
||||||
color: tabValue === 1 ? '#2e7d32' : 'inherit',
|
color: tabValue === 1 ? '#2e7d32' : 'inherit',
|
||||||
fontWeight: 'bold'
|
fontWeight: 'bold'
|
||||||
@@ -598,7 +616,14 @@ export class LoginComponent extends Component {
|
|||||||
<Box sx={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', mb: 2 }}>
|
<Box sx={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', mb: 2 }}>
|
||||||
{!privacyConfirmed && (
|
{!privacyConfirmed && (
|
||||||
<Typography variant="caption" sx={{ mb: 1, textAlign: 'center' }}>
|
<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>
|
Mit dem Click auf "Mit Google anmelden" akzeptiere ich die <Link to="/datenschutz" style={{ color: '#4285F4' }}>Datenschutzbestimmungen</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{!showGoogleAuth && (
|
{!showGoogleAuth && (
|
||||||
@@ -611,7 +636,7 @@ export class LoginComponent extends Component {
|
|||||||
}}
|
}}
|
||||||
sx={{ width: '100%', backgroundColor: '#4285F4', color: 'white' }}
|
sx={{ width: '100%', backgroundColor: '#4285F4', color: 'white' }}
|
||||||
>
|
>
|
||||||
Mit Google anmelden
|
{this.props.t ? this.props.t('auth.loginWithGoogle') : 'Mit Google anmelden'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -643,7 +668,9 @@ export class LoginComponent extends Component {
|
|||||||
{/* OR Divider */}
|
{/* OR Divider */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', my: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', my: 2 }}>
|
||||||
<Box sx={{ flex: 1, height: '1px', backgroundColor: '#e0e0e0' }} />
|
<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 sx={{ flex: 1, height: '1px', backgroundColor: '#e0e0e0' }} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -654,7 +681,7 @@ export class LoginComponent extends Component {
|
|||||||
<Box sx={{ py: 1 }}>
|
<Box sx={{ py: 1 }}>
|
||||||
<TextField
|
<TextField
|
||||||
margin="dense"
|
margin="dense"
|
||||||
label="E-Mail"
|
label={this.props.t ? this.props.t('auth.email') : 'E-Mail'}
|
||||||
type="email"
|
type="email"
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -665,7 +692,7 @@ export class LoginComponent extends Component {
|
|||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
margin="dense"
|
margin="dense"
|
||||||
label="Passwort"
|
label={this.props.t ? this.props.t('auth.password') : 'Passwort'}
|
||||||
type="password"
|
type="password"
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -687,7 +714,7 @@ export class LoginComponent extends Component {
|
|||||||
'&:hover': { backgroundColor: 'transparent', textDecoration: 'underline' }
|
'&:hover': { backgroundColor: 'transparent', textDecoration: 'underline' }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Passwort vergessen?
|
{this.props.t ? this.props.t('auth.forgotPassword') : 'Passwort vergessen?'}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -695,7 +722,7 @@ export class LoginComponent extends Component {
|
|||||||
{tabValue === 1 && (
|
{tabValue === 1 && (
|
||||||
<TextField
|
<TextField
|
||||||
margin="dense"
|
margin="dense"
|
||||||
label="Passwort bestätigen"
|
label={this.props.t ? this.props.t('auth.confirmPassword') : 'Passwort bestätigen'}
|
||||||
type="password"
|
type="password"
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -717,7 +744,7 @@ export class LoginComponent extends Component {
|
|||||||
onClick={tabValue === 0 ? this.handleLogin : this.handleRegister}
|
onClick={tabValue === 0 ? this.handleLogin : this.handleRegister}
|
||||||
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -740,4 +767,4 @@ export class LoginComponent extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(LoginComponent);
|
export default withRouter(withI18n()(LoginComponent));
|
||||||
651
src/components/MainPageLayout.js
Normal file
651
src/components/MainPageLayout.js
Normal file
@@ -0,0 +1,651 @@
|
|||||||
|
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 MainPageLayout = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const currentPath = location.pathname;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [starHovered, setStarHovered] = React.useState(false);
|
||||||
|
|
||||||
|
// Determine which page we're on
|
||||||
|
const isHome = currentPath === "/";
|
||||||
|
const isAktionen = currentPath === "/aktionen";
|
||||||
|
const isFiliale = currentPath === "/filiale";
|
||||||
|
|
||||||
|
// Add CSS animations for rotating stars
|
||||||
|
React.useEffect(() => {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
@keyframes rotateClockwise {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotateCounterClockwise {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(-360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-rotate-slow-cw {
|
||||||
|
animation: rotateClockwise 60s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-rotate-slow-ccw {
|
||||||
|
animation: rotateCounterClockwise 45s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-rotate-medium-cw {
|
||||||
|
animation: rotateClockwise 30s linear infinite;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.head.removeChild(style);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get navigation config based on current page
|
||||||
|
const getNavigationConfig = () => {
|
||||||
|
if (isHome) {
|
||||||
|
return {
|
||||||
|
leftNav: { text: t('navigation.aktionen'), link: "/aktionen" },
|
||||||
|
rightNav: { text: t('navigation.filiale'), link: "/filiale" }
|
||||||
|
};
|
||||||
|
} else if (isAktionen) {
|
||||||
|
return {
|
||||||
|
leftNav: { text: t('navigation.filiale'), link: "/filiale" },
|
||||||
|
rightNav: { text: t('navigation.home'), link: "/" }
|
||||||
|
};
|
||||||
|
} else if (isFiliale) {
|
||||||
|
return {
|
||||||
|
leftNav: { text: t('navigation.home'), link: "/" },
|
||||||
|
rightNav: { text: t('navigation.aktionen'), link: "/aktionen" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { leftNav: null, rightNav: null };
|
||||||
|
};
|
||||||
|
|
||||||
|
const allTitles = {
|
||||||
|
home: t('titles.filiale'),
|
||||||
|
aktionen: t('titles.aktionen'),
|
||||||
|
filiale: t('titles.home')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define all content boxes for layered rendering
|
||||||
|
const allContentBoxes = {
|
||||||
|
home: [
|
||||||
|
{
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
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.seeds'),
|
||||||
|
image: "/assets/images/seeds.jpg",
|
||||||
|
bgcolor: "#e1f0d3",
|
||||||
|
link: "/Kategorie/Seeds"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('sections.stecklinge'),
|
||||||
|
image: "/assets/images/cutlings.jpg",
|
||||||
|
bgcolor: "#e8f5d6",
|
||||||
|
link: "/Kategorie/Stecklinge"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get opacity for each page layer
|
||||||
|
const getOpacity = (pageType) => {
|
||||||
|
if (pageType === "home" && isHome) return 1;
|
||||||
|
if (pageType === "aktionen" && isAktionen) return 1;
|
||||||
|
if (pageType === "filiale" && isFiliale) return 1;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const navConfig = getNavigationConfig();
|
||||||
|
|
||||||
|
// Navigation text mapping for translation
|
||||||
|
const navTexts = [
|
||||||
|
{ key: 'aktionen', text: t('navigation.aktionen'), link: '/aktionen' },
|
||||||
|
{ key: 'filiale', text: t('navigation.filiale'), link: '/filiale' },
|
||||||
|
{ key: 'home', text: t('navigation.home'), link: '/' }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 2 }}>
|
||||||
|
<style>{getCombinedAnimatedBorderStyles(['seeds', 'cutlings'])}</style>
|
||||||
|
|
||||||
|
{/* Main Navigation Header */}
|
||||||
|
<Box sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 4,
|
||||||
|
mt: 2,
|
||||||
|
px: 0,
|
||||||
|
transition: "all 0.3s ease-in-out",
|
||||||
|
// Portrait phone: stack title above navigation
|
||||||
|
flexDirection: {
|
||||||
|
xs: "column",
|
||||||
|
sm: "row"
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{/* Title for portrait phones - shown first */}
|
||||||
|
<Box sx={{
|
||||||
|
display: { xs: "block", sm: "none" },
|
||||||
|
mb: { xs: 2, sm: 0 },
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "center",
|
||||||
|
position: "relative"
|
||||||
|
}}>
|
||||||
|
{Object.entries(allTitles).map(([pageType, title]) => (
|
||||||
|
<Typography
|
||||||
|
key={pageType}
|
||||||
|
variant="h3"
|
||||||
|
component="h1"
|
||||||
|
sx={{
|
||||||
|
fontFamily: "SwashingtonCP",
|
||||||
|
fontSize: { xs: "2.125rem", sm: "2.125rem", md: "3rem" },
|
||||||
|
textAlign: "center",
|
||||||
|
color: "primary.main",
|
||||||
|
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)",
|
||||||
|
transition: "opacity 0.5s ease-in-out",
|
||||||
|
opacity: getOpacity(pageType),
|
||||||
|
position: pageType === "home" ? "relative" : "absolute",
|
||||||
|
top: pageType !== "home" ? 0 : "auto",
|
||||||
|
left: pageType !== "home" ? 0 : "auto",
|
||||||
|
transform: "none",
|
||||||
|
width: "100%",
|
||||||
|
pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none",
|
||||||
|
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" },
|
||||||
|
wordWrap: "break-word",
|
||||||
|
hyphens: "auto"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Navigation container for portrait phones */}
|
||||||
|
<Box sx={{
|
||||||
|
display: { xs: "flex", sm: "contents" },
|
||||||
|
width: { xs: "100%", sm: "auto" },
|
||||||
|
justifyContent: { xs: "space-between", sm: "initial" },
|
||||||
|
alignItems: "center"
|
||||||
|
}}>
|
||||||
|
{/* Left Navigation - Layered rendering */}
|
||||||
|
<Box sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
position: "relative",
|
||||||
|
mr: { xs: 0, sm: 2 }
|
||||||
|
}}>
|
||||||
|
{navTexts.map((navItem, index) => {
|
||||||
|
const isActive = navConfig.leftNav && navConfig.leftNav.text === navItem.text;
|
||||||
|
const link = navItem.link;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={navItem.key}
|
||||||
|
component={Link}
|
||||||
|
to={link}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
textDecoration: "none",
|
||||||
|
color: "inherit",
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
opacity: isActive ? 1 : 0,
|
||||||
|
position: index === 0 ? "relative" : "absolute",
|
||||||
|
left: index !== 0 ? 0 : "auto",
|
||||||
|
pointerEvents: isActive ? "auto" : "none",
|
||||||
|
"&:hover": {
|
||||||
|
transform: "translateX(-5px)",
|
||||||
|
color: "primary.main"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft sx={{ fontSize: "2rem", mr: 1 }} />
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: "SwashingtonCP",
|
||||||
|
fontSize: { xs: "1.25rem", sm: "1.25rem", md: "2.125rem" },
|
||||||
|
textShadow: "2px 2px 4px rgba(0, 0, 0, 0.3)",
|
||||||
|
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" },
|
||||||
|
whiteSpace: "nowrap"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{navItem.text}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Center Title - Layered rendering - Hidden on portrait phones, shown on larger screens */}
|
||||||
|
<Box sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: { xs: "none", sm: "flex" },
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
px: 0,
|
||||||
|
position: "relative",
|
||||||
|
minWidth: 0
|
||||||
|
}}>
|
||||||
|
{Object.entries(allTitles).map(([pageType, title]) => (
|
||||||
|
<Typography
|
||||||
|
key={pageType}
|
||||||
|
variant="h3"
|
||||||
|
component="h1"
|
||||||
|
sx={{
|
||||||
|
fontFamily: "SwashingtonCP",
|
||||||
|
fontSize: { xs: "2.125rem", sm: "2.125rem", md: "3rem" },
|
||||||
|
textAlign: "center",
|
||||||
|
color: "primary.main",
|
||||||
|
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)",
|
||||||
|
transition: "opacity 0.5s ease-in-out",
|
||||||
|
opacity: getOpacity(pageType),
|
||||||
|
position: pageType === "home" ? "relative" : "absolute",
|
||||||
|
top: pageType !== "home" ? "50%" : "auto",
|
||||||
|
left: pageType !== "home" ? "50%" : "auto",
|
||||||
|
transform: pageType !== "home" ? "translate(-50%, -50%)" : "none",
|
||||||
|
width: "100%",
|
||||||
|
pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none",
|
||||||
|
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" },
|
||||||
|
wordWrap: "break-word",
|
||||||
|
hyphens: "auto"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Right Navigation - Layered rendering */}
|
||||||
|
<Box sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
position: "relative",
|
||||||
|
ml: { xs: 0, sm: 2 }
|
||||||
|
}}>
|
||||||
|
{navTexts.map((navItem, index) => {
|
||||||
|
const isActive = navConfig.rightNav && navConfig.rightNav.text === navItem.text;
|
||||||
|
const link = navItem.link;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={navItem.key}
|
||||||
|
component={Link}
|
||||||
|
to={link}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
textDecoration: "none",
|
||||||
|
color: "inherit",
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
opacity: isActive ? 1 : 0,
|
||||||
|
position: index === 0 ? "relative" : "absolute",
|
||||||
|
right: index !== 0 ? 0 : "auto",
|
||||||
|
pointerEvents: isActive ? "auto" : "none",
|
||||||
|
"&:hover": {
|
||||||
|
transform: "translateX(5px)",
|
||||||
|
color: "primary.main"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: "SwashingtonCP",
|
||||||
|
fontSize: { xs: "1.25rem", sm: "1.25rem", md: "2.125rem" },
|
||||||
|
textShadow: "2px 2px 4px rgba(0, 0, 0, 0.3)",
|
||||||
|
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" },
|
||||||
|
whiteSpace: "nowrap"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{navItem.text}
|
||||||
|
</Typography>
|
||||||
|
<ChevronRight sx={{ fontSize: "2rem", ml: 1 }} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Content Boxes - Layered rendering */}
|
||||||
|
<Box sx={{ position: "relative", mb: 4 }}>
|
||||||
|
{Object.entries(allContentBoxes).map(([pageType, contentBoxes]) => (
|
||||||
|
<Grid
|
||||||
|
key={pageType}
|
||||||
|
container
|
||||||
|
spacing={0}
|
||||||
|
sx={{
|
||||||
|
transition: "opacity 0.5s ease-in-out",
|
||||||
|
opacity: getOpacity(pageType),
|
||||||
|
position: pageType === "home" ? "relative" : "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{contentBoxes.map((box, index) => (
|
||||||
|
<Grid key={`${pageType}-${index}`} item xs={12} sm={6} sx={{ p: 2, width: "50%", position: 'relative' }}>
|
||||||
|
{/* Multi-pointed star for seeds box - moved to Grid level */}
|
||||||
|
{index === 0 && pageType === "filiale" && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-55px',
|
||||||
|
left: '-45px',
|
||||||
|
width: '180px',
|
||||||
|
height: '180px',
|
||||||
|
zIndex: 999,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
cursor: 'pointer',
|
||||||
|
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' }
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setStarHovered(true)}
|
||||||
|
onMouseLeave={() => setStarHovered(false)}
|
||||||
|
>
|
||||||
|
{/* Background star - slightly larger and rotated */}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 60 60"
|
||||||
|
width="168"
|
||||||
|
height="168"
|
||||||
|
className="star-rotate-slow-cw"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-9px',
|
||||||
|
left: '-9px',
|
||||||
|
transform: 'rotate(20deg)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||||
|
fill="#B8860B"
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Middle star - medium size with different rotation */}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 60 60"
|
||||||
|
width="159"
|
||||||
|
height="159"
|
||||||
|
className="star-rotate-slow-ccw"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-4.5px',
|
||||||
|
left: '-4.5px',
|
||||||
|
transform: 'rotate(-25deg)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||||
|
fill="#DAA520"
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Foreground star - main star with text */}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 60 60"
|
||||||
|
width="150"
|
||||||
|
height="150"
|
||||||
|
className="star-rotate-medium-cw"
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||||
|
fill="#FFD700"
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Text positioned in the center of the star */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '45%',
|
||||||
|
left: '43%',
|
||||||
|
transform: 'translate(-50%, -50%) rotate(-10deg)',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: '900',
|
||||||
|
fontSize: '20px',
|
||||||
|
textShadow: '0px 3px 6px rgba(0,0,0,0.5)',
|
||||||
|
zIndex: 1000,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: '1.1',
|
||||||
|
width: '135px',
|
||||||
|
transition: 'opacity 0.3s ease',
|
||||||
|
opacity: starHovered ? 0 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('sections.showUsPhoto')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover text */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '45%',
|
||||||
|
left: '43%',
|
||||||
|
transform: 'translate(-50%, -50%) rotate(-10deg)',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: '900',
|
||||||
|
fontSize: '20px',
|
||||||
|
textShadow: '0px 3px 6px rgba(0,0,0,0.5)',
|
||||||
|
zIndex: 1000,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: '1.1',
|
||||||
|
width: '135px',
|
||||||
|
opacity: starHovered ? 1 : 0,
|
||||||
|
transition: 'opacity 0.3s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('sections.selectSeedRate')}
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Multi-pointed star for stecklinge box - bottom right */}
|
||||||
|
{index === 1 && pageType === "filiale" && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '-45px',
|
||||||
|
right: '-65px',
|
||||||
|
width: '180px',
|
||||||
|
height: '180px',
|
||||||
|
zIndex: 999,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
cursor: 'pointer',
|
||||||
|
filter: 'drop-shadow(6px 6px 12px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 20px rgba(175, 238, 238, 0.8)) drop-shadow(0 0 40px rgba(175, 238, 238, 0.4))',
|
||||||
|
display: { xs: 'none', sm: 'block' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background star - slightly larger and rotated */}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 60 60"
|
||||||
|
width="168"
|
||||||
|
height="168"
|
||||||
|
className="star-rotate-slow-ccw"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-9px',
|
||||||
|
left: '-9px',
|
||||||
|
transform: 'rotate(20deg)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||||
|
fill="#5F9EA0"
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Middle star - medium size with different rotation */}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 60 60"
|
||||||
|
width="159"
|
||||||
|
height="159"
|
||||||
|
className="star-rotate-medium-cw"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-4.5px',
|
||||||
|
left: '-4.5px',
|
||||||
|
transform: 'rotate(-25deg)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||||
|
fill="#7FCDCD"
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Foreground star - main star with text */}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 60 60"
|
||||||
|
width="150"
|
||||||
|
height="150"
|
||||||
|
className="star-rotate-slow-cw"
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||||
|
fill="#AFEEEE"
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Text positioned in the center of the star */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '42%',
|
||||||
|
left: '45%',
|
||||||
|
transform: 'translate(-50%, -50%) rotate(10deg)',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: '900',
|
||||||
|
fontSize: '20px',
|
||||||
|
textShadow: '0px 3px 6px rgba(0,0,0,0.5)',
|
||||||
|
zIndex: 1000,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: '1.1',
|
||||||
|
width: '135px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('sections.indoorSeason')}
|
||||||
|
</div>
|
||||||
|
</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,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: "100%",
|
||||||
|
bgcolor: box.bgcolor,
|
||||||
|
backgroundImage: `url("${box.image}")`,
|
||||||
|
backgroundSize: "contain",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bgcolor: "rgba(27, 94, 32, 0.8)",
|
||||||
|
p: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: "1.6rem",
|
||||||
|
color: "white",
|
||||||
|
fontFamily: "SwashingtonCP",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{box.title}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</div>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Shared Carousel */}
|
||||||
|
<SharedCarousel />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainPageLayout;
|
||||||
168
src/components/PaymentSuccess.js
Normal file
168
src/components/PaymentSuccess.js
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||||
|
import SocketContext from '../contexts/SocketContext.js';
|
||||||
|
|
||||||
|
class PaymentSuccess extends Component {
|
||||||
|
static contextType = SocketContext;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
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) => {
|
||||||
|
const { socket } = this.context;
|
||||||
|
|
||||||
|
if (!socket || !socket.connected) {
|
||||||
|
console.error('Socket not connected');
|
||||||
|
this.setState({
|
||||||
|
redirectUrl: '/profile#cart',
|
||||||
|
processing: false,
|
||||||
|
error: 'Connection error'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit('checkMollieIntent', { paymentId }, (response) => {
|
||||||
|
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
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,
|
||||||
|
CloudUpload
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
|
||||||
|
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: `Maximal ${maxFiles} Dateien erlaubt`
|
||||||
|
});
|
||||||
|
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: 'Nur Bilddateien (JPEG, PNG, GIF, WebP) sind erlaubt'
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
this.setState({
|
||||||
|
error: `Datei zu groß. Maximum: ${Math.round(maxSize / (1024 * 1024))}MB`
|
||||||
|
});
|
||||||
|
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 } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
|
||||||
|
{label || 'Fotos anhängen (optional)'}
|
||||||
|
</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 }}
|
||||||
|
>
|
||||||
|
Fotos auswählen
|
||||||
|
</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}
|
||||||
|
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' }}>
|
||||||
|
{files.length} Datei(en) ausgewählt
|
||||||
|
{previews.length > 0 && previews.some(p => p.originalSize && p.compressedSize) && (
|
||||||
|
<span style={{ marginLeft: '8px' }}>
|
||||||
|
(komprimiert für Upload)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PhotoUpload;
|
||||||
@@ -8,6 +8,7 @@ import CircularProgress from '@mui/material/CircularProgress';
|
|||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import AddToCartButton from './AddToCartButton.js';
|
import AddToCartButton from './AddToCartButton.js';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { withI18n } from '../i18n/withTranslation.js';
|
||||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||||
|
|
||||||
class Product extends Component {
|
class Product extends Component {
|
||||||
@@ -27,6 +28,43 @@ class Product extends Component {
|
|||||||
}else{
|
}else{
|
||||||
this.state = {image: null, loading: true, error: false};
|
this.state = {image: null, loading: true, error: false};
|
||||||
console.log("Product: Fetching image from socketB", this.props.socketB);
|
console.log("Product: Fetching image from socketB", this.props.socketB);
|
||||||
|
|
||||||
|
// Check if socketB is available and connected before emitting
|
||||||
|
if (this.props.socketB && this.props.socketB.connected) {
|
||||||
|
this.loadImage(bildId);
|
||||||
|
} else {
|
||||||
|
// Socket not available, set error state or wait
|
||||||
|
console.log("Product: socketB not available, will retry when connected");
|
||||||
|
this.state.error = true;
|
||||||
|
this.state.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
this.state = {image: null, loading: false, error: false};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this._isMounted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
// Retry loading image if socket just became available
|
||||||
|
const wasConnected = prevProps.socketB && prevProps.socketB.connected;
|
||||||
|
const isNowConnected = this.props.socketB && this.props.socketB.connected;
|
||||||
|
|
||||||
|
if (!wasConnected && isNowConnected && this.state.error && this.props.pictureList) {
|
||||||
|
// Socket just connected and we had an error, retry loading
|
||||||
|
const bildId = this.props.pictureList.split(',')[0];
|
||||||
|
if (!window.smallPicCache[bildId]) {
|
||||||
|
this.setState({loading: true, error: false});
|
||||||
|
this.loadImage(bildId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadImage = (bildId) => {
|
||||||
|
if (this.props.socketB && this.props.socketB.connected) {
|
||||||
this.props.socketB.emit('getPic', { bildId, size:'small' }, (res) => {
|
this.props.socketB.emit('getPic', { bildId, size:'small' }, (res) => {
|
||||||
if(res.success){
|
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/jpeg' }));
|
||||||
@@ -45,15 +83,8 @@ class Product extends Component {
|
|||||||
this.state.loading = false;
|
this.state.loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}else{
|
|
||||||
this.state = {image: null, loading: false, error: false};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this._isMounted = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
@@ -68,8 +99,8 @@ class Product extends Component {
|
|||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
id, name, price, available, manufacturer, seoName,
|
id, name, price, available, manufacturer, seoName,
|
||||||
currency, vat, massMenge, massEinheit, thc,
|
currency, vat, cGrundEinheit, fGrundPreis, thc,
|
||||||
floweringWeeks,incoming, neu, weight, versandklasse, availableSupplier
|
floweringWeeks,incoming, neu, weight, versandklasse, availableSupplier, komponenten
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const isNew = neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
const isNew = neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||||
@@ -173,7 +204,7 @@ class Product extends Component {
|
|||||||
zIndex: 1000
|
zIndex: 1000
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
NEU
|
{this.props.t ? this.props.t('product.new') : 'NEU'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -240,7 +271,7 @@ class Product extends Component {
|
|||||||
transformOrigin: 'top left'
|
transformOrigin: 'top left'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{floweringWeeks} Wochen
|
{floweringWeeks} {this.props.t ? this.props.t('product.weeks') : 'Wochen'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -336,13 +367,13 @@ class Product extends Component {
|
|||||||
sx={{ mt: 'auto', fontWeight: 'bold', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
|
sx={{ mt: 'auto', fontWeight: 'bold', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
|
||||||
>
|
>
|
||||||
<span>{new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price)}</span>
|
<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>
|
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>({this.props.t ? this.props.t('product.inclVatFooter', { vat }) : `incl. ${vat}% USt.,*`})</small>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</Typography>
|
</Typography>
|
||||||
{massMenge != 1 && massEinheit && (<Typography variant="body2" color="text.secondary" sx={{ m: 0,p: 0 }}>
|
{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(price/massMenge)}/{massEinheit})
|
({new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(fGrundPreis)}/{cGrundEinheit})
|
||||||
</Typography> )}
|
</Typography> )}
|
||||||
</div>
|
</div>
|
||||||
{/*incoming*/}
|
{/*incoming*/}
|
||||||
@@ -358,7 +389,7 @@ class Product extends Component {
|
|||||||
>
|
>
|
||||||
<ZoomInIcon />
|
<ZoomInIcon />
|
||||||
</IconButton>
|
</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>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -366,4 +397,4 @@ class Product extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Product;
|
export default withI18n()(Product);
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import React, { Component } from "react";
|
import React, { Component } from "react";
|
||||||
import { Box, Typography, CardMedia, Stack, Chip } from "@mui/material";
|
import { Box, Container, Typography, CardMedia, Stack, Chip, Button, Collapse } from "@mui/material";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import parse from "html-react-parser";
|
import parse from "html-react-parser";
|
||||||
import AddToCartButton from "./AddToCartButton.js";
|
import AddToCartButton from "./AddToCartButton.js";
|
||||||
import Images from "./Images.js";
|
import ProductImage from "./ProductImage.js";
|
||||||
|
import { withI18n } from "../i18n/withTranslation.js";
|
||||||
|
import ArticleQuestionForm from "./ArticleQuestionForm.js";
|
||||||
|
import ArticleRatingForm from "./ArticleRatingForm.js";
|
||||||
|
import ArticleAvailabilityForm from "./ArticleAvailabilityForm.js";
|
||||||
|
|
||||||
// Utility function to clean product names by removing trailing number in parentheses
|
// Utility function to clean product names by removing trailing number in parentheses
|
||||||
const cleanProductName = (name) => {
|
const cleanProductName = (name) => {
|
||||||
@@ -21,14 +25,42 @@ class ProductDetailPage extends Component {
|
|||||||
window.productDetailCache &&
|
window.productDetailCache &&
|
||||||
window.productDetailCache[this.props.seoName]
|
window.productDetailCache[this.props.seoName]
|
||||||
) {
|
) {
|
||||||
|
const cachedData = window.productDetailCache[this.props.seoName];
|
||||||
|
|
||||||
|
// Clean up prerender fallback since we have cached data
|
||||||
|
if (typeof window !== "undefined" && window.__PRERENDER_FALLBACK__) {
|
||||||
|
delete window.__PRERENDER_FALLBACK__;
|
||||||
|
console.log("ProductDetailPage: Cleaned up prerender fallback using cached product data");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize komponenten from cached product data
|
||||||
|
const komponenten = [];
|
||||||
|
if(cachedData.product.komponenten) {
|
||||||
|
for(const komponent of cachedData.product.komponenten.split(",")) {
|
||||||
|
// Handle both "x" and "×" as separators
|
||||||
|
const [id, count] = komponent.split(/[x×]/);
|
||||||
|
komponenten.push({id: id.trim(), count: count.trim()});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
product: window.productDetailCache[this.props.seoName],
|
product: cachedData.product,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
attributeImages: {},
|
attributeImages: {},
|
||||||
attributes: [],
|
attributes: cachedData.attributes || [],
|
||||||
isSteckling: false,
|
isSteckling: false,
|
||||||
imageDialogOpen: false,
|
imageDialogOpen: false,
|
||||||
|
komponenten: komponenten,
|
||||||
|
komponentenLoaded: komponenten.length === 0, // If no komponenten, mark as loaded
|
||||||
|
komponentenData: {}, // Store individual komponent data with loading states
|
||||||
|
komponentenImages: {}, // Store tiny pictures for komponenten
|
||||||
|
totalKomponentenPrice: 0,
|
||||||
|
totalSavings: 0,
|
||||||
|
// Collapsible sections state
|
||||||
|
showQuestionForm: false,
|
||||||
|
showRatingForm: false,
|
||||||
|
showAvailabilityForm: false
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -39,12 +71,32 @@ class ProductDetailPage extends Component {
|
|||||||
attributes: [],
|
attributes: [],
|
||||||
isSteckling: false,
|
isSteckling: false,
|
||||||
imageDialogOpen: false,
|
imageDialogOpen: false,
|
||||||
|
komponenten: [],
|
||||||
|
komponentenLoaded: false,
|
||||||
|
komponentenData: {}, // Store individual komponent data with loading states
|
||||||
|
komponentenImages: {}, // Store tiny pictures for komponenten
|
||||||
|
totalKomponentenPrice: 0,
|
||||||
|
totalSavings: 0,
|
||||||
|
// Collapsible sections state
|
||||||
|
showQuestionForm: false,
|
||||||
|
showRatingForm: false,
|
||||||
|
showAvailabilityForm: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
// Only load product data if not already cached
|
||||||
|
if (!this.state.product) {
|
||||||
this.loadProductData();
|
this.loadProductData();
|
||||||
|
} else {
|
||||||
|
// Product is cached, but we still need to load komponenten if they exist
|
||||||
|
if (this.state.komponenten.length > 0 && !this.state.komponentenLoaded) {
|
||||||
|
for(const komponent of this.state.komponenten) {
|
||||||
|
this.loadKomponent(komponent.id, komponent.count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
@@ -64,11 +116,252 @@ class ProductDetailPage extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadKomponentImage = (komponentId, pictureList) => {
|
||||||
|
// Initialize cache if it doesn't exist
|
||||||
|
if (!window.smallPicCache) {
|
||||||
|
window.smallPicCache = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if no pictureList
|
||||||
|
if (!pictureList || pictureList.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first image ID from pictureList
|
||||||
|
const bildId = pictureList.split(',')[0];
|
||||||
|
|
||||||
|
// Check if already cached
|
||||||
|
if (window.smallPicCache[bildId]) {
|
||||||
|
this.setState(prevState => ({
|
||||||
|
komponentenImages: {
|
||||||
|
...prevState.komponentenImages,
|
||||||
|
[komponentId]: window.smallPicCache[bildId]
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if socketB is available
|
||||||
|
if (!this.props.socketB || !this.props.socketB.connected) {
|
||||||
|
console.log("SocketB not connected yet, skipping image load for komponent:", komponentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch image from server
|
||||||
|
this.props.socketB.emit('getPic', { bildId, size: 'small' }, (res) => {
|
||||||
|
if (res.success) {
|
||||||
|
// Cache the image
|
||||||
|
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
this.setState(prevState => ({
|
||||||
|
komponentenImages: {
|
||||||
|
...prevState.komponentenImages,
|
||||||
|
[komponentId]: window.smallPicCache[bildId]
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
console.log('Error loading komponent image:', res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadKomponent = (id, count) => {
|
||||||
|
// Initialize cache if it doesn't exist
|
||||||
|
if (!window.productDetailCache) {
|
||||||
|
window.productDetailCache = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this komponent is already cached
|
||||||
|
if (window.productDetailCache[id]) {
|
||||||
|
const cachedProduct = window.productDetailCache[id];
|
||||||
|
|
||||||
|
// Load komponent image if available
|
||||||
|
if (cachedProduct.pictureList) {
|
||||||
|
this.loadKomponentImage(id, cachedProduct.pictureList);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state with cached data
|
||||||
|
this.setState(prevState => {
|
||||||
|
const newKomponentenData = {
|
||||||
|
...prevState.komponentenData,
|
||||||
|
[id]: {
|
||||||
|
...cachedProduct,
|
||||||
|
count: parseInt(count),
|
||||||
|
loaded: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if all remaining komponenten are loaded
|
||||||
|
const allLoaded = prevState.komponenten.every(k =>
|
||||||
|
newKomponentenData[k.id] && newKomponentenData[k.id].loaded
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate totals if all loaded
|
||||||
|
let totalKomponentenPrice = 0;
|
||||||
|
let totalSavings = 0;
|
||||||
|
|
||||||
|
if (allLoaded) {
|
||||||
|
totalKomponentenPrice = prevState.komponenten.reduce((sum, k) => {
|
||||||
|
const komponentData = newKomponentenData[k.id];
|
||||||
|
if (komponentData && komponentData.loaded) {
|
||||||
|
return sum + (komponentData.price * parseInt(k.count));
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Calculate savings (difference between buying individually vs as set)
|
||||||
|
const setPrice = prevState.product ? prevState.product.price : 0;
|
||||||
|
totalSavings = Math.max(0, totalKomponentenPrice - setPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Cached komponent loaded:", id, "data:", newKomponentenData[id]);
|
||||||
|
console.log("All loaded (cached):", allLoaded);
|
||||||
|
|
||||||
|
return {
|
||||||
|
komponentenData: newKomponentenData,
|
||||||
|
komponentenLoaded: allLoaded,
|
||||||
|
totalKomponentenPrice,
|
||||||
|
totalSavings
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not cached, fetch from server (similar to loadProductData)
|
||||||
|
if (!this.props.socket || !this.props.socket.connected) {
|
||||||
|
console.log("Socket not connected yet, waiting for connection to load komponent data");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark this komponent as loading
|
||||||
|
this.setState(prevState => ({
|
||||||
|
komponentenData: {
|
||||||
|
...prevState.komponentenData,
|
||||||
|
[id]: {
|
||||||
|
...prevState.komponentenData[id],
|
||||||
|
loading: true,
|
||||||
|
loaded: false,
|
||||||
|
count: parseInt(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.props.socket.emit(
|
||||||
|
"getProductView",
|
||||||
|
{ articleId: id },
|
||||||
|
(res) => {
|
||||||
|
if (res.success) {
|
||||||
|
// Cache the successful response
|
||||||
|
window.productDetailCache[id] = res.product;
|
||||||
|
|
||||||
|
// Load komponent image if available
|
||||||
|
if (res.product.pictureList) {
|
||||||
|
this.loadKomponentImage(id, res.product.pictureList);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state with loaded data
|
||||||
|
this.setState(prevState => {
|
||||||
|
const newKomponentenData = {
|
||||||
|
...prevState.komponentenData,
|
||||||
|
[id]: {
|
||||||
|
...res.product,
|
||||||
|
count: parseInt(count),
|
||||||
|
loading: false,
|
||||||
|
loaded: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if all remaining komponenten are loaded
|
||||||
|
const allLoaded = prevState.komponenten.every(k =>
|
||||||
|
newKomponentenData[k.id] && newKomponentenData[k.id].loaded
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate totals if all loaded
|
||||||
|
let totalKomponentenPrice = 0;
|
||||||
|
let totalSavings = 0;
|
||||||
|
|
||||||
|
if (allLoaded) {
|
||||||
|
totalKomponentenPrice = prevState.komponenten.reduce((sum, k) => {
|
||||||
|
const komponentData = newKomponentenData[k.id];
|
||||||
|
if (komponentData && komponentData.loaded) {
|
||||||
|
return sum + (komponentData.price * parseInt(k.count));
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Calculate savings (difference between buying individually vs as set)
|
||||||
|
const setPrice = prevState.product ? prevState.product.price : 0;
|
||||||
|
totalSavings = Math.max(0, totalKomponentenPrice - setPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Updated komponentenData for", id, ":", newKomponentenData[id]);
|
||||||
|
console.log("All loaded:", allLoaded);
|
||||||
|
|
||||||
|
return {
|
||||||
|
komponentenData: newKomponentenData,
|
||||||
|
komponentenLoaded: allLoaded,
|
||||||
|
totalKomponentenPrice,
|
||||||
|
totalSavings
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("getProductView (komponent)", res);
|
||||||
|
} else {
|
||||||
|
console.error("Error loading komponent:", res.error || "Unknown error", res);
|
||||||
|
|
||||||
|
// Remove failed komponent from the list and check if all remaining are loaded
|
||||||
|
this.setState(prevState => {
|
||||||
|
const newKomponenten = prevState.komponenten.filter(k => k.id !== id);
|
||||||
|
const newKomponentenData = { ...prevState.komponentenData };
|
||||||
|
|
||||||
|
// Remove failed komponent from data
|
||||||
|
delete newKomponentenData[id];
|
||||||
|
|
||||||
|
// Check if all remaining komponenten are loaded
|
||||||
|
const allLoaded = newKomponenten.length === 0 || newKomponenten.every(k =>
|
||||||
|
newKomponentenData[k.id] && newKomponentenData[k.id].loaded
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate totals if all loaded
|
||||||
|
let totalKomponentenPrice = 0;
|
||||||
|
let totalSavings = 0;
|
||||||
|
|
||||||
|
if (allLoaded && newKomponenten.length > 0) {
|
||||||
|
totalKomponentenPrice = newKomponenten.reduce((sum, k) => {
|
||||||
|
const komponentData = newKomponentenData[k.id];
|
||||||
|
if (komponentData && komponentData.loaded) {
|
||||||
|
return sum + (komponentData.price * parseInt(k.count));
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Calculate savings (difference between buying individually vs as set)
|
||||||
|
const setPrice = this.state.product ? this.state.product.price : 0;
|
||||||
|
totalSavings = Math.max(0, totalKomponentenPrice - setPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Removed failed komponent:", id, "remaining:", newKomponenten.length);
|
||||||
|
console.log("All loaded after removal:", allLoaded);
|
||||||
|
|
||||||
|
return {
|
||||||
|
komponenten: newKomponenten,
|
||||||
|
komponentenData: newKomponentenData,
|
||||||
|
komponentenLoaded: allLoaded,
|
||||||
|
totalKomponentenPrice,
|
||||||
|
totalSavings
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
loadProductData = () => {
|
loadProductData = () => {
|
||||||
if (!this.props.socket || !this.props.socket.connected) {
|
if (!this.props.socket || !this.props.socket.connected) {
|
||||||
// Socket not connected yet, but don't show error immediately on first load
|
// Socket not connected yet, but don't show error immediately on first load
|
||||||
// The componentDidUpdate will retry when socket connects
|
// The componentDidUpdate will retry when socket connects
|
||||||
console.log("Socket not connected yet, waiting for connection to load product data");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,12 +371,43 @@ class ProductDetailPage extends Component {
|
|||||||
(res) => {
|
(res) => {
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
res.product.seoName = this.props.seoName;
|
res.product.seoName = this.props.seoName;
|
||||||
|
|
||||||
|
// Initialize cache if it doesn't exist
|
||||||
|
if (!window.productDetailCache) {
|
||||||
|
window.productDetailCache = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the complete response data (product + attributes)
|
||||||
|
window.productDetailCache[this.props.seoName] = res;
|
||||||
|
|
||||||
|
// Clean up prerender fallback since we now have real data
|
||||||
|
if (typeof window !== "undefined" && window.__PRERENDER_FALLBACK__) {
|
||||||
|
delete window.__PRERENDER_FALLBACK__;
|
||||||
|
console.log("ProductDetailPage: Cleaned up prerender fallback after loading product data");
|
||||||
|
}
|
||||||
|
|
||||||
|
const komponenten = [];
|
||||||
|
if(res.product.komponenten) {
|
||||||
|
for(const komponent of res.product.komponenten.split(",")) {
|
||||||
|
// Handle both "x" and "×" as separators
|
||||||
|
const [id, count] = komponent.split(/[x×]/);
|
||||||
|
komponenten.push({id: id.trim(), count: count.trim()});
|
||||||
|
}
|
||||||
|
}
|
||||||
this.setState({
|
this.setState({
|
||||||
product: res.product,
|
product: res.product,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
imageDialogOpen: false,
|
imageDialogOpen: false,
|
||||||
attributes: res.attributes
|
attributes: res.attributes,
|
||||||
|
komponenten: komponenten,
|
||||||
|
komponentenLoaded: komponenten.length === 0 // If no komponenten, mark as loaded
|
||||||
|
}, () => {
|
||||||
|
if(komponenten.length > 0) {
|
||||||
|
for(const komponent of komponenten) {
|
||||||
|
this.loadKomponent(komponent.id, komponent.count);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
console.log("getProductView", res);
|
console.log("getProductView", res);
|
||||||
|
|
||||||
@@ -179,11 +503,73 @@ class ProductDetailPage extends Component {
|
|||||||
this.setState({ imageDialogOpen: false });
|
this.setState({ imageDialogOpen: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
toggleQuestionForm = () => {
|
||||||
|
this.setState(prevState => ({
|
||||||
|
showQuestionForm: !prevState.showQuestionForm,
|
||||||
|
showRatingForm: false,
|
||||||
|
showAvailabilityForm: false
|
||||||
|
}), () => {
|
||||||
|
if (this.state.showQuestionForm) {
|
||||||
|
setTimeout(() => this.scrollToSection('question-form'), 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
toggleRatingForm = () => {
|
||||||
|
this.setState(prevState => ({
|
||||||
|
showRatingForm: !prevState.showRatingForm,
|
||||||
|
showQuestionForm: false,
|
||||||
|
showAvailabilityForm: false
|
||||||
|
}), () => {
|
||||||
|
if (this.state.showRatingForm) {
|
||||||
|
setTimeout(() => this.scrollToSection('rating-form'), 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
toggleAvailabilityForm = () => {
|
||||||
|
this.setState(prevState => ({
|
||||||
|
showAvailabilityForm: !prevState.showAvailabilityForm,
|
||||||
|
showQuestionForm: false,
|
||||||
|
showRatingForm: false
|
||||||
|
}), () => {
|
||||||
|
if (this.state.showAvailabilityForm) {
|
||||||
|
setTimeout(() => this.scrollToSection('availability-form'), 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollToSection = (sectionId) => {
|
||||||
|
const element = document.getElementById(sectionId);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { product, loading, error, attributeImages, isSteckling, attributes } =
|
const { product, loading, error, attributeImages, isSteckling, attributes, komponentenLoaded, komponentenData, komponentenImages, totalKomponentenPrice, totalSavings } =
|
||||||
this.state;
|
this.state;
|
||||||
|
|
||||||
|
// Debug alerts removed
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
// Check if prerender fallback is available
|
||||||
|
if (typeof window !== "undefined" && window.__PRERENDER_FALLBACK__) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: window.__PRERENDER_FALLBACK__.content,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to loading message if no prerender content
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -211,7 +597,7 @@ class ProductDetailPage extends Component {
|
|||||||
<Typography>{error}</Typography>
|
<Typography>{error}</Typography>
|
||||||
<Link to="/" style={{ textDecoration: "none" }}>
|
<Link to="/" style={{ textDecoration: "none" }}>
|
||||||
<Typography color="primary" sx={{ mt: 2 }}>
|
<Typography color="primary" sx={{ mt: 2 }}>
|
||||||
Zurück zur Startseite
|
{this.props.t ? this.props.t('product.backToHome') : 'Zurück zur Startseite'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Link>
|
</Link>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -229,7 +615,7 @@ class ProductDetailPage extends Component {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Link to="/" style={{ textDecoration: "none" }}>
|
<Link to="/" style={{ textDecoration: "none" }}>
|
||||||
<Typography color="primary" sx={{ mt: 2 }}>
|
<Typography color="primary" sx={{ mt: 2 }}>
|
||||||
Zurück zur Startseite
|
{this.props.t ? this.props.t('product.backToHome') : 'Zurück zur Startseite'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Link>
|
</Link>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -242,12 +628,12 @@ class ProductDetailPage extends Component {
|
|||||||
}).format(product.price);
|
}).format(product.price);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Container
|
||||||
|
maxWidth="lg"
|
||||||
sx={{
|
sx={{
|
||||||
p: { xs: 2, md: 2 },
|
p: { xs: 2, md: 2 },
|
||||||
pb: { xs: 4, md: 8 },
|
pb: { xs: 4, md: 8 },
|
||||||
maxWidth: "1400px",
|
flexGrow: 1
|
||||||
mx: "auto",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Breadcrumbs */}
|
{/* Breadcrumbs */}
|
||||||
@@ -294,7 +680,7 @@ class ProductDetailPage extends Component {
|
|||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Zurück
|
{this.props.t ? this.props.t('common.back') : 'Zurück'}
|
||||||
</Link>
|
</Link>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -310,38 +696,14 @@ class ProductDetailPage extends Component {
|
|||||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<ProductImage
|
||||||
sx={{
|
product={product}
|
||||||
width: { xs: "100%", sm: "555px" },
|
|
||||||
maxWidth: "100%",
|
|
||||||
minHeight: "400px",
|
|
||||||
background: "#f8f8f8",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!product.pictureList && (
|
|
||||||
<CardMedia
|
|
||||||
component="img"
|
|
||||||
height="400"
|
|
||||||
image="/assets/images/nopicture.jpg"
|
|
||||||
alt={product.name}
|
|
||||||
sx={{ objectFit: "cover" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{product.pictureList && (
|
|
||||||
<Images
|
|
||||||
socket={this.props.socket}
|
socket={this.props.socket}
|
||||||
socketB={this.props.socketB}
|
socketB={this.props.socketB}
|
||||||
pictureList={product.pictureList}
|
|
||||||
fullscreenOpen={this.state.imageDialogOpen}
|
fullscreenOpen={this.state.imageDialogOpen}
|
||||||
onOpenFullscreen={this.handleOpenDialog}
|
onOpenFullscreen={this.handleOpenDialog}
|
||||||
onCloseFullscreen={this.handleCloseDialog}
|
onCloseFullscreen={this.handleCloseDialog}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Product Details */}
|
{/* Product Details */}
|
||||||
<Box
|
<Box
|
||||||
@@ -355,7 +717,7 @@ class ProductDetailPage extends Component {
|
|||||||
{/* Product identifiers */}
|
{/* Product identifiers */}
|
||||||
<Box sx={{ mb: 1 }}>
|
<Box sx={{ mb: 1 }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Artikelnummer: {product.articleNumber} {product.gtin ? ` | GTIN: ${product.gtin}` : ""}
|
{this.props.t ? this.props.t('product.articleNumber') : 'Artikelnummer'}: {product.articleNumber} {product.gtin ? ` | GTIN: ${product.gtin}` : ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -373,31 +735,26 @@ class ProductDetailPage extends Component {
|
|||||||
{product.manufacturer && (
|
{product.manufacturer && (
|
||||||
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
|
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
|
||||||
<Typography variant="body2" sx={{ fontStyle: "italic" }}>
|
<Typography variant="body2" sx={{ fontStyle: "italic" }}>
|
||||||
Hersteller: {product.manufacturer}
|
{this.props.t ? this.props.t('product.manufacturer') : 'Hersteller'}: {product.manufacturer}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Attribute images and chips */}
|
{/* Attribute images and chips with action buttons */}
|
||||||
{(attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert])) && (
|
{(attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert])) && (
|
||||||
<Stack direction="row" spacing={2} sx={{ flexWrap: "wrap", gap: 1, mb: 2 }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2, gap: 2 }}>
|
||||||
|
<Stack direction="row" spacing={2} sx={{ flexWrap: "wrap", gap: 1, flex: 1 }}>
|
||||||
{attributes
|
{attributes
|
||||||
.filter(attribute => attributeImages[attribute.kMerkmalWert])
|
.filter(attribute => attributeImages[attribute.kMerkmalWert])
|
||||||
.map((attribute) => {
|
.map((attribute) => {
|
||||||
const key = attribute.kMerkmalWert;
|
const key = attribute.kMerkmalWert;
|
||||||
return (
|
return (
|
||||||
<Box key={key} sx={{ mb: 1 }}>
|
<Box key={key} sx={{ mb: 1,border: "1px solid #e0e0e0", borderRadius: 1 }}>
|
||||||
<CardMedia
|
<CardMedia
|
||||||
component="img"
|
component="img"
|
||||||
|
style={{ width: "72px", height: "98px" }}
|
||||||
image={attributeImages[key]}
|
image={attributeImages[key]}
|
||||||
alt={`Attribute ${key}`}
|
alt={`Attribute ${key}`}
|
||||||
sx={{
|
|
||||||
maxWidth: "100px",
|
|
||||||
maxHeight: "100px",
|
|
||||||
objectFit: "contain",
|
|
||||||
border: "1px solid #e0e0e0",
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -413,13 +770,68 @@ class ProductDetailPage extends Component {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
{/* Right-aligned action buttons */}
|
||||||
|
<Stack direction="column" spacing={1} sx={{ flexShrink: 0 }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={this.toggleQuestionForm}
|
||||||
|
sx={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.5,
|
||||||
|
minWidth: "auto",
|
||||||
|
whiteSpace: "nowrap"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Frage zum Artikel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={this.toggleRatingForm}
|
||||||
|
sx={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.5,
|
||||||
|
minWidth: "auto",
|
||||||
|
whiteSpace: "nowrap"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Artikel Bewerten
|
||||||
|
</Button>
|
||||||
|
{(product.available !== 1 && product.availableSupplier !== 1) && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={this.toggleAvailabilityForm}
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Verfügbarkeit anfragen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Weight */}
|
{/* Weight */}
|
||||||
{product.weight > 0 && (
|
{product.weight > 0 && (
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Gewicht: {product.weight.toFixed(1).replace(".", ",")} kg
|
{this.props.t ? this.props.t('product.weight', { weight: product.weight.toFixed(1).replace(".", ",") }) : `Gewicht: ${product.weight.toFixed(1).replace(".", ",")} kg`}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -451,8 +863,12 @@ class ProductDetailPage extends Component {
|
|||||||
{priceWithTax}
|
{priceWithTax}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
inkl. {product.vat}% MwSt.
|
{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}</>
|
||||||
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{product.versandklasse &&
|
{product.versandklasse &&
|
||||||
product.versandklasse != "standard" &&
|
product.versandklasse != "standard" &&
|
||||||
product.versandklasse != "kostenlos" && (
|
product.versandklasse != "kostenlos" && (
|
||||||
@@ -461,6 +877,42 @@ class ProductDetailPage extends Component {
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Savings comparison - positioned between price and cart button */}
|
||||||
|
{product.komponenten && komponentenLoaded && totalKomponentenPrice > product.price &&
|
||||||
|
(totalKomponentenPrice - product.price >= 2 &&
|
||||||
|
(totalKomponentenPrice - product.price) / product.price >= 0.02) && (
|
||||||
|
<Box sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minWidth: { xs: "100%", sm: "200px" }
|
||||||
|
}}>
|
||||||
|
<Box sx={{ p: 2, borderRadius: 1, backgroundColor: "#e8f5e8", textAlign: "center" }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "success.main"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.props.t ? this.props.t('product.youSave', {
|
||||||
|
amount: new Intl.NumberFormat("de-DE", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(totalKomponentenPrice - product.price)
|
||||||
|
}) : `Sie sparen: ${new Intl.NumberFormat("de-DE", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(totalKomponentenPrice - product.price)}`}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{this.props.t ? this.props.t('product.cheaperThanIndividual') : 'Günstiger als Einzelkauf'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -487,6 +939,7 @@ class ProductDetailPage extends Component {
|
|||||||
vat={product.vat}
|
vat={product.vat}
|
||||||
weight={product.weight}
|
weight={product.weight}
|
||||||
availableSupplier={product.availableSupplier}
|
availableSupplier={product.availableSupplier}
|
||||||
|
komponenten={product.komponenten}
|
||||||
name={cleanProductName(product.name) + " Stecklingsvorbestellung 1 Stück"}
|
name={cleanProductName(product.name) + " Stecklingsvorbestellung 1 Stück"}
|
||||||
versandklasse={"nur Abholung"}
|
versandklasse={"nur Abholung"}
|
||||||
/>
|
/>
|
||||||
@@ -499,7 +952,7 @@ class ProductDetailPage extends Component {
|
|||||||
mt: 1
|
mt: 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Abholpreis: 19,90 € pro Steckling.
|
{this.props.t ? this.props.t('product.pickupPrice') : 'Abholpreis: 19,90 € pro Steckling.'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -516,12 +969,16 @@ class ProductDetailPage extends Component {
|
|||||||
available={product.available}
|
available={product.available}
|
||||||
id={product.id}
|
id={product.id}
|
||||||
availableSupplier={product.availableSupplier}
|
availableSupplier={product.availableSupplier}
|
||||||
|
komponenten={product.komponenten}
|
||||||
|
cGrundEinheit={product.cGrundEinheit}
|
||||||
|
fGrundPreis={product.fGrundPreis}
|
||||||
price={product.price}
|
price={product.price}
|
||||||
vat={product.vat}
|
vat={product.vat}
|
||||||
weight={product.weight}
|
weight={product.weight}
|
||||||
name={cleanProductName(product.name)}
|
name={cleanProductName(product.name)}
|
||||||
versandklasse={product.versandklasse}
|
versandklasse={product.versandklasse}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
sx={{
|
sx={{
|
||||||
@@ -531,9 +988,12 @@ class ProductDetailPage extends Component {
|
|||||||
mt: 1
|
mt: 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{product.id.toString().endsWith("steckling") ? "Lieferzeit: 14 Tage" :
|
{product.id.toString().endsWith("steckling") ?
|
||||||
product.available == 1 ? "Lieferzeit: 2-3 Tage" :
|
(this.props.t ? this.props.t('delivery.times.cutting14Days') : "Lieferzeit: 14 Tage") :
|
||||||
product.availableSupplier == 1 ? "Lieferzeit: 7-9 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") : ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -565,9 +1025,242 @@ class ProductDetailPage extends Component {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Article Question Form */}
|
||||||
|
<Collapse in={this.state.showQuestionForm}>
|
||||||
|
<div id="question-form">
|
||||||
|
<ArticleQuestionForm
|
||||||
|
productId={product.id}
|
||||||
|
productName={cleanProductName(product.name)}
|
||||||
|
socket={this.props.socket}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
{/* Article Rating Form */}
|
||||||
|
<Collapse in={this.state.showRatingForm}>
|
||||||
|
<div id="rating-form">
|
||||||
|
<ArticleRatingForm
|
||||||
|
productId={product.id}
|
||||||
|
productName={cleanProductName(product.name)}
|
||||||
|
socket={this.props.socket}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
{/* Article Availability Form - only show for out of stock items */}
|
||||||
|
{(product.available !== 1 && product.availableSupplier !== 1) && (
|
||||||
|
<Collapse in={this.state.showAvailabilityForm}>
|
||||||
|
<ArticleAvailabilityForm
|
||||||
|
productId={product.id}
|
||||||
|
productName={cleanProductName(product.name)}
|
||||||
|
socket={this.props.socket}
|
||||||
|
/>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{product.komponenten && product.komponenten.split(",").length > 0 && (
|
||||||
|
<Box sx={{ mt: 4, p: 4, background: "#fff", borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
|
||||||
|
<Typography variant="h4" gutterBottom>{this.props.t ? this.props.t('product.consistsOf') : 'Bestehend aus:'}</Typography>
|
||||||
|
<Box sx={{ maxWidth: 800, mx: "auto" }}>
|
||||||
|
|
||||||
|
{(console.log("komponentenLoaded:", komponentenLoaded), komponentenLoaded) ? (
|
||||||
|
<>
|
||||||
|
{console.log("Rendering loaded komponenten:", this.state.komponenten.length, "komponentenData:", Object.keys(komponentenData).length)}
|
||||||
|
{this.state.komponenten.map((komponent, index) => {
|
||||||
|
const komponentData = komponentenData[komponent.id];
|
||||||
|
console.log(`Rendering komponent ${komponent.id}:`, komponentData);
|
||||||
|
|
||||||
|
// Don't show border on last item (pricing section has its own top border)
|
||||||
|
const isLastItem = index === this.state.komponenten.length - 1;
|
||||||
|
const showBorder = !isLastItem;
|
||||||
|
|
||||||
|
if (!komponentData || !komponentData.loaded) {
|
||||||
|
return (
|
||||||
|
<Box key={komponent.id} sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
py: 1,
|
||||||
|
borderBottom: showBorder ? "1px solid #eee" : "none",
|
||||||
|
minHeight: "70px" // Consistent height to prevent layout shifts
|
||||||
|
}}>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||||
|
<Box sx={{ width: 50, height: 50, flexShrink: 0, backgroundColor: "#f5f5f5", borderRadius: 1, border: "1px solid #e0e0e0" }}>
|
||||||
|
{/* Empty placeholder for image */}
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{index + 1}. Lädt...
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{komponent.count}x
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||||
|
-
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemPrice = komponentData.price * parseInt(komponent.count);
|
||||||
|
const formattedPrice = new Intl.NumberFormat("de-DE", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(itemPrice);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={komponent.id}
|
||||||
|
component={Link}
|
||||||
|
to={`/Artikel/${komponentData.seoName}`}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
py: 1,
|
||||||
|
borderBottom: showBorder ? "1px solid #eee" : "none",
|
||||||
|
textDecoration: "none",
|
||||||
|
color: "inherit",
|
||||||
|
minHeight: "70px", // Consistent height to prevent layout shifts
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "#f5f5f5"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||||
|
<Box sx={{ width: 50, height: 50, flexShrink: 0 }}>
|
||||||
|
{komponentenImages[komponent.id] ? (
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
height="50"
|
||||||
|
image={komponentenImages[komponent.id]}
|
||||||
|
alt={komponentData.name}
|
||||||
|
sx={{
|
||||||
|
objectFit: "contain",
|
||||||
|
borderRadius: 1,
|
||||||
|
border: "1px solid #e0e0e0"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
height="50"
|
||||||
|
image="/assets/images/nopicture.jpg"
|
||||||
|
alt={komponentData.name}
|
||||||
|
sx={{
|
||||||
|
objectFit: "contain",
|
||||||
|
borderRadius: 1,
|
||||||
|
border: "1px solid #e0e0e0"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||||
|
{index + 1}. {cleanProductName(komponentData.name)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{komponent.count}x à {new Intl.NumberFormat("de-DE", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(komponentData.price)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||||
|
{formattedPrice}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Total price and savings display - only show when prices differ meaningfully */}
|
||||||
|
{totalKomponentenPrice > product.price &&
|
||||||
|
(totalKomponentenPrice - product.price >= 2 &&
|
||||||
|
(totalKomponentenPrice - product.price) / product.price >= 0.02) && (
|
||||||
|
<Box sx={{ mt: 3, pt: 2, borderTop: "2px solid #eee" }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}>
|
||||||
|
<Typography variant="h6">
|
||||||
|
{this.props.t ? this.props.t('product.individualPriceTotal') : 'Einzelpreis gesamt:'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" sx={{ textDecoration: "line-through", color: "text.secondary" }}>
|
||||||
|
{new Intl.NumberFormat("de-DE", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(totalKomponentenPrice)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}>
|
||||||
|
<Typography variant="h6">
|
||||||
|
{this.props.t ? this.props.t('product.setPrice') : 'Set-Preis:'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" color="primary" sx={{ fontWeight: "bold" }}>
|
||||||
|
{new Intl.NumberFormat("de-DE", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(product.price)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{totalSavings > 0 && (
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mt: 2, p: 2, backgroundColor: "#e8f5e8", borderRadius: 1 }}>
|
||||||
|
<Typography variant="h6" color="success.main" sx={{ fontWeight: "bold" }}>
|
||||||
|
{this.props.t ? this.props.t('product.yourSavings') : 'Ihre Ersparnis:'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" color="success.main" sx={{ fontWeight: "bold" }}>
|
||||||
|
{new Intl.NumberFormat("de-DE", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(totalSavings)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Loading state
|
||||||
|
<Box>
|
||||||
|
{this.state.komponenten.map((komponent, index) => {
|
||||||
|
// For loading state, we don't know if pricing will be shown, so show all borders
|
||||||
|
return (
|
||||||
|
<Box key={komponent.id} sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
py: 1,
|
||||||
|
borderBottom: "1px solid #eee",
|
||||||
|
minHeight: "70px" // Consistent height to prevent layout shifts
|
||||||
|
}}>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||||
|
<Box sx={{ width: 50, height: 50, flexShrink: 0, backgroundColor: "#f5f5f5", borderRadius: 1, border: "1px solid #e0e0e0" }}>
|
||||||
|
{/* Empty placeholder for image */}
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{this.props.t ? this.props.t('product.loadingComponentDetails', { index: index + 1 }) : `${index + 1}. Lädt Komponent-Details...`}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{komponent.count}x
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||||
|
-
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ProductDetailPage;
|
export default withI18n()(ProductDetailPage);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Typography from '@mui/material/Typography';
|
|||||||
import Filter from './Filter.js';
|
import Filter from './Filter.js';
|
||||||
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { setSessionSetting, removeSessionSetting, clearAllSessionSettings } from '../utils/sessionStorage.js';
|
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);
|
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
@@ -93,14 +94,14 @@ class ProductFilters extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_getAvailabilityValues = (products) => {
|
_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){
|
for(const product of products){
|
||||||
if(isNew(product.neu)){
|
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(!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
|
return filters
|
||||||
@@ -193,7 +194,7 @@ class ProductFilters extends Component {
|
|||||||
|
|
||||||
{this.props.products.length > 0 && (
|
{this.props.products.length > 0 && (
|
||||||
<><Filter
|
<><Filter
|
||||||
title="Verfügbarkeit"
|
title={this.props.t ? this.props.t('filters.availability') : 'Verfügbarkeit'}
|
||||||
options={this.state.availabilityValues}
|
options={this.state.availabilityValues}
|
||||||
searchParams={this.props.searchParams}
|
searchParams={this.props.searchParams}
|
||||||
products={this.props.products}
|
products={this.props.products}
|
||||||
@@ -236,7 +237,7 @@ class ProductFilters extends Component {
|
|||||||
{this.generateAttributeFilters()}
|
{this.generateAttributeFilters()}
|
||||||
|
|
||||||
<Filter
|
<Filter
|
||||||
title="Hersteller"
|
title={this.props.t ? this.props.t('filters.manufacturer') : 'Hersteller'}
|
||||||
options={this.state.uniqueManufacturerArray}
|
options={this.state.uniqueManufacturerArray}
|
||||||
filterType="manufacturer"
|
filterType="manufacturer"
|
||||||
products={this.props.products}
|
products={this.props.products}
|
||||||
@@ -257,4 +258,4 @@ class ProductFilters extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(ProductFilters);
|
export default withRouter(withI18n()(ProductFilters));
|
||||||
51
src/components/ProductImage.js
Normal file
51
src/components/ProductImage.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import CardMedia from '@mui/material/CardMedia';
|
||||||
|
import Images from './Images.js';
|
||||||
|
|
||||||
|
const ProductImage = ({
|
||||||
|
product,
|
||||||
|
socket,
|
||||||
|
socketB,
|
||||||
|
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}
|
||||||
|
sx={{ objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{product.pictureList && (
|
||||||
|
<Images
|
||||||
|
socket={socket}
|
||||||
|
socketB={socketB}
|
||||||
|
pictureList={product.pictureList}
|
||||||
|
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 Stack from '@mui/material/Stack';
|
||||||
import Product from './Product.js';
|
import Product from './Product.js';
|
||||||
import { removeSessionSetting } from '../utils/sessionStorage.js';
|
import { removeSessionSetting } from '../utils/sessionStorage.js';
|
||||||
|
import { withI18n } from '../i18n/withTranslation.js';
|
||||||
|
|
||||||
// Sort products by fuzzy similarity to their name/description
|
// Sort products by fuzzy similarity to their name/description
|
||||||
function sortProductsByFuzzySimilarity(products, searchTerm) {
|
function sortProductsByFuzzySimilarity(products, searchTerm) {
|
||||||
@@ -141,12 +142,12 @@ class ProductList extends Component {
|
|||||||
onChange={this.handlePageChange}
|
onChange={this.handlePageChange}
|
||||||
color="primary"
|
color="primary"
|
||||||
size={"large"}
|
size={"large"}
|
||||||
siblingCount={window.innerWidth < 600 ? 0 : 1}
|
siblingCount={1}
|
||||||
boundaryCount={window.innerWidth < 600 ? 1 : 1}
|
boundaryCount={1}
|
||||||
hideNextButton={false}
|
hideNextButton={true}
|
||||||
hidePrevButton={false}
|
hidePrevButton={true}
|
||||||
showFirstButton={window.innerWidth >= 600}
|
showFirstButton={false}
|
||||||
showLastButton={window.innerWidth >= 600}
|
showLastButton={false}
|
||||||
sx={{
|
sx={{
|
||||||
'& .MuiPagination-ul': {
|
'& .MuiPagination-ul': {
|
||||||
flexWrap: 'nowrap',
|
flexWrap: 'nowrap',
|
||||||
@@ -184,7 +185,7 @@ class ProductList extends Component {
|
|||||||
px: 2
|
px: 2
|
||||||
}}>
|
}}>
|
||||||
<Typography variant="h6" color="text.secondary" sx={{ textAlign: 'center' }}>
|
<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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -200,14 +201,14 @@ class ProductList extends Component {
|
|||||||
|
|
||||||
if (!isFiltered) {
|
if (!isFiltered) {
|
||||||
// No filters applied
|
// No filters applied
|
||||||
if (filteredCount === 0) return "0 Produkte";
|
if (filteredCount === 0) return this.props.t ? this.props.t('product.countDisplay.noProducts') : "0 Produkte";
|
||||||
if (filteredCount === 1) return "1 Produkt";
|
if (filteredCount === 1) return this.props.t ? this.props.t('product.countDisplay.oneProduct') : "1 Produkt";
|
||||||
return `${filteredCount} Produkte`;
|
return this.props.t ? this.props.t('product.countDisplay.multipleProducts', { count: filteredCount }) : `${filteredCount} Produkte`;
|
||||||
} else {
|
} else {
|
||||||
// Filters applied
|
// Filters applied
|
||||||
if (totalCount === 0) return "0 Produkte";
|
if (totalCount === 0) return this.props.t ? this.props.t('product.countDisplay.noProducts') : "0 Produkte";
|
||||||
if (totalCount === 1) return `${filteredCount} von 1 Produkt`;
|
if (totalCount === 1) return this.props.t ? this.props.t('product.countDisplay.filteredOneProduct', { filtered: filteredCount }) : `${filteredCount} von 1 Produkt`;
|
||||||
return `${filteredCount} von ${totalCount} Produkten`;
|
return this.props.t ? this.props.t('product.countDisplay.filteredProducts', { filtered: filteredCount, total: totalCount }) : `${filteredCount} von ${totalCount} Produkten`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,13 +328,13 @@ class ProductList extends Component {
|
|||||||
minWidth: { xs: 120, sm: 140 }
|
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
|
<Select
|
||||||
size="small"
|
size="small"
|
||||||
labelId="sort-by-label"
|
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'}
|
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}
|
onChange={this.handleSortChange}
|
||||||
label="Sortierung"
|
label={this.props.t ? this.props.t('filters.sorting') : 'Sortierung'}
|
||||||
MenuProps={{
|
MenuProps={{
|
||||||
disableScrollLock: true,
|
disableScrollLock: true,
|
||||||
anchorOrigin: {
|
anchorOrigin: {
|
||||||
@@ -353,10 +354,10 @@ class ProductList extends Component {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem value="name">Name</MenuItem>
|
<MenuItem value="name">{this.props.t ? this.props.t('sorting.name') : 'Name'}</MenuItem>
|
||||||
{window.currentSearchQuery && <MenuItem value="searchField">Suchbegriff</MenuItem>}
|
{window.currentSearchQuery && <MenuItem value="searchField">{this.props.t ? this.props.t('sorting.searchField') : 'Suchbegriff'}</MenuItem>}
|
||||||
<MenuItem value="price-low-high">Preis: Niedrig zu Hoch</MenuItem>
|
<MenuItem value="price-low-high">{this.props.t ? this.props.t('sorting.priceLowHigh') : 'Preis: Niedrig zu Hoch'}</MenuItem>
|
||||||
<MenuItem value="price-high-low">Preis: Hoch zu Niedrig</MenuItem>
|
<MenuItem value="price-high-low">{this.props.t ? this.props.t('sorting.priceHighLow') : 'Preis: Hoch zu Niedrig'}</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@@ -368,12 +369,12 @@ class ProductList extends Component {
|
|||||||
minWidth: { xs: 80, sm: 100 }
|
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
|
<Select
|
||||||
labelId="products-per-page-label"
|
labelId="products-per-page-label"
|
||||||
value={this.state.itemsPerPage}
|
value={this.state.itemsPerPage}
|
||||||
onChange={this.handleProductsPerPageChange}
|
onChange={this.handleProductsPerPageChange}
|
||||||
label="pro Seite"
|
label={this.props.t ? this.props.t('filters.perPage') : 'pro Seite'}
|
||||||
MenuProps={{
|
MenuProps={{
|
||||||
disableScrollLock: true,
|
disableScrollLock: true,
|
||||||
anchorOrigin: {
|
anchorOrigin: {
|
||||||
@@ -462,8 +463,8 @@ class ProductList extends Component {
|
|||||||
available={product.available}
|
available={product.available}
|
||||||
manufacturer={product.manufacturer}
|
manufacturer={product.manufacturer}
|
||||||
vat={product.vat}
|
vat={product.vat}
|
||||||
massMenge={product.massMenge}
|
cGrundEinheit={product.cGrundEinheit}
|
||||||
massEinheit={product.massEinheit}
|
fGrundPreis={product.fGrundPreis}
|
||||||
incoming={product.incomingDate}
|
incoming={product.incomingDate}
|
||||||
neu={product.neu}
|
neu={product.neu}
|
||||||
thc={product.thc}
|
thc={product.thc}
|
||||||
@@ -474,6 +475,8 @@ class ProductList extends Component {
|
|||||||
socketB={this.props.socketB}
|
socketB={this.props.socketB}
|
||||||
pictureList={product.pictureList}
|
pictureList={product.pictureList}
|
||||||
availableSupplier={product.availableSupplier}
|
availableSupplier={product.availableSupplier}
|
||||||
|
komponenten={product.komponenten}
|
||||||
|
t={this.props.t}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
@@ -495,4 +498,4 @@ class ProductList extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ProductList;
|
export default withI18n()(ProductList);
|
||||||
283
src/components/SharedCarousel.js
Normal file
283
src/components/SharedCarousel.js
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import React, { useContext, useEffect, useState } 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 CategoryBox from "./CategoryBox.js";
|
||||||
|
import SocketContext from "../contexts/SocketContext.js";
|
||||||
|
import { useCarousel } from "../contexts/CarouselContext.js";
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
// Helper to process and set categories
|
||||||
|
const processCategoryTree = (categoryTree) => {
|
||||||
|
if (
|
||||||
|
categoryTree &&
|
||||||
|
categoryTree.id === 209 &&
|
||||||
|
Array.isArray(categoryTree.children)
|
||||||
|
) {
|
||||||
|
return categoryTree.children;
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for cached data
|
||||||
|
const getProductCache = () => {
|
||||||
|
if (typeof window !== "undefined" && window.productCache) {
|
||||||
|
return window.productCache;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof global !== "undefined" &&
|
||||||
|
global.window &&
|
||||||
|
global.window.productCache
|
||||||
|
) {
|
||||||
|
return global.window.productCache;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize categories
|
||||||
|
const initializeCategories = (language = 'en') => {
|
||||||
|
const productCache = getProductCache();
|
||||||
|
|
||||||
|
if (productCache && productCache[`categoryTree_209_${language}`]) {
|
||||||
|
const cached = productCache[`categoryTree_209_${language}`];
|
||||||
|
if (cached.categoryTree) {
|
||||||
|
return processCategoryTree(cached.categoryTree);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only fallback to old cache format if we're looking for German (default language)
|
||||||
|
// This prevents showing German categories when user wants English categories
|
||||||
|
if (language === 'de' && productCache && productCache["categoryTree_209"]) {
|
||||||
|
const cached = productCache["categoryTree_209"];
|
||||||
|
if (cached.categoryTree) {
|
||||||
|
return processCategoryTree(cached.categoryTree);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const SharedCarousel = () => {
|
||||||
|
const { carouselRef, filteredCategories, setFilteredCategories, moveCarousel } = useCarousel();
|
||||||
|
const context = useContext(SocketContext);
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const [rootCategories, setRootCategories] = useState([]);
|
||||||
|
const [currentLanguage, setCurrentLanguage] = useState(i18n.language || 'de');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initialCategories = initializeCategories(currentLanguage);
|
||||||
|
setRootCategories(initialCategories);
|
||||||
|
}, [currentLanguage]);
|
||||||
|
|
||||||
|
// Also listen for i18n ready state
|
||||||
|
useEffect(() => {
|
||||||
|
if (i18n.isInitialized && i18n.language !== currentLanguage) {
|
||||||
|
setCurrentLanguage(i18n.language);
|
||||||
|
}
|
||||||
|
}, [i18n.isInitialized, i18n.language, currentLanguage]);
|
||||||
|
|
||||||
|
// Listen for language changes
|
||||||
|
useEffect(() => {
|
||||||
|
const handleLanguageChange = (lng) => {
|
||||||
|
setCurrentLanguage(lng);
|
||||||
|
// Clear categories to force refetch
|
||||||
|
setRootCategories([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
i18n.on('languageChanged', handleLanguageChange);
|
||||||
|
return () => {
|
||||||
|
i18n.off('languageChanged', handleLanguageChange);
|
||||||
|
};
|
||||||
|
}, [i18n]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only fetch from socket if we don't already have categories
|
||||||
|
if (
|
||||||
|
rootCategories.length === 0 &&
|
||||||
|
context && context.socket && context.socket.connected &&
|
||||||
|
typeof window !== "undefined"
|
||||||
|
) {
|
||||||
|
context.socket.emit("categoryList", { categoryId: 209, language: currentLanguage, requestTranslation: true }, (response) => {
|
||||||
|
if (response && response.success) {
|
||||||
|
// Use translated data if available, otherwise fall back to original
|
||||||
|
const categoryTreeToUse = response.translation || response.categoryTree;
|
||||||
|
|
||||||
|
if (categoryTreeToUse) {
|
||||||
|
// Store in cache with language-specific key
|
||||||
|
try {
|
||||||
|
if (!window.productCache) window.productCache = {};
|
||||||
|
window.productCache[`categoryTree_209_${currentLanguage}`] = {
|
||||||
|
categoryTree: categoryTreeToUse,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
const newCategories = categoryTreeToUse.children || [];
|
||||||
|
setRootCategories(newCategories);
|
||||||
|
}
|
||||||
|
} else if (response && response.categoryTree) {
|
||||||
|
// Fallback for old response format
|
||||||
|
// Store in cache with language-specific key
|
||||||
|
try {
|
||||||
|
if (!window.productCache) window.productCache = {};
|
||||||
|
window.productCache[`categoryTree_209_${currentLanguage}`] = {
|
||||||
|
categoryTree: response.categoryTree,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
const newCategories = response.categoryTree.children || [];
|
||||||
|
setRootCategories(newCategories);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [context, context?.socket?.connected, rootCategories.length, currentLanguage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const filtered = rootCategories.filter(
|
||||||
|
(cat) => cat.id !== 689 && cat.id !== 706
|
||||||
|
);
|
||||||
|
setFilteredCategories(filtered);
|
||||||
|
}, [rootCategories, setFilteredCategories]);
|
||||||
|
|
||||||
|
// Create duplicated array for seamless scrolling
|
||||||
|
const displayCategories = [...filteredCategories, ...filteredCategories];
|
||||||
|
|
||||||
|
if (filteredCategories.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<Typography
|
||||||
|
variant="h4"
|
||||||
|
component="h1"
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
fontFamily: "SwashingtonCP",
|
||||||
|
color: "primary.main",
|
||||||
|
textAlign: "center",
|
||||||
|
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('navigation.categories')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="carousel-wrapper"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '1200px',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: '0 20px',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left Arrow */}
|
||||||
|
<IconButton
|
||||||
|
onClick={() => moveCarousel("left")}
|
||||||
|
aria-label="Vorherige Kategorien anzeigen"
|
||||||
|
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
|
||||||
|
onClick={() => moveCarousel("right")}
|
||||||
|
aria-label="Nächste Kategorien anzeigen"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
right: '8px',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
zIndex: 1200,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: '50%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRight />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="carousel-container"
|
||||||
|
style={{
|
||||||
|
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={carouselRef}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '16px',
|
||||||
|
transition: 'none',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
width: 'fit-content',
|
||||||
|
overflow: 'visible',
|
||||||
|
position: 'relative',
|
||||||
|
transform: 'translateX(0px)',
|
||||||
|
margin: '0 auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayCategories.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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SharedCarousel;
|
||||||
@@ -10,7 +10,9 @@ import CloseIcon from '@mui/icons-material/Close';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import LoginComponent from '../LoginComponent.js';
|
import LoginComponent from '../LoginComponent.js';
|
||||||
import CartDropdown from '../CartDropdown.js';
|
import CartDropdown from '../CartDropdown.js';
|
||||||
|
import LanguageSwitcher from '../LanguageSwitcher.js';
|
||||||
import { isUserLoggedIn } from '../LoginComponent.js';
|
import { isUserLoggedIn } from '../LoginComponent.js';
|
||||||
|
import { withI18n } from '../../i18n/withTranslation.js';
|
||||||
|
|
||||||
function getBadgeNumber() {
|
function getBadgeNumber() {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@@ -116,14 +118,14 @@ class ButtonGroup extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { socket, navigate } = this.props;
|
const { socket, navigate, t } = this.props;
|
||||||
const { isCartOpen } = this.state;
|
const { isCartOpen } = this.state;
|
||||||
const cartItems = Array.isArray(window.cart) ? window.cart : [];
|
const cartItems = Array.isArray(window.cart) ? window.cart : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', gap: { xs: 0.5, sm: 1 } }}>
|
<Box sx={{ display: 'flex', gap: { xs: 0.5, sm: 1 } }}>
|
||||||
|
|
||||||
|
<LanguageSwitcher />
|
||||||
<LoginComponent socket={socket} />
|
<LoginComponent socket={socket} />
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -164,7 +166,7 @@ class ButtonGroup extends Component {
|
|||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Typography variant="h6">Warenkorb</Typography>
|
<Typography variant="h6">{t ? t('cart.title') : 'Warenkorb'}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Divider sx={{ mb: 2 }} />
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
@@ -173,7 +175,7 @@ class ButtonGroup extends Component {
|
|||||||
|
|
||||||
if (isUserLoggedIn().isLoggedIn) {
|
if (isUserLoggedIn().isLoggedIn) {
|
||||||
this.toggleCart(); // Close the cart drawer
|
this.toggleCart(); // Close the cart drawer
|
||||||
navigate('/profile');
|
navigate('/profile#cart');
|
||||||
} else if (window.openLoginDrawer) {
|
} else if (window.openLoginDrawer) {
|
||||||
window.openLoginDrawer(); // Call global function to open login drawer
|
window.openLoginDrawer(); // Call global function to open login drawer
|
||||||
this.toggleCart(); // Close the cart drawer
|
this.toggleCart(); // Close the cart drawer
|
||||||
@@ -189,10 +191,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 ButtonGroupWithRouter = (props) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return <ButtonGroup {...props} navigate={navigate} />;
|
const ButtonGroupWithTranslation = withI18n()(ButtonGroup);
|
||||||
|
return <ButtonGroupWithTranslation {...props} navigate={navigate} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ButtonGroupWithRouter;
|
export default ButtonGroupWithRouter;
|
||||||
@@ -8,6 +8,7 @@ import { Link } from "react-router-dom";
|
|||||||
import HomeIcon from "@mui/icons-material/Home";
|
import HomeIcon from "@mui/icons-material/Home";
|
||||||
import MenuIcon from "@mui/icons-material/Menu";
|
import MenuIcon from "@mui/icons-material/Menu";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import { withI18n } from "../../i18n/withTranslation.js";
|
||||||
|
|
||||||
class CategoryList extends Component {
|
class CategoryList extends Component {
|
||||||
findCategoryById = (category, targetId) => {
|
findCategoryById = (category, targetId) => {
|
||||||
@@ -49,6 +50,9 @@ class CategoryList extends Component {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
// Get current language from props (provided by withI18n HOC)
|
||||||
|
const currentLanguage = props.languageContext?.currentLanguage || 'de';
|
||||||
|
|
||||||
// Check for cached data during SSR/initial render
|
// Check for cached data during SSR/initial render
|
||||||
let initialState = {
|
let initialState = {
|
||||||
categoryTree: null,
|
categoryTree: null,
|
||||||
@@ -58,6 +62,7 @@ class CategoryList extends Component {
|
|||||||
activePath: [], // Array of active category objects for each level
|
activePath: [], // Array of active category objects for each level
|
||||||
fetchedCategories: false,
|
fetchedCategories: false,
|
||||||
mobileMenuOpen: false, // State for mobile collapsible menu
|
mobileMenuOpen: false, // State for mobile collapsible menu
|
||||||
|
currentLanguage: currentLanguage,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try to get cached data for SSR
|
// Try to get cached data for SSR
|
||||||
@@ -67,7 +72,7 @@ class CategoryList extends Component {
|
|||||||
(typeof window !== "undefined" && window.productCache);
|
(typeof window !== "undefined" && window.productCache);
|
||||||
|
|
||||||
if (productCache) {
|
if (productCache) {
|
||||||
const cacheKey = "categoryTree_209";
|
const cacheKey = `categoryTree_209_${currentLanguage}`;
|
||||||
const cachedData = productCache[cacheKey];
|
const cachedData = productCache[cacheKey];
|
||||||
if (cachedData && cachedData.categoryTree) {
|
if (cachedData && cachedData.categoryTree) {
|
||||||
const { categoryTree, timestamp } = cachedData;
|
const { categoryTree, timestamp } = cachedData;
|
||||||
@@ -127,8 +132,27 @@ class CategoryList extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
// Handle socket connection changes
|
// Handle language changes
|
||||||
|
const currentLanguage = this.props.languageContext?.currentLanguage || 'de';
|
||||||
|
const prevLanguage = prevProps.languageContext?.currentLanguage || 'de';
|
||||||
|
|
||||||
|
if (currentLanguage !== prevLanguage) {
|
||||||
|
// Language changed, need to refetch categories
|
||||||
|
this.setState({
|
||||||
|
currentLanguage: currentLanguage,
|
||||||
|
fetchedCategories: false,
|
||||||
|
categoryTree: null,
|
||||||
|
level1Categories: [],
|
||||||
|
level2Categories: [],
|
||||||
|
level3Categories: [],
|
||||||
|
activePath: [],
|
||||||
|
}, () => {
|
||||||
|
this.fetchCategories();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle socket connection changes
|
||||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||||
|
|
||||||
@@ -168,6 +192,9 @@ class CategoryList extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get current language from state
|
||||||
|
const currentLanguage = this.state.currentLanguage || 'de';
|
||||||
|
|
||||||
// Initialize global cache object if it doesn't exist
|
// Initialize global cache object if it doesn't exist
|
||||||
// @note Handle both SSR (global.window) and browser (window) environments
|
// @note Handle both SSR (global.window) and browser (window) environments
|
||||||
const windowObj = (typeof global !== "undefined" && global.window) ||
|
const windowObj = (typeof global !== "undefined" && global.window) ||
|
||||||
@@ -179,7 +206,7 @@ class CategoryList extends Component {
|
|||||||
|
|
||||||
// Check if we have a valid cache in the global object
|
// Check if we have a valid cache in the global object
|
||||||
try {
|
try {
|
||||||
const cacheKey = "categoryTree_209";
|
const cacheKey = `categoryTree_209_${currentLanguage}`;
|
||||||
const cachedData = windowObj && windowObj.productCache ? windowObj.productCache[cacheKey] : null;
|
const cachedData = windowObj && windowObj.productCache ? windowObj.productCache[cacheKey] : null;
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
const { categoryTree, fetching } = cachedData;
|
const { categoryTree, fetching } = cachedData;
|
||||||
@@ -216,7 +243,7 @@ class CategoryList extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mark as being fetched to prevent concurrent calls
|
// Mark as being fetched to prevent concurrent calls
|
||||||
const cacheKey = "categoryTree_209";
|
const cacheKey = `categoryTree_209_${currentLanguage}`;
|
||||||
if (windowObj && windowObj.productCache) {
|
if (windowObj && windowObj.productCache) {
|
||||||
windowObj.productCache[cacheKey] = {
|
windowObj.productCache[cacheKey] = {
|
||||||
fetching: true,
|
fetching: true,
|
||||||
@@ -226,15 +253,18 @@ class CategoryList extends Component {
|
|||||||
this.setState({ fetchedCategories: true });
|
this.setState({ fetchedCategories: true });
|
||||||
|
|
||||||
//console.log('CategoryList: Fetching categories from socket');
|
//console.log('CategoryList: Fetching categories from socket');
|
||||||
socket.emit("categoryList", { categoryId: 209 }, (response) => {
|
socket.emit("categoryList", { categoryId: 209, language: currentLanguage, requestTranslation: true }, (response) => {
|
||||||
if (response && response.categoryTree) {
|
if (response && response.success) {
|
||||||
|
// Use translated data if available, otherwise fall back to original
|
||||||
|
const categoryTreeToUse = response.translation || response.categoryTree;
|
||||||
|
|
||||||
|
if (categoryTreeToUse) {
|
||||||
// Store in global cache with timestamp
|
// Store in global cache with timestamp
|
||||||
try {
|
try {
|
||||||
const cacheKey = "categoryTree_209";
|
const cacheKey = `categoryTree_209_${currentLanguage}`;
|
||||||
if (windowObj && windowObj.productCache) {
|
if (windowObj && windowObj.productCache) {
|
||||||
windowObj.productCache[cacheKey] = {
|
windowObj.productCache[cacheKey] = {
|
||||||
categoryTree: response.categoryTree,
|
categoryTree: categoryTreeToUse,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
fetching: false,
|
fetching: false,
|
||||||
};
|
};
|
||||||
@@ -242,14 +272,40 @@ class CategoryList extends Component {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error writing to cache:", err);
|
console.error("Error writing to cache:", err);
|
||||||
}
|
}
|
||||||
this.processCategoryTree(response.categoryTree);
|
this.processCategoryTree(categoryTreeToUse);
|
||||||
} else {
|
} else {
|
||||||
|
console.error('No category tree found in response');
|
||||||
|
// Clear cache on error
|
||||||
try {
|
try {
|
||||||
const cacheKey = "categoryTree_209";
|
const cacheKey = `categoryTree_209_${currentLanguage}`;
|
||||||
if (windowObj && windowObj.productCache) {
|
if (windowObj && windowObj.productCache) {
|
||||||
windowObj.productCache[cacheKey] = {
|
windowObj.productCache[cacheKey] = {
|
||||||
categoryTree: null,
|
categoryTree: null,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
fetching: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error writing to cache:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
categoryTree: null,
|
||||||
|
level1Categories: [],
|
||||||
|
level2Categories: [],
|
||||||
|
level3Categories: [],
|
||||||
|
activePath: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch categories:', response);
|
||||||
|
try {
|
||||||
|
const cacheKey = `categoryTree_209_${currentLanguage}`;
|
||||||
|
if (windowObj && windowObj.productCache) {
|
||||||
|
windowObj.productCache[cacheKey] = {
|
||||||
|
categoryTree: null,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
fetching: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -410,7 +466,7 @@ class CategoryList extends Component {
|
|||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Startseite
|
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
|
||||||
</Box>
|
</Box>
|
||||||
{/* Thin text (positioned on top) */}
|
{/* Thin text (positioned on top) */}
|
||||||
<Box
|
<Box
|
||||||
@@ -424,7 +480,7 @@ class CategoryList extends Component {
|
|||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Startseite
|
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -595,7 +651,10 @@ class CategoryList extends Component {
|
|||||||
onClick={this.handleMobileMenuToggle}
|
onClick={this.handleMobileMenuToggle}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
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) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -607,7 +666,7 @@ class CategoryList extends Component {
|
|||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
textShadow: "0 1px 2px rgba(0,0,0,0.3)"
|
textShadow: "0 1px 2px rgba(0,0,0,0.3)"
|
||||||
}}>
|
}}>
|
||||||
Kategorien
|
{this.props.t ? this.props.t('navigation.categories') : 'Kategorien'}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||||
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
|
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
|
||||||
@@ -628,4 +687,4 @@ class CategoryList extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CategoryList;
|
export default withI18n()(CategoryList);
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import ListItem from "@mui/material/ListItem";
|
|||||||
import ListItemText from "@mui/material/ListItemText";
|
import ListItemText from "@mui/material/ListItemText";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import CircularProgress from "@mui/material/CircularProgress";
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
import SearchIcon from "@mui/icons-material/Search";
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
|
import KeyboardReturnIcon from "@mui/icons-material/KeyboardReturn";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import SocketContext from "../../contexts/SocketContext.js";
|
import SocketContext from "../../contexts/SocketContext.js";
|
||||||
|
|
||||||
@@ -184,6 +186,15 @@ const SearchBar = () => {
|
|||||||
}, 200);
|
}, 200);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle enter icon click
|
||||||
|
const handleEnterClick = () => {
|
||||||
|
delete window.currentSearchQuery;
|
||||||
|
setShowSuggestions(false);
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
navigate(`/search?q=${encodeURIComponent(searchQuery)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Clean up timers on unmount
|
// Clean up timers on unmount
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -244,9 +255,23 @@ const SearchBar = () => {
|
|||||||
<SearchIcon />
|
<SearchIcon />
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
endAdornment: loadingSuggestions && (
|
endAdornment: (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
<CircularProgress size={16} />
|
{loadingSuggestions && <CircularProgress size={16} />}
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={handleEnterClick}
|
||||||
|
sx={{
|
||||||
|
ml: loadingSuggestions ? 0.5 : 0,
|
||||||
|
p: 0.5,
|
||||||
|
color: "text.secondary",
|
||||||
|
"&:hover": {
|
||||||
|
color: "primary.main",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KeyboardReturnIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
sx: { borderRadius: 2, bgcolor: "background.paper" },
|
sx: { borderRadius: 2, bgcolor: "background.paper" },
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Box, TextField, Typography } from "@mui/material";
|
import { Box, TextField, Typography } from "@mui/material";
|
||||||
|
import { withI18n } from "../../i18n/withTranslation.js";
|
||||||
|
|
||||||
const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
const AddressForm = ({ title, address, onChange, errors, namePrefix, t }) => {
|
||||||
// Helper function to determine if a required field should show error styling
|
// Helper function to determine if a required field should show error styling
|
||||||
const getRequiredFieldError = (fieldName, value) => {
|
const getRequiredFieldError = (fieldName, value) => {
|
||||||
const isEmpty = !value || value.trim() === "";
|
const isEmpty = !value || value.trim() === "";
|
||||||
@@ -36,7 +37,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
label="Vorname"
|
label={t ? t('checkout.addressFields.firstName') : 'Vorname'}
|
||||||
name="firstName"
|
name="firstName"
|
||||||
value={address.firstName}
|
value={address.firstName}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -49,7 +50,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Nachname"
|
label={t ? t('checkout.addressFields.lastName') : 'Nachname'}
|
||||||
name="lastName"
|
name="lastName"
|
||||||
value={address.lastName}
|
value={address.lastName}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -62,7 +63,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Adresszusatz"
|
label={t ? t('checkout.addressFields.addressSupplement') : 'Adresszusatz'}
|
||||||
name="addressAddition"
|
name="addressAddition"
|
||||||
value={address.addressAddition || ""}
|
value={address.addressAddition || ""}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -70,7 +71,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
|||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Straße"
|
label={t ? t('checkout.addressFields.street') : 'Straße'}
|
||||||
name="street"
|
name="street"
|
||||||
value={address.street}
|
value={address.street}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -83,7 +84,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Hausnummer"
|
label={t ? t('checkout.addressFields.houseNumber') : 'Hausnummer'}
|
||||||
name="houseNumber"
|
name="houseNumber"
|
||||||
value={address.houseNumber}
|
value={address.houseNumber}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -96,7 +97,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="PLZ"
|
label={t ? t('checkout.addressFields.postalCode') : 'PLZ'}
|
||||||
name="postalCode"
|
name="postalCode"
|
||||||
value={address.postalCode}
|
value={address.postalCode}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -109,7 +110,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Stadt"
|
label={t ? t('checkout.addressFields.city') : 'Stadt'}
|
||||||
name="city"
|
name="city"
|
||||||
value={address.city}
|
value={address.city}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -122,7 +123,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Land"
|
label={t ? t('checkout.addressFields.country') : 'Land'}
|
||||||
name="country"
|
name="country"
|
||||||
value={address.country}
|
value={address.country}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -135,4 +136,4 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AddressForm;
|
export default withI18n()(AddressForm);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import PaymentConfirmationDialog from "./PaymentConfirmationDialog.js";
|
|||||||
import OrderProcessingService from "./OrderProcessingService.js";
|
import OrderProcessingService from "./OrderProcessingService.js";
|
||||||
import CheckoutValidation from "./CheckoutValidation.js";
|
import CheckoutValidation from "./CheckoutValidation.js";
|
||||||
import SocketContext from "../../contexts/SocketContext.js";
|
import SocketContext from "../../contexts/SocketContext.js";
|
||||||
|
import { withI18n } from "../../i18n/index.js";
|
||||||
|
|
||||||
class CartTab extends Component {
|
class CartTab extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -116,7 +117,7 @@ class CartTab extends Component {
|
|||||||
// Determine payment method - respect constraints
|
// Determine payment method - respect constraints
|
||||||
let prefillPaymentMethod = template.payment_method || "wire";
|
let prefillPaymentMethod = template.payment_method || "wire";
|
||||||
const paymentMethodMap = {
|
const paymentMethodMap = {
|
||||||
"credit_card": "stripe",
|
"credit_card": "mollie",/*stripe*/
|
||||||
"bank_transfer": "wire",
|
"bank_transfer": "wire",
|
||||||
"cash_on_delivery": "onDelivery",
|
"cash_on_delivery": "onDelivery",
|
||||||
"cash": "cash"
|
"cash": "cash"
|
||||||
@@ -292,7 +293,7 @@ class CartTab extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
validateAddressForm = () => {
|
validateAddressForm = () => {
|
||||||
const errors = CheckoutValidation.validateAddressForm(this.state);
|
const errors = CheckoutValidation.validateAddressForm(this.state, this.props.t);
|
||||||
this.setState({ addressFormErrors: errors });
|
this.setState({ addressFormErrors: errors });
|
||||||
return Object.keys(errors).length === 0;
|
return Object.keys(errors).length === 0;
|
||||||
};
|
};
|
||||||
@@ -322,7 +323,7 @@ class CartTab extends Component {
|
|||||||
handleCompleteOrder = () => {
|
handleCompleteOrder = () => {
|
||||||
this.setState({ completionError: null }); // Clear previous errors
|
this.setState({ completionError: null }); // Clear previous errors
|
||||||
|
|
||||||
const validationError = CheckoutValidation.getValidationErrorMessage(this.state);
|
const validationError = CheckoutValidation.getValidationErrorMessage(this.state, false, this.props.t);
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
this.setState({ completionError: validationError });
|
this.setState({ completionError: validationError });
|
||||||
this.validateAddressForm(); // To show field-specific errors
|
this.validateAddressForm(); // To show field-specific errors
|
||||||
@@ -363,6 +364,40 @@ class CartTab extends Component {
|
|||||||
this.orderService.createStripeIntent(totalAmount, this.loadStripeComponent);
|
this.orderService.createStripeIntent(totalAmount, this.loadStripeComponent);
|
||||||
return;
|
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
|
// Handle regular orders
|
||||||
const orderData = {
|
const orderData = {
|
||||||
@@ -405,7 +440,7 @@ class CartTab extends Component {
|
|||||||
const deliveryCost = this.orderService.getDeliveryCost();
|
const deliveryCost = this.orderService.getDeliveryCost();
|
||||||
const { isPickupOnly, hasStecklinge } = CheckoutValidation.getCartItemFlags(cartItems);
|
const { isPickupOnly, hasStecklinge } = CheckoutValidation.getCartItemFlags(cartItems);
|
||||||
|
|
||||||
const preSubmitError = CheckoutValidation.getValidationErrorMessage(this.state);
|
const preSubmitError = CheckoutValidation.getValidationErrorMessage(this.state, false, this.props.t);
|
||||||
const displayError = completionError || preSubmitError;
|
const displayError = completionError || preSubmitError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -445,7 +480,7 @@ class CartTab extends Component {
|
|||||||
{isLoadingStripe ? (
|
{isLoadingStripe ? (
|
||||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
<Typography variant="body1">
|
<Typography variant="body1">
|
||||||
Zahlungskomponente wird geladen...
|
{this.props.t ? this.props.t('payment.loadingPaymentComponent') : 'Zahlungskomponente wird geladen...'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : showStripePayment && StripeComponent ? (
|
) : showStripePayment && StripeComponent ? (
|
||||||
@@ -463,7 +498,7 @@ class CartTab extends Component {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
← Zurück zur Bestellung
|
{this.props.t ? this.props.t('cart.backToOrder') : '← Zurück zur Bestellung'}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
<StripeComponent clientSecret={stripeClientSecret} />
|
<StripeComponent clientSecret={stripeClientSecret} />
|
||||||
@@ -507,4 +542,4 @@ class CartTab extends Component {
|
|||||||
// Set static contextType to access the socket
|
// Set static contextType to access the socket
|
||||||
CartTab.contextType = SocketContext;
|
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 DeliveryMethodSelector from "./DeliveryMethodSelector.js";
|
||||||
import PaymentMethodSelector from "./PaymentMethodSelector.js";
|
import PaymentMethodSelector from "./PaymentMethodSelector.js";
|
||||||
import OrderSummary from "./OrderSummary.js";
|
import OrderSummary from "./OrderSummary.js";
|
||||||
|
import { withI18n } from "../../i18n/withTranslation.js";
|
||||||
|
|
||||||
class CheckoutForm extends Component {
|
class CheckoutForm extends Component {
|
||||||
render() {
|
render() {
|
||||||
@@ -40,7 +41,7 @@ class CheckoutForm extends Component {
|
|||||||
{paymentMethod !== "cash" && (
|
{paymentMethod !== "cash" && (
|
||||||
<>
|
<>
|
||||||
<AddressForm
|
<AddressForm
|
||||||
title="Rechnungsadresse"
|
title={this.props.t ? this.props.t('checkout.invoiceAddress') : 'Rechnungsadresse'}
|
||||||
address={invoiceAddress}
|
address={invoiceAddress}
|
||||||
onChange={onInvoiceAddressChange}
|
onChange={onInvoiceAddressChange}
|
||||||
errors={addressFormErrors}
|
errors={addressFormErrors}
|
||||||
@@ -57,7 +58,7 @@ class CheckoutForm extends Component {
|
|||||||
}
|
}
|
||||||
label={
|
label={
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
Für zukünftige Bestellungen speichern
|
{this.props.t ? this.props.t('checkout.saveForFuture') : 'Für zukünftige Bestellungen speichern'}
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
sx={{ mb: 2 }}
|
sx={{ mb: 2 }}
|
||||||
@@ -70,13 +71,12 @@ class CheckoutForm extends Component {
|
|||||||
variant="body1"
|
variant="body1"
|
||||||
sx={{ mb: 2, fontWeight: "bold", color: "#2e7d32" }}
|
sx={{ mb: 2, fontWeight: "bold", color: "#2e7d32" }}
|
||||||
>
|
>
|
||||||
Für welchen Termin ist die Abholung der Stecklinge
|
{this.props.t ? this.props.t('checkout.pickupDate') : 'Für welchen Termin ist die Abholung der Stecklinge gewünscht?'}
|
||||||
gewünscht?
|
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Anmerkung"
|
label={this.props.t ? this.props.t('checkout.note') : 'Anmerkung'}
|
||||||
name="note"
|
name="note"
|
||||||
value={note}
|
value={note}
|
||||||
onChange={onNoteChange}
|
onChange={onNoteChange}
|
||||||
@@ -93,6 +93,7 @@ class CheckoutForm extends Component {
|
|||||||
deliveryMethod={deliveryMethod}
|
deliveryMethod={deliveryMethod}
|
||||||
onChange={onDeliveryMethodChange}
|
onChange={onDeliveryMethodChange}
|
||||||
isPickupOnly={isPickupOnly || hasStecklinge}
|
isPickupOnly={isPickupOnly || hasStecklinge}
|
||||||
|
cartItems={cartItems}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(deliveryMethod === "DHL" || deliveryMethod === "DPD") && (
|
{(deliveryMethod === "DHL" || deliveryMethod === "DPD") && (
|
||||||
@@ -107,7 +108,7 @@ class CheckoutForm extends Component {
|
|||||||
}
|
}
|
||||||
label={
|
label={
|
||||||
<Typography variant="body1">
|
<Typography variant="body1">
|
||||||
Lieferadresse ist identisch mit Rechnungsadresse
|
{this.props.t ? this.props.t('checkout.sameAddress') : 'Lieferadresse ist identisch mit Rechnungsadresse'}
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
sx={{ mb: 2 }}
|
sx={{ mb: 2 }}
|
||||||
@@ -115,7 +116,7 @@ class CheckoutForm extends Component {
|
|||||||
|
|
||||||
{!useSameAddress && (
|
{!useSameAddress && (
|
||||||
<AddressForm
|
<AddressForm
|
||||||
title="Lieferadresse"
|
title={this.props.t ? this.props.t('checkout.deliveryAddress') : 'Lieferadresse'}
|
||||||
address={deliveryAddress}
|
address={deliveryAddress}
|
||||||
onChange={onDeliveryAddressChange}
|
onChange={onDeliveryAddressChange}
|
||||||
errors={addressFormErrors}
|
errors={addressFormErrors}
|
||||||
@@ -150,8 +151,7 @@ class CheckoutForm extends Component {
|
|||||||
}
|
}
|
||||||
label={
|
label={
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
Ich habe die AGBs, die Datenschutzerklärung und die
|
{this.props.t ? this.props.t('checkout.termsAccept') : 'Ich habe die AGBs, die Datenschutzerklärung und die Bestimmungen zum Widerrufsrecht gelesen'}
|
||||||
Bestimmungen zum Widerrufsrecht gelesen
|
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
sx={{ mb: 3, mt: 2 }}
|
sx={{ mb: 3, mt: 2 }}
|
||||||
@@ -174,12 +174,12 @@ class CheckoutForm extends Component {
|
|||||||
disabled={isCompletingOrder || !!preSubmitError}
|
disabled={isCompletingOrder || !!preSubmitError}
|
||||||
>
|
>
|
||||||
{isCompletingOrder
|
{isCompletingOrder
|
||||||
? "Bestellung wird verarbeitet..."
|
? (this.props.t ? this.props.t('checkout.processingOrder') : 'Bestellung wird verarbeitet...')
|
||||||
: "Bestellung abschließen"}
|
: (this.props.t ? this.props.t('checkout.completeOrder') : 'Bestellung abschließen')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CheckoutForm;
|
export default withI18n()(CheckoutForm);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
class CheckoutValidation {
|
class CheckoutValidation {
|
||||||
static validateAddressForm(state) {
|
static validateAddressForm(state, t = null) {
|
||||||
const {
|
const {
|
||||||
invoiceAddress,
|
invoiceAddress,
|
||||||
deliveryAddress,
|
deliveryAddress,
|
||||||
@@ -12,15 +12,15 @@ class CheckoutValidation {
|
|||||||
// Validate invoice address (skip if payment method is "cash")
|
// Validate invoice address (skip if payment method is "cash")
|
||||||
if (paymentMethod !== "cash") {
|
if (paymentMethod !== "cash") {
|
||||||
if (!invoiceAddress.firstName)
|
if (!invoiceAddress.firstName)
|
||||||
errors.invoiceFirstName = "Vorname erforderlich";
|
errors.invoiceFirstName = t ? t('checkout.validationErrors.firstNameRequired') : "Vorname erforderlich";
|
||||||
if (!invoiceAddress.lastName)
|
if (!invoiceAddress.lastName)
|
||||||
errors.invoiceLastName = "Nachname erforderlich";
|
errors.invoiceLastName = t ? t('checkout.validationErrors.lastNameRequired') : "Nachname erforderlich";
|
||||||
if (!invoiceAddress.street) errors.invoiceStreet = "Straße erforderlich";
|
if (!invoiceAddress.street) errors.invoiceStreet = t ? t('checkout.validationErrors.streetRequired') : "Straße erforderlich";
|
||||||
if (!invoiceAddress.houseNumber)
|
if (!invoiceAddress.houseNumber)
|
||||||
errors.invoiceHouseNumber = "Hausnummer erforderlich";
|
errors.invoiceHouseNumber = t ? t('checkout.validationErrors.houseNumberRequired') : "Hausnummer erforderlich";
|
||||||
if (!invoiceAddress.postalCode)
|
if (!invoiceAddress.postalCode)
|
||||||
errors.invoicePostalCode = "PLZ erforderlich";
|
errors.invoicePostalCode = t ? t('checkout.validationErrors.postalCodeRequired') : "PLZ erforderlich";
|
||||||
if (!invoiceAddress.city) errors.invoiceCity = "Stadt erforderlich";
|
if (!invoiceAddress.city) errors.invoiceCity = t ? t('checkout.validationErrors.cityRequired') : "Stadt erforderlich";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate delivery address for shipping methods that require it
|
// Validate delivery address for shipping methods that require it
|
||||||
@@ -29,37 +29,37 @@ class CheckoutValidation {
|
|||||||
(deliveryMethod === "DHL" || deliveryMethod === "DPD")
|
(deliveryMethod === "DHL" || deliveryMethod === "DPD")
|
||||||
) {
|
) {
|
||||||
if (!deliveryAddress.firstName)
|
if (!deliveryAddress.firstName)
|
||||||
errors.deliveryFirstName = "Vorname erforderlich";
|
errors.deliveryFirstName = t ? t('checkout.validationErrors.firstNameRequired') : "Vorname erforderlich";
|
||||||
if (!deliveryAddress.lastName)
|
if (!deliveryAddress.lastName)
|
||||||
errors.deliveryLastName = "Nachname erforderlich";
|
errors.deliveryLastName = t ? t('checkout.validationErrors.lastNameRequired') : "Nachname erforderlich";
|
||||||
if (!deliveryAddress.street)
|
if (!deliveryAddress.street)
|
||||||
errors.deliveryStreet = "Straße erforderlich";
|
errors.deliveryStreet = t ? t('checkout.validationErrors.streetRequired') : "Straße erforderlich";
|
||||||
if (!deliveryAddress.houseNumber)
|
if (!deliveryAddress.houseNumber)
|
||||||
errors.deliveryHouseNumber = "Hausnummer erforderlich";
|
errors.deliveryHouseNumber = t ? t('checkout.validationErrors.houseNumberRequired') : "Hausnummer erforderlich";
|
||||||
if (!deliveryAddress.postalCode)
|
if (!deliveryAddress.postalCode)
|
||||||
errors.deliveryPostalCode = "PLZ erforderlich";
|
errors.deliveryPostalCode = t ? t('checkout.validationErrors.postalCodeRequired') : "PLZ erforderlich";
|
||||||
if (!deliveryAddress.city) errors.deliveryCity = "Stadt erforderlich";
|
if (!deliveryAddress.city) errors.deliveryCity = t ? t('checkout.validationErrors.cityRequired') : "Stadt erforderlich";
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
static getValidationErrorMessage(state, isAddressOnly = false) {
|
static getValidationErrorMessage(state, isAddressOnly = false, t = null) {
|
||||||
const { termsAccepted } = state;
|
const { termsAccepted } = state;
|
||||||
|
|
||||||
const addressErrors = this.validateAddressForm(state);
|
const addressErrors = this.validateAddressForm(state, t);
|
||||||
|
|
||||||
if (isAddressOnly) {
|
if (isAddressOnly) {
|
||||||
return addressErrors;
|
return addressErrors;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(addressErrors).length > 0) {
|
if (Object.keys(addressErrors).length > 0) {
|
||||||
return "Bitte überprüfen Sie Ihre Eingaben in den Adressfeldern.";
|
return t ? t('checkout.addressValidationError') : "Bitte überprüfen Sie Ihre Eingaben in den Adressfeldern.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate terms acceptance
|
// Validate terms acceptance
|
||||||
if (!termsAccepted) {
|
if (!termsAccepted) {
|
||||||
return "Bitte akzeptieren Sie die AGBs, Datenschutzerklärung und Widerrufsrecht, um fortzufahren.";
|
return t ? t('checkout.termsValidationError') : "Bitte akzeptieren Sie die AGBs, Datenschutzerklärung und Widerrufsrecht, um fortzufahren.";
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -82,7 +82,7 @@ class CheckoutValidation {
|
|||||||
|
|
||||||
// Prefer stripe when available and meets minimum amount
|
// Prefer stripe when available and meets minimum amount
|
||||||
if (deliveryMethod === "DHL" || deliveryMethod === "DPD" || deliveryMethod === "Abholung") {
|
if (deliveryMethod === "DHL" || deliveryMethod === "DPD" || deliveryMethod === "Abholung") {
|
||||||
return "stripe";
|
return "wire";/*stripe*/
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to wire transfer
|
// Fall back to wire transfer
|
||||||
@@ -106,11 +106,21 @@ class CheckoutValidation {
|
|||||||
newPaymentMethod = "wire";
|
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
|
// Check minimum amount for stripe payments
|
||||||
if (paymentMethod === "stripe" && totalAmount < 0.50) {
|
if (paymentMethod === "stripe" && totalAmount < 0.50) {
|
||||||
newPaymentMethod = "wire";
|
newPaymentMethod = "wire";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check minimum amount for mollie payments
|
||||||
|
if (paymentMethod === "mollie" && totalAmount < 0.50) {
|
||||||
|
newPaymentMethod = "wire";
|
||||||
|
}
|
||||||
|
|
||||||
if (deliveryMethod !== "Abholung" && paymentMethod === "cash") {
|
if (deliveryMethod !== "Abholung" && paymentMethod === "cash") {
|
||||||
newPaymentMethod = "wire";
|
newPaymentMethod = "wire";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,34 +3,42 @@ import Box from '@mui/material/Box';
|
|||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import Radio from '@mui/material/Radio';
|
import Radio from '@mui/material/Radio';
|
||||||
import Checkbox from '@mui/material/Checkbox';
|
import Checkbox from '@mui/material/Checkbox';
|
||||||
|
import { withI18n } from '../../i18n/withTranslation.js';
|
||||||
|
|
||||||
|
const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartItems = [], 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 = [
|
const deliveryOptions = [
|
||||||
{
|
{
|
||||||
id: 'DHL',
|
id: 'DHL',
|
||||||
name: 'DHL',
|
name: 'DHL',
|
||||||
description: isPickupOnly ? "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können" : 'Standardversand',
|
description: isPickupOnly ? (t ? t('delivery.descriptions.notAvailable') : "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können") :
|
||||||
price: '6,99 €',
|
isFreeShipping ? (t ? t('delivery.descriptions.standardFree') : 'Standardversand - KOSTENLOS ab 100€ Warenwert!') : (t ? t('delivery.descriptions.standard') : 'Standardversand'),
|
||||||
|
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dhl') : '6,99 €'),
|
||||||
disabled: isPickupOnly
|
disabled: isPickupOnly
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'DPD',
|
id: 'DPD',
|
||||||
name: 'DPD',
|
name: 'DPD',
|
||||||
description: isPickupOnly ? "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können" : 'Standardversand',
|
description: isPickupOnly ? (t ? t('delivery.descriptions.notAvailable') : "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können") :
|
||||||
price: '4,90 €',
|
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
|
disabled: isPickupOnly
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'Sperrgut',
|
id: 'Sperrgut',
|
||||||
name: 'Sperrgut',
|
name: t ? t('delivery.methods.sperrgutName') : 'Sperrgut',
|
||||||
description: 'Für große und schwere Artikel',
|
description: t ? t('delivery.descriptions.bulky') : 'Für große und schwere Artikel',
|
||||||
price: '28,99 €',
|
price: t ? t('delivery.prices.sperrgut') : '28,99 €',
|
||||||
disabled: true,
|
disabled: true,
|
||||||
isCheckbox: true
|
isCheckbox: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'Abholung',
|
id: 'Abholung',
|
||||||
name: 'Abholung in der Filiale',
|
name: t ? t('delivery.methods.pickup') : 'Abholung in der Filiale',
|
||||||
description: '',
|
description: '',
|
||||||
price: ''
|
price: ''
|
||||||
}
|
}
|
||||||
@@ -39,7 +47,7 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
Versandart wählen
|
{t ? t('delivery.selector.title') : 'Versandart wählen'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ mb: 3 }}>
|
<Box sx={{ mb: 3 }}>
|
||||||
@@ -114,9 +122,44 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly }) => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DeliveryMethodSelector;
|
export default withI18n()(DeliveryMethodSelector);
|
||||||
@@ -15,22 +15,36 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
Paper
|
Paper
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const OrderDetailsDialog = ({ open, onClose, order }) => {
|
const OrderDetailsDialog = ({ open, onClose, order }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!order) {
|
if (!order) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currencyFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
|
const currencyFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
|
||||||
|
|
||||||
|
// Helper function to translate payment methods
|
||||||
|
const getPaymentMethodDisplay = (paymentMethod) => {
|
||||||
|
if (!paymentMethod) return t('orders.details.notSpecified');
|
||||||
|
|
||||||
|
switch (paymentMethod.toLowerCase()) {
|
||||||
|
case 'wire':
|
||||||
|
return t('payment.methods.bankTransfer');
|
||||||
|
default:
|
||||||
|
return paymentMethod;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCancelOrder = () => {
|
const handleCancelOrder = () => {
|
||||||
// Implement order cancellation logic here
|
// Implement order cancellation logic here
|
||||||
console.log(`Cancel order: ${order.orderId}`);
|
console.log(`Cancel order: ${order.orderId}`);
|
||||||
onClose(); // Close the dialog after action
|
onClose(); // Close the dialog after action
|
||||||
};
|
};
|
||||||
|
|
||||||
const subtotal = order.items.reduce((acc, item) => acc + item.price * item.quantity_ordered, 0);
|
const total = order.items.reduce((acc, item) => acc + item.price * item.quantity_ordered, 0);
|
||||||
const total = subtotal + order.delivery_cost;
|
|
||||||
|
|
||||||
// Calculate VAT breakdown similar to CartDropdown
|
// Calculate VAT breakdown similar to CartDropdown
|
||||||
const vatCalculations = order.items.reduce((acc, item) => {
|
const vatCalculations = order.items.reduce((acc, item) => {
|
||||||
@@ -52,10 +66,10 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||||
<DialogTitle>Bestelldetails: {order.orderId}</DialogTitle>
|
<DialogTitle>{t('orders.details.title', { orderId: order.orderId })}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Typography variant="h6">Lieferadresse</Typography>
|
<Typography variant="h6">{t('orders.details.deliveryAddress')}</Typography>
|
||||||
<Typography>{order.shipping_address_name}</Typography>
|
<Typography>{order.shipping_address_name}</Typography>
|
||||||
<Typography>{order.shipping_address_street} {order.shipping_address_house_number}</Typography>
|
<Typography>{order.shipping_address_street} {order.shipping_address_house_number}</Typography>
|
||||||
<Typography>{order.shipping_address_postal_code} {order.shipping_address_city}</Typography>
|
<Typography>{order.shipping_address_postal_code} {order.shipping_address_city}</Typography>
|
||||||
@@ -63,7 +77,7 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Typography variant="h6">Rechnungsadresse</Typography>
|
<Typography variant="h6">{t('orders.details.invoiceAddress')}</Typography>
|
||||||
<Typography>{order.invoice_address_name}</Typography>
|
<Typography>{order.invoice_address_name}</Typography>
|
||||||
<Typography>{order.invoice_address_street} {order.invoice_address_house_number}</Typography>
|
<Typography>{order.invoice_address_street} {order.invoice_address_house_number}</Typography>
|
||||||
<Typography>{order.invoice_address_postal_code} {order.invoice_address_city}</Typography>
|
<Typography>{order.invoice_address_postal_code} {order.invoice_address_city}</Typography>
|
||||||
@@ -72,28 +86,29 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
|||||||
|
|
||||||
{/* Order Details Section */}
|
{/* Order Details Section */}
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Typography variant="h6" gutterBottom>Bestelldetails</Typography>
|
<Typography variant="h6" gutterBottom>{t('orders.details.orderDetails')}</Typography>
|
||||||
<Box sx={{ display: 'flex', gap: 4 }}>
|
<Box sx={{ display: 'flex', gap: 4 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body2" color="text.secondary">Lieferart:</Typography>
|
<Typography variant="body2" color="text.secondary">{t('orders.details.deliveryMethod')}</Typography>
|
||||||
<Typography variant="body1">{order.deliveryMethod || order.delivery_method || 'Nicht angegeben'}</Typography>
|
<Typography variant="body1">{order.deliveryMethod || order.delivery_method || t('orders.details.notSpecified')}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body2" color="text.secondary">Zahlungsart:</Typography>
|
<Typography variant="body2" color="text.secondary">{t('orders.details.paymentMethod')}</Typography>
|
||||||
<Typography variant="body1">{order.paymentMethod || order.payment_method || 'Nicht angegeben'}</Typography>
|
<Typography variant="body1">{getPaymentMethodDisplay(order.paymentMethod || order.payment_method)}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="h6" gutterBottom>Bestellte Artikel</Typography>
|
<Typography variant="h6" gutterBottom>{t('orders.details.orderedItems')}</Typography>
|
||||||
<TableContainer component={Paper}>
|
<TableContainer component={Paper}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Artikel</TableCell>
|
<TableCell>{t('orders.details.item')}</TableCell>
|
||||||
<TableCell align="right">Menge</TableCell>
|
<TableCell align="right">{t('orders.details.quantity')}</TableCell>
|
||||||
<TableCell align="right">Preis</TableCell>
|
<TableCell align="right">{t('orders.details.price')}</TableCell>
|
||||||
<TableCell align="right">Gesamt</TableCell>
|
<TableCell align="right">{t('product.vatShort')}</TableCell>
|
||||||
|
<TableCell align="right">{t('orders.details.total')}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -102,13 +117,13 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
|||||||
<TableCell>{item.name}</TableCell>
|
<TableCell>{item.name}</TableCell>
|
||||||
<TableCell align="right">{item.quantity_ordered}</TableCell>
|
<TableCell align="right">{item.quantity_ordered}</TableCell>
|
||||||
<TableCell align="right">{currencyFormatter.format(item.price)}</TableCell>
|
<TableCell align="right">{currencyFormatter.format(item.price)}</TableCell>
|
||||||
|
<TableCell align="right">{item.vat}%</TableCell>
|
||||||
<TableCell align="right">{currencyFormatter.format(item.price * item.quantity_ordered)}</TableCell>
|
<TableCell align="right">{currencyFormatter.format(item.price * item.quantity_ordered)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={2} />
|
<TableCell colSpan={4} align="right">
|
||||||
<TableCell align="right">
|
<Typography fontWeight="bold">{t ? t('tax.totalNet') : 'Gesamtnettopreis'}</Typography>
|
||||||
<Typography fontWeight="bold">Gesamtnettopreis</Typography>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
<Typography fontWeight="bold">{currencyFormatter.format(vatCalculations.totalNet)}</Typography>
|
<Typography fontWeight="bold">{currencyFormatter.format(vatCalculations.totalNet)}</Typography>
|
||||||
@@ -116,36 +131,19 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
{vatCalculations.vat7 > 0 && (
|
{vatCalculations.vat7 > 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={2} />
|
<TableCell colSpan={4} align="right">{t ? t('tax.vat7') : '7% Mehrwertsteuer'}</TableCell>
|
||||||
<TableCell align="right">7% Mehrwertsteuer</TableCell>
|
|
||||||
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat7)}</TableCell>
|
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat7)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{vatCalculations.vat19 > 0 && (
|
{vatCalculations.vat19 > 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={2} />
|
<TableCell colSpan={4} align="right">{t ? t('tax.vat19') : '19% Mehrwertsteuer'}</TableCell>
|
||||||
<TableCell align="right">19% Mehrwertsteuer</TableCell>
|
|
||||||
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat19)}</TableCell>
|
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat19)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={2} />
|
<TableCell colSpan={4} align="right">
|
||||||
<TableCell align="right">
|
<Typography fontWeight="bold">{t ? t('cart.summary.total') : 'Gesamtsumme'}</Typography>
|
||||||
<Typography fontWeight="bold">Zwischensumme</Typography>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
<Typography fontWeight="bold">{currencyFormatter.format(subtotal)}</Typography>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={2} />
|
|
||||||
<TableCell align="right">Lieferkosten</TableCell>
|
|
||||||
<TableCell align="right">{currencyFormatter.format(order.delivery_cost)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={2} />
|
|
||||||
<TableCell align="right">
|
|
||||||
<Typography fontWeight="bold">Gesamtsumme</Typography>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
<Typography fontWeight="bold">{currencyFormatter.format(total)}</Typography>
|
<Typography fontWeight="bold">{currencyFormatter.format(total)}</Typography>
|
||||||
@@ -159,10 +157,10 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
|
|||||||
<DialogActions>
|
<DialogActions>
|
||||||
{order.status === 'new' && (
|
{order.status === 'new' && (
|
||||||
<Button onClick={handleCancelOrder} color="error">
|
<Button onClick={handleCancelOrder} color="error">
|
||||||
Bestellung stornieren
|
{t('orders.details.cancelOrder')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={onClose}>Schließen</Button>
|
<Button onClick={onClose}>{t ? t('common.close') : 'Schließen'}</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,18 +49,29 @@ class OrderProcessingService {
|
|||||||
waitForVerifyTokenAndProcessOrder() {
|
waitForVerifyTokenAndProcessOrder() {
|
||||||
// Check if window.cart is already populated (verifyToken already completed)
|
// Check if window.cart is already populated (verifyToken already completed)
|
||||||
if (Array.isArray(window.cart) && window.cart.length > 0) {
|
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);
|
this.processStripeOrderWithCart(window.cart);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for cart event which is dispatched after verifyToken completes
|
// Listen for cart event which is dispatched after verifyToken completes
|
||||||
this.verifyTokenHandler = () => {
|
this.verifyTokenHandler = () => {
|
||||||
if (Array.isArray(window.cart) && window.cart.length > 0) {
|
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
|
// Clear window.cart after copying
|
||||||
window.cart = [];
|
window.cart = [];
|
||||||
window.dispatchEvent(new CustomEvent("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 {
|
} else {
|
||||||
this.setState({
|
this.setState({
|
||||||
completionError: "Cart is empty. Please add items to your cart before placing an order."
|
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() {
|
processStripeOrder() {
|
||||||
// If no original cart items, don't process
|
// If no original cart items, don't process
|
||||||
if (!this.getState().originalCartItems || this.getState().originalCartItems.length === 0) {
|
if (!this.getState().originalCartItems || this.getState().originalCartItems.length === 0) {
|
||||||
@@ -205,6 +231,20 @@ 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
|
// Process regular (non-Stripe) orders
|
||||||
processRegularOrder(orderData) {
|
processRegularOrder(orderData) {
|
||||||
const context = this.getContext();
|
const context = this.getContext();
|
||||||
@@ -271,9 +311,43 @@ class OrderProcessingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create Mollie payment intent
|
||||||
|
createMollieIntent(mollieOrderData) {
|
||||||
|
const context = this.getContext();
|
||||||
|
if (context && context.socket && context.socket.connected) {
|
||||||
|
context.socket.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("Error:", response.error);
|
||||||
|
this.setState({
|
||||||
|
isCompletingOrder: false,
|
||||||
|
completionError: response.error || "Failed to create Mollie payment intent. Please try again.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error("Socket context not available");
|
||||||
|
this.setState({
|
||||||
|
isCompletingOrder: false,
|
||||||
|
completionError: "Cannot connect to server. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate delivery cost
|
// Calculate delivery cost
|
||||||
getDeliveryCost() {
|
getDeliveryCost() {
|
||||||
const { deliveryMethod, paymentMethod } = this.getState();
|
const { deliveryMethod, paymentMethod, cartItems } = this.getState();
|
||||||
let cost = 0;
|
let cost = 0;
|
||||||
|
|
||||||
switch (deliveryMethod) {
|
switch (deliveryMethod) {
|
||||||
@@ -293,7 +367,16 @@ class OrderProcessingService {
|
|||||||
cost = 6.99;
|
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") {
|
if (paymentMethod === "onDelivery") {
|
||||||
cost += 8.99;
|
cost += 8.99;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import Table from '@mui/material/Table';
|
|||||||
import TableBody from '@mui/material/TableBody';
|
import TableBody from '@mui/material/TableBody';
|
||||||
import TableCell from '@mui/material/TableCell';
|
import TableCell from '@mui/material/TableCell';
|
||||||
import TableRow from '@mui/material/TableRow';
|
import TableRow from '@mui/material/TableRow';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
|
const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const currencyFormatter = new Intl.NumberFormat('de-DE', {
|
const currencyFormatter = new Intl.NumberFormat('de-DE', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'EUR'
|
currency: 'EUR'
|
||||||
@@ -30,9 +32,9 @@ const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
|
|||||||
return acc;
|
return acc;
|
||||||
}, { totalGross: 0, totalNet: 0, vat7: 0, vat19: 0 });
|
}, { totalGross: 0, totalNet: 0, vat7: 0, vat19: 0 });
|
||||||
|
|
||||||
// Calculate shipping VAT (19% VAT for shipping costs)
|
// Calculate shipping VAT (19% VAT for shipping costs) - only if there are shipping costs
|
||||||
const shippingNetPrice = deliveryCost / (1 + 19 / 100);
|
const shippingNetPrice = deliveryCost > 0 ? deliveryCost / (1 + 19 / 100) : 0;
|
||||||
const shippingVat = deliveryCost - shippingNetPrice;
|
const shippingVat = deliveryCost > 0 ? deliveryCost - shippingNetPrice : 0;
|
||||||
|
|
||||||
// Combine totals - add shipping VAT to the 19% VAT total
|
// Combine totals - add shipping VAT to the 19% VAT total
|
||||||
const totalVat7 = cartVatCalculations.vat7;
|
const totalVat7 = cartVatCalculations.vat7;
|
||||||
@@ -42,20 +44,20 @@ const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ my: 3, p: 2, bgcolor: '#f5f5f5', borderRadius: 1 }}>
|
<Box sx={{ my: 3, p: 2, bgcolor: '#f5f5f5', borderRadius: 1 }}>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
Bestellübersicht
|
{t ? t('cart.summary.title') : 'Bestellübersicht'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Waren (netto):</TableCell>
|
<TableCell>{t ? t('cart.summary.goodsNet') : 'Waren (netto):'}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{currencyFormatter.format(cartVatCalculations.totalNet)}
|
{currencyFormatter.format(cartVatCalculations.totalNet)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{deliveryCost > 0 && (
|
{deliveryCost > 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Versandkosten (netto):</TableCell>
|
<TableCell>{t ? t('cart.summary.shippingNet') : 'Versandkosten (netto):'}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{currencyFormatter.format(shippingNetPrice)}
|
{currencyFormatter.format(shippingNetPrice)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -63,7 +65,7 @@ const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
|
|||||||
)}
|
)}
|
||||||
{totalVat7 > 0 && (
|
{totalVat7 > 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>7% Mehrwertsteuer:</TableCell>
|
<TableCell>{t ? t('tax.vat7') : '7% Mehrwertsteuer'}:</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{currencyFormatter.format(totalVat7)}
|
{currencyFormatter.format(totalVat7)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -71,28 +73,37 @@ const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
|
|||||||
)}
|
)}
|
||||||
{totalVat19 > 0 && (
|
{totalVat19 > 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>19% Mehrwertsteuer (inkl. Versand):</TableCell>
|
<TableCell>{t ? t('tax.vat19WithShipping') : '19% Mehrwertsteuer (inkl. Versand)'}:</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{currencyFormatter.format(totalVat19)}
|
{currencyFormatter.format(totalVat19)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtsumme Waren:</TableCell>
|
<TableCell sx={{ fontWeight: 'bold' }}>{t ? t('cart.summary.totalGoods') : 'Gesamtsumme Waren:'}</TableCell>
|
||||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||||
{currencyFormatter.format(cartVatCalculations.totalGross)}
|
{currencyFormatter.format(cartVatCalculations.totalGross)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{deliveryCost > 0 && (
|
|
||||||
<TableRow>
|
<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' }}>
|
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
|
||||||
<TableRow sx={{ borderTop: '1px solid #e0e0e0' }}>
|
<TableRow sx={{ borderTop: '1px solid #e0e0e0' }}>
|
||||||
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>Gesamtsumme:</TableCell>
|
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>{t ? t('cart.summary.total') : 'Gesamtsumme:'}</TableCell>
|
||||||
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
|
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
|
||||||
{currencyFormatter.format(totalGross)}
|
{currencyFormatter.format(totalGross)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect, useContext, useCallback } from "react";
|
import React, { useState, useEffect, useContext, useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { withI18n } from "../../i18n/withTranslation.js";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -14,19 +15,28 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Typography,
|
Typography,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import SearchIcon from "@mui/icons-material/Search";
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
|
import CancelIcon from "@mui/icons-material/Cancel";
|
||||||
import SocketContext from "../../contexts/SocketContext.js";
|
import SocketContext from "../../contexts/SocketContext.js";
|
||||||
import OrderDetailsDialog from "./OrderDetailsDialog.js";
|
import OrderDetailsDialog from "./OrderDetailsDialog.js";
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const statusTranslations = {
|
const getStatusTranslation = (status, t) => {
|
||||||
new: "in Bearbeitung",
|
const statusMap = {
|
||||||
pending: "Neu",
|
new: t ? t('orders.status.new') : "in Bearbeitung",
|
||||||
processing: "in Bearbeitung",
|
pending: t ? t('orders.status.pending') : "Neu",
|
||||||
cancelled: "Storniert",
|
processing: t ? t('orders.status.processing') : "in Bearbeitung",
|
||||||
shipped: "Verschickt",
|
cancelled: t ? t('orders.status.cancelled') : "Storniert",
|
||||||
delivered: "Geliefert",
|
shipped: t ? t('orders.status.shipped') : "Verschickt",
|
||||||
|
delivered: t ? t('orders.status.delivered') : "Geliefert",
|
||||||
|
};
|
||||||
|
return statusMap[status] || status;
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusEmojis = {
|
const statusEmojis = {
|
||||||
@@ -61,12 +71,15 @@ const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Orders Tab Content Component
|
// Orders Tab Content Component
|
||||||
const OrdersTab = ({ orderIdFromHash }) => {
|
const OrdersTab = ({ orderIdFromHash, t }) => {
|
||||||
const [orders, setOrders] = useState([]);
|
const [orders, setOrders] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [selectedOrder, setSelectedOrder] = useState(null);
|
const [selectedOrder, setSelectedOrder] = useState(null);
|
||||||
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
|
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
|
||||||
|
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
|
||||||
|
const [orderToCancel, setOrderToCancel] = useState(null);
|
||||||
|
const [isCancelling, setIsCancelling] = useState(false);
|
||||||
|
|
||||||
const {socket} = useContext(SocketContext);
|
const {socket} = useContext(SocketContext);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -77,9 +90,11 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
|||||||
if (orderToView) {
|
if (orderToView) {
|
||||||
setSelectedOrder(orderToView);
|
setSelectedOrder(orderToView);
|
||||||
setIsDetailsDialogOpen(true);
|
setIsDetailsDialogOpen(true);
|
||||||
|
// Update the hash to include the order ID
|
||||||
|
navigate(`/profile#${orderId}`, { replace: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[orders]
|
[orders, navigate]
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchOrders = useCallback(() => {
|
const fetchOrders = useCallback(() => {
|
||||||
@@ -120,7 +135,7 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
|||||||
}, [orderIdFromHash, orders, handleViewDetails]);
|
}, [orderIdFromHash, orders, handleViewDetails]);
|
||||||
|
|
||||||
const getStatusDisplay = (status) => {
|
const getStatusDisplay = (status) => {
|
||||||
return statusTranslations[status] || status;
|
return getStatusTranslation(status, t);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusEmoji = (status) => {
|
const getStatusEmoji = (status) => {
|
||||||
@@ -134,7 +149,48 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
|||||||
const handleCloseDetailsDialog = () => {
|
const handleCloseDetailsDialog = () => {
|
||||||
setIsDetailsDialogOpen(false);
|
setIsDetailsDialogOpen(false);
|
||||||
setSelectedOrder(null);
|
setSelectedOrder(null);
|
||||||
navigate("/profile", { replace: true });
|
navigate("/profile#orders", { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if order can be cancelled
|
||||||
|
const isOrderCancelable = (order) => {
|
||||||
|
const cancelableStatuses = ['new', 'pending', 'processing'];
|
||||||
|
return cancelableStatuses.includes(order.status);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle cancel button click
|
||||||
|
const handleCancelClick = (order) => {
|
||||||
|
setOrderToCancel(order);
|
||||||
|
setCancelConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle cancel confirmation
|
||||||
|
const handleConfirmCancel = () => {
|
||||||
|
if (!orderToCancel || !socket) return;
|
||||||
|
|
||||||
|
setIsCancelling(true);
|
||||||
|
socket.emit('cancelOrder', { orderId: orderToCancel.orderId }, (response) => {
|
||||||
|
setIsCancelling(false);
|
||||||
|
setCancelConfirmOpen(false);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
console.log('Order cancelled:', response.orderId);
|
||||||
|
// Refresh orders list
|
||||||
|
fetchOrders();
|
||||||
|
} else {
|
||||||
|
setError(response.error || 'Failed to cancel order');
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrderToCancel(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle cancel dialog close
|
||||||
|
const handleCancelDialogClose = () => {
|
||||||
|
if (!isCancelling) {
|
||||||
|
setCancelConfirmOpen(false);
|
||||||
|
setOrderToCancel(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -160,22 +216,21 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Bestellnummer</TableCell>
|
<TableCell>{t ? t('orders.table.orderNumber') : 'Bestellnummer'}</TableCell>
|
||||||
<TableCell>Datum</TableCell>
|
<TableCell>{t ? t('orders.table.date') : 'Datum'}</TableCell>
|
||||||
<TableCell>Status</TableCell>
|
<TableCell>{t ? t('orders.table.status') : 'Status'}</TableCell>
|
||||||
<TableCell>Artikel</TableCell>
|
<TableCell>{t ? t('orders.table.items') : 'Artikel'}</TableCell>
|
||||||
<TableCell align="right">Summe</TableCell>
|
<TableCell align="right">{t ? t('orders.table.total') : 'Summe'}</TableCell>
|
||||||
<TableCell align="center">Aktionen</TableCell>
|
<TableCell align="center">{t ? t('orders.table.actions') : 'Aktionen'}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{orders.map((order) => {
|
{orders.map((order) => {
|
||||||
const displayStatus = getStatusDisplay(order.status);
|
const displayStatus = getStatusDisplay(order.status);
|
||||||
const subtotal = order.items.reduce(
|
const total = order.items.reduce(
|
||||||
(acc, item) => acc + item.price * item.quantity_ordered,
|
(acc, item) => acc + item.price * item.quantity_ordered,
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
const total = subtotal + order.delivery_cost;
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={order.orderId} hover>
|
<TableRow key={order.orderId} hover>
|
||||||
<TableCell>{order.orderId}</TableCell>
|
<TableCell>{order.orderId}</TableCell>
|
||||||
@@ -204,7 +259,16 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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,
|
(acc, item) => acc + item.quantity_ordered,
|
||||||
0
|
0
|
||||||
)}
|
)}
|
||||||
@@ -213,7 +277,8 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
|||||||
{currencyFormatter.format(total)}
|
{currencyFormatter.format(total)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="center">
|
<TableCell align="center">
|
||||||
<Tooltip title="Details anzeigen">
|
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center' }}>
|
||||||
|
<Tooltip title={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -222,6 +287,18 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
|||||||
<SearchIcon />
|
<SearchIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{isOrderCancelable(order) && (
|
||||||
|
<Tooltip title={t ? t('orders.tooltips.cancelOrder') : 'Bestellung stornieren'}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => handleCancelClick(order)}
|
||||||
|
>
|
||||||
|
<CancelIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
@@ -231,7 +308,7 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
|||||||
</TableContainer>
|
</TableContainer>
|
||||||
) : (
|
) : (
|
||||||
<Alert severity="info">
|
<Alert severity="info">
|
||||||
Sie haben noch keine Bestellungen aufgegeben.
|
{t ? t('orders.noOrders') : 'Sie haben noch keine Bestellungen aufgegeben.'}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<OrderDetailsDialog
|
<OrderDetailsDialog
|
||||||
@@ -239,8 +316,49 @@ const OrdersTab = ({ orderIdFromHash }) => {
|
|||||||
onClose={handleCloseDetailsDialog}
|
onClose={handleCloseDetailsDialog}
|
||||||
order={selectedOrder}
|
order={selectedOrder}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Cancel Confirmation Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={cancelConfirmOpen}
|
||||||
|
onClose={handleCancelDialogClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
{t ? t('orders.cancelConfirm.title') : 'Bestellung stornieren'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
{t ? t('orders.cancelConfirm.message') : 'Sind Sie sicher, dass Sie diese Bestellung stornieren möchten?'}
|
||||||
|
</Typography>
|
||||||
|
{orderToCancel && (
|
||||||
|
<Typography variant="body2" sx={{ mt: 1, fontWeight: 'bold' }}>
|
||||||
|
{t ? t('orders.table.orderNumber') : 'Bestellnummer'}: {orderToCancel.orderId}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
onClick={handleCancelDialogClose}
|
||||||
|
disabled={isCancelling}
|
||||||
|
>
|
||||||
|
{t ? t('common.cancel') : 'Abbrechen'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmCancel}
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
disabled={isCancelling}
|
||||||
|
>
|
||||||
|
{isCancelling
|
||||||
|
? (t ? t('orders.cancelConfirm.cancelling') : 'Wird storniert...')
|
||||||
|
: (t ? t('orders.cancelConfirm.confirm') : 'Stornieren')
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default OrdersTab;
|
export default withI18n()(OrdersTab);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { Component } from "react";
|
import React, { Component } from "react";
|
||||||
import { Box, Typography, Button } from "@mui/material";
|
import { Box, Typography, Button } from "@mui/material";
|
||||||
|
import { withI18n } from "../../i18n/withTranslation.js";
|
||||||
|
|
||||||
class PaymentConfirmationDialog extends Component {
|
class PaymentConfirmationDialog extends Component {
|
||||||
render() {
|
render() {
|
||||||
@@ -28,30 +29,32 @@ class PaymentConfirmationDialog extends Component {
|
|||||||
color: paymentCompletionData.isSuccessful ? '#2e7d32' : '#d32f2f',
|
color: paymentCompletionData.isSuccessful ? '#2e7d32' : '#d32f2f',
|
||||||
fontWeight: 'bold'
|
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>
|
</Typography>
|
||||||
|
|
||||||
{paymentCompletionData.isSuccessful ? (
|
{paymentCompletionData.isSuccessful ? (
|
||||||
<>
|
<>
|
||||||
{orderCompleted ? (
|
{orderCompleted ? (
|
||||||
<Typography variant="body1" sx={{ mt: 2, color: '#2e7d32' }}>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<Typography variant="body1" sx={{ mt: 2, color: '#2e7d32' }}>
|
<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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Typography variant="body1" sx={{ mt: 2, color: '#d32f2f' }}>
|
<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>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isCompletingOrder && (
|
{isCompletingOrder && (
|
||||||
<Typography variant="body2" sx={{ mt: 2, color: '#2e7d32', p: 2, bgcolor: '#e8f5e8', borderRadius: 1 }}>
|
<Typography variant="body2" sx={{ mt: 2, color: '#2e7d32', p: 2, bgcolor: '#e8f5e8', borderRadius: 1 }}>
|
||||||
Bestellung wird abgeschlossen...
|
{this.props.t ? this.props.t('orders.processing') : 'Bestellung wird abgeschlossen...'}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -75,7 +78,7 @@ class PaymentConfirmationDialog extends Component {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Weiter einkaufen
|
{this.props.t ? this.props.t('cart.continueShopping') : 'Weiter einkaufen'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={onViewOrders}
|
onClick={onViewOrders}
|
||||||
@@ -85,7 +88,7 @@ class PaymentConfirmationDialog extends Component {
|
|||||||
'&:hover': { bgcolor: '#1b5e20' }
|
'&:hover': { bgcolor: '#1b5e20' }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Zu meinen Bestellungen
|
{this.props.t ? this.props.t('payment.viewOrders') : 'Zu meinen Bestellungen'}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</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 React, { useEffect, useCallback } from "react";
|
||||||
import { Box, Typography, Radio } from "@mui/material";
|
import { Box, Typography, Radio } from "@mui/material";
|
||||||
|
import { withI18n } from "../../i18n/withTranslation.js";
|
||||||
|
|
||||||
const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeliveryMethodChange, cartItems = [], deliveryCost = 0 }) => {
|
const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeliveryMethodChange, cartItems = [], deliveryCost = 0, t }) => {
|
||||||
|
|
||||||
// Calculate total amount
|
// Calculate total amount
|
||||||
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
|
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
|
||||||
@@ -24,7 +25,7 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
|
|||||||
// Handle delivery method changes - auto-switch to stripe when DHL/DPD is selected
|
// Handle delivery method changes - auto-switch to stripe when DHL/DPD is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((deliveryMethod === "DHL" || deliveryMethod === "DPD") && paymentMethod === "cash") {
|
if ((deliveryMethod === "DHL" || deliveryMethod === "DPD") && paymentMethod === "cash") {
|
||||||
handlePaymentMethodChange({ target: { value: "stripe" } });
|
handlePaymentMethodChange({ target: { value: "wire" /*stripe*/ } });
|
||||||
}
|
}
|
||||||
}, [deliveryMethod, paymentMethod, handlePaymentMethodChange]);
|
}, [deliveryMethod, paymentMethod, handlePaymentMethodChange]);
|
||||||
|
|
||||||
@@ -38,11 +39,11 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
|
|||||||
const paymentOptions = [
|
const paymentOptions = [
|
||||||
{
|
{
|
||||||
id: "wire",
|
id: "wire",
|
||||||
name: "Überweisung",
|
name: t ? t('payment.methods.bankTransfer') : "Überweisung",
|
||||||
description: "Bezahlen Sie per Banküberweisung",
|
description: t ? t('payment.methods.bankTransferDescription') : "Bezahlen Sie per Banküberweisung",
|
||||||
disabled: totalAmount === 0,
|
disabled: totalAmount === 0,
|
||||||
},
|
},
|
||||||
{
|
/*{
|
||||||
id: "stripe",
|
id: "stripe",
|
||||||
name: "Karte oder Sofortüberweisung",
|
name: "Karte oder Sofortüberweisung",
|
||||||
description: totalAmount < 0.50 && totalAmount > 0
|
description: totalAmount < 0.50 && totalAmount > 0
|
||||||
@@ -55,18 +56,32 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
|
|||||||
"/assets/images/mastercard.png",
|
"/assets/images/mastercard.png",
|
||||||
"/assets/images/visa_electron.png",
|
"/assets/images/visa_electron.png",
|
||||||
],
|
],
|
||||||
},
|
},*/
|
||||||
|
/*{
|
||||||
|
id: "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",
|
id: "onDelivery",
|
||||||
name: "Nachnahme",
|
name: t ? t('payment.methods.cashOnDelivery') : "Nachnahme",
|
||||||
description: "Bezahlen Sie bei Lieferung (8,99 € Aufschlag)",
|
description: t ? t('payment.methods.cashOnDeliveryDescription') : "Bezahlen Sie bei Lieferung (8,99 € Aufschlag)",
|
||||||
disabled: totalAmount === 0 || deliveryMethod !== "DHL",
|
disabled: totalAmount === 0 || deliveryMethod !== "DHL",
|
||||||
icons: ["/assets/images/cash.png"],
|
icons: ["/assets/images/cash.png"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cash",
|
id: "cash",
|
||||||
name: "Zahlung in der Filiale",
|
name: t ? t('payment.methods.cashInStore') : "Zahlung in der Filiale",
|
||||||
description: "Bei Abholung bezahlen",
|
description: t ? t('payment.methods.cashInStoreDescription') : "Bei Abholung bezahlen",
|
||||||
disabled: false, // Always enabled
|
disabled: false, // Always enabled
|
||||||
icons: ["/assets/images/cash.png"],
|
icons: ["/assets/images/cash.png"],
|
||||||
},
|
},
|
||||||
@@ -75,7 +90,7 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
Zahlungsart wählen
|
{t ? t('payment.methods.selectPaymentMethod') : 'Zahlungsart wählen'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ mb: 3 }}>
|
<Box sx={{ mb: 3 }}>
|
||||||
@@ -175,4 +190,4 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PaymentMethodSelector;
|
export default withI18n()(PaymentMethodSelector);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Snackbar
|
Snackbar
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { ContentCopy } from '@mui/icons-material';
|
import { ContentCopy } from '@mui/icons-material';
|
||||||
|
import { withI18n } from '../../i18n/withTranslation.js';
|
||||||
|
|
||||||
class SettingsTab extends Component {
|
class SettingsTab extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -72,17 +73,17 @@ class SettingsTab extends Component {
|
|||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!this.state.currentPassword || !this.state.newPassword || !this.state.confirmPassword) {
|
if (!this.state.currentPassword || !this.state.newPassword || !this.state.confirmPassword) {
|
||||||
this.setState({ passwordError: 'Bitte füllen Sie alle Felder aus' });
|
this.setState({ passwordError: this.props.t ? this.props.t('settings.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.newPassword !== this.state.confirmPassword) {
|
if (this.state.newPassword !== this.state.confirmPassword) {
|
||||||
this.setState({ passwordError: 'Die neuen Passwörter stimmen nicht überein' });
|
this.setState({ passwordError: this.props.t ? this.props.t('settings.errors.passwordsNotMatch') : 'Die neuen Passwörter stimmen nicht überein' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.newPassword.length < 8) {
|
if (this.state.newPassword.length < 8) {
|
||||||
this.setState({ passwordError: 'Das neue Passwort muss mindestens 8 Zeichen lang sein' });
|
this.setState({ passwordError: this.props.t ? this.props.t('settings.errors.passwordTooShort') : 'Das neue Passwort muss mindestens 8 Zeichen lang sein' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,14 +97,14 @@ class SettingsTab extends Component {
|
|||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
this.setState({
|
this.setState({
|
||||||
passwordSuccess: 'Passwort erfolgreich aktualisiert',
|
passwordSuccess: this.props.t ? this.props.t('settings.success.passwordUpdated') : 'Passwort erfolgreich aktualisiert',
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
confirmPassword: ''
|
confirmPassword: ''
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.setState({
|
this.setState({
|
||||||
passwordError: response.message || 'Fehler beim Aktualisieren des Passworts'
|
passwordError: response.message || (this.props.t ? this.props.t('settings.errors.passwordUpdateError') : 'Fehler beim Aktualisieren des Passworts')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,12 +122,12 @@ class SettingsTab extends Component {
|
|||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!this.state.password || !this.state.newEmail) {
|
if (!this.state.password || !this.state.newEmail) {
|
||||||
this.setState({ emailError: 'Bitte füllen Sie alle Felder aus' });
|
this.setState({ emailError: this.props.t ? this.props.t('settings.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.state.newEmail)) {
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.state.newEmail)) {
|
||||||
this.setState({ emailError: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
this.setState({ emailError: this.props.t ? this.props.t('settings.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +141,7 @@ class SettingsTab extends Component {
|
|||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
this.setState({
|
this.setState({
|
||||||
emailSuccess: 'E-Mail-Adresse erfolgreich aktualisiert',
|
emailSuccess: this.props.t ? this.props.t('settings.success.emailUpdated') : 'E-Mail-Adresse erfolgreich aktualisiert',
|
||||||
password: ''
|
password: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,7 +158,7 @@ class SettingsTab extends Component {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.setState({
|
this.setState({
|
||||||
emailError: response.message || 'Fehler beim Aktualisieren der E-Mail-Adresse'
|
emailError: response.message || (this.props.t ? this.props.t('settings.errors.emailUpdateError') : 'Fehler beim Aktualisieren der E-Mail-Adresse')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,7 +239,7 @@ class SettingsTab extends Component {
|
|||||||
<Box sx={{ p: { xs: 1, sm: 3 } }}>
|
<Box sx={{ p: { xs: 1, sm: 3 } }}>
|
||||||
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
|
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
|
||||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||||
Passwort ändern
|
{this.props.t ? this.props.t('settings.changePassword') : 'Passwort ändern'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{this.state.passwordError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.passwordError}</Alert>}
|
{this.state.passwordError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.passwordError}</Alert>}
|
||||||
@@ -247,7 +248,7 @@ class SettingsTab extends Component {
|
|||||||
<Box component="form" onSubmit={this.handleUpdatePassword}>
|
<Box component="form" onSubmit={this.handleUpdatePassword}>
|
||||||
<TextField
|
<TextField
|
||||||
margin="normal"
|
margin="normal"
|
||||||
label="Aktuelles Passwort"
|
label={this.props.t ? this.props.t('settings.currentPassword') : 'Aktuelles Passwort'}
|
||||||
type="password"
|
type="password"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={this.state.currentPassword}
|
value={this.state.currentPassword}
|
||||||
@@ -257,7 +258,7 @@ class SettingsTab extends Component {
|
|||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
margin="normal"
|
margin="normal"
|
||||||
label="Neues Passwort"
|
label={this.props.t ? this.props.t('settings.newPassword') : 'Neues Passwort'}
|
||||||
type="password"
|
type="password"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={this.state.newPassword}
|
value={this.state.newPassword}
|
||||||
@@ -267,7 +268,7 @@ class SettingsTab extends Component {
|
|||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
margin="normal"
|
margin="normal"
|
||||||
label="Neues Passwort bestätigen"
|
label={this.props.t ? this.props.t('settings.confirmNewPassword') : 'Neues Passwort bestätigen'}
|
||||||
type="password"
|
type="password"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={this.state.confirmPassword}
|
value={this.state.confirmPassword}
|
||||||
@@ -282,7 +283,7 @@ class SettingsTab extends Component {
|
|||||||
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
||||||
disabled={this.state.loading}
|
disabled={this.state.loading}
|
||||||
>
|
>
|
||||||
{this.state.loading ? <CircularProgress size={24} /> : 'Passwort aktualisieren'}
|
{this.state.loading ? <CircularProgress size={24} /> : (this.props.t ? this.props.t('settings.updatePassword') : 'Passwort aktualisieren')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -291,7 +292,7 @@ class SettingsTab extends Component {
|
|||||||
|
|
||||||
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
|
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
|
||||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||||
E-Mail-Adresse ändern
|
{this.props.t ? this.props.t('settings.changeEmail') : 'E-Mail-Adresse ändern'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{this.state.emailError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.emailError}</Alert>}
|
{this.state.emailError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.emailError}</Alert>}
|
||||||
@@ -300,7 +301,7 @@ class SettingsTab extends Component {
|
|||||||
<Box component="form" onSubmit={this.handleUpdateEmail}>
|
<Box component="form" onSubmit={this.handleUpdateEmail}>
|
||||||
<TextField
|
<TextField
|
||||||
margin="normal"
|
margin="normal"
|
||||||
label="Passwort"
|
label={this.props.t ? this.props.t('settings.password') : 'Passwort'}
|
||||||
type="password"
|
type="password"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={this.state.password}
|
value={this.state.password}
|
||||||
@@ -310,7 +311,7 @@ class SettingsTab extends Component {
|
|||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
margin="normal"
|
margin="normal"
|
||||||
label="Neue E-Mail-Adresse"
|
label={this.props.t ? this.props.t('settings.newEmail') : 'Neue E-Mail-Adresse'}
|
||||||
type="email"
|
type="email"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={this.state.newEmail}
|
value={this.state.newEmail}
|
||||||
@@ -325,7 +326,7 @@ class SettingsTab extends Component {
|
|||||||
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
||||||
disabled={this.state.loading}
|
disabled={this.state.loading}
|
||||||
>
|
>
|
||||||
{this.state.loading ? <CircularProgress size={24} /> : 'E-Mail aktualisieren'}
|
{this.state.loading ? <CircularProgress size={24} /> : (this.props.t ? this.props.t('settings.updateEmail') : 'E-Mail aktualisieren')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -334,11 +335,11 @@ class SettingsTab extends Component {
|
|||||||
|
|
||||||
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
|
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
|
||||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||||
API-Schlüssel
|
{this.props.t ? this.props.t('settings.apiKey') : 'API-Schlüssel'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
Verwenden Sie Ihren API-Schlüssel für die Integration mit externen Anwendungen.
|
{this.props.t ? this.props.t('settings.apiKeyDescription') : 'Verwenden Sie Ihren API-Schlüssel für die Integration mit externen Anwendungen.'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{this.state.apiKeyError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.apiKeyError}</Alert>}
|
{this.state.apiKeyError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.apiKeyError}</Alert>}
|
||||||
@@ -347,14 +348,14 @@ class SettingsTab extends Component {
|
|||||||
{this.state.apiKeySuccess}
|
{this.state.apiKeySuccess}
|
||||||
{this.state.apiKey && this.state.apiKeyDisplay !== '************' && (
|
{this.state.apiKey && this.state.apiKeyDisplay !== '************' && (
|
||||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||||
Speichern Sie diesen Schlüssel sicher. Er wird aus Sicherheitsgründen in 10 Sekunden ausgeblendet.
|
{this.props.t ? this.props.t('settings.success.apiKeyWarning') : 'Speichern Sie diesen Schlüssel sicher. Er wird aus Sicherheitsgründen in 10 Sekunden ausgeblendet.'}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||||
API-Dokumentation: {' '}
|
{this.props.t ? this.props.t('settings.apiDocumentation') : 'API-Dokumentation:'} {' '}
|
||||||
<a
|
<a
|
||||||
href={`${window.location.protocol}//${window.location.host}/api/`}
|
href={`${window.location.protocol}//${window.location.host}/api/`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -367,7 +368,7 @@ class SettingsTab extends Component {
|
|||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mt: 2 }}>
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mt: 2 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="API-Schlüssel"
|
label={this.props.t ? this.props.t('settings.apiKey') : 'API-Schlüssel'}
|
||||||
value={this.state.apiKeyDisplay}
|
value={this.state.apiKeyDisplay}
|
||||||
disabled
|
disabled
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -385,7 +386,7 @@ class SettingsTab extends Component {
|
|||||||
color: '#2e7d32',
|
color: '#2e7d32',
|
||||||
'&:hover': { bgcolor: 'rgba(46, 125, 50, 0.1)' }
|
'&:hover': { bgcolor: 'rgba(46, 125, 50, 0.1)' }
|
||||||
}}
|
}}
|
||||||
title="In Zwischenablage kopieren"
|
title={this.props.t ? this.props.t('settings.copyToClipboard') : 'In Zwischenablage kopieren'}
|
||||||
>
|
>
|
||||||
<ContentCopy />
|
<ContentCopy />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -405,7 +406,7 @@ class SettingsTab extends Component {
|
|||||||
{this.state.loadingApiKey ? (
|
{this.state.loadingApiKey ? (
|
||||||
<CircularProgress size={24} />
|
<CircularProgress size={24} />
|
||||||
) : (
|
) : (
|
||||||
this.state.hasApiKey ? 'Regenerieren' : 'Generieren'
|
this.state.hasApiKey ? (this.props.t ? this.props.t('settings.regenerate') : 'Regenerieren') : (this.props.t ? this.props.t('settings.generate') : 'Generieren')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -415,7 +416,7 @@ class SettingsTab extends Component {
|
|||||||
open={this.state.copySnackbarOpen}
|
open={this.state.copySnackbarOpen}
|
||||||
autoHideDuration={3000}
|
autoHideDuration={3000}
|
||||||
onClose={this.handleCloseSnackbar}
|
onClose={this.handleCloseSnackbar}
|
||||||
message="API-Schlüssel in Zwischenablage kopiert"
|
message={this.props.t ? this.props.t('settings.apiKeyCopied') : 'API-Schlüssel in Zwischenablage kopiert'}
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -423,4 +424,4 @@ class SettingsTab extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SettingsTab;
|
export default withI18n()(SettingsTab);
|
||||||
192
src/config.js
192
src/config.js
@@ -8,17 +8,195 @@ const config = {
|
|||||||
siteName: "Growheads.de",
|
siteName: "Growheads.de",
|
||||||
brandName: "GrowHeads",
|
brandName: "GrowHeads",
|
||||||
currency: "EUR",
|
currency: "EUR",
|
||||||
language: "de-DE",
|
language: "de-DE", // Will be updated dynamically based on i18n
|
||||||
country: "DE",
|
country: "DE",
|
||||||
|
|
||||||
// Shop Descriptions
|
// Multilingual configurations
|
||||||
descriptions: {
|
languages: {
|
||||||
short: "GrowHeads - Online-Shop für Cannanis-Samen, Stecklinge und Gartenbedarf",
|
de: {
|
||||||
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."
|
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
|
// Shop Descriptions - Multilingual
|
||||||
keywords: "Seeds, Steckling, Cannabis, Biobizz, Growheads",
|
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
|
||||||
shipping: {
|
shipping: {
|
||||||
|
|||||||
225
src/contexts/CarouselContext.js
Normal file
225
src/contexts/CarouselContext.js
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import React, { createContext, useContext, useRef, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const CarouselContext = createContext();
|
||||||
|
|
||||||
|
export const useCarousel = () => {
|
||||||
|
const context = useContext(CarouselContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useCarousel must be used within a CarouselProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CarouselProvider = ({ children }) => {
|
||||||
|
const carouselRef = useRef(null);
|
||||||
|
const scrollPositionRef = useRef(0);
|
||||||
|
const animationIdRef = useRef(null);
|
||||||
|
const isPausedRef = useRef(false);
|
||||||
|
const resumeTimeoutRef = useRef(null);
|
||||||
|
|
||||||
|
const [filteredCategories, setFilteredCategories] = useState([]);
|
||||||
|
|
||||||
|
// Initialize refs properly
|
||||||
|
useEffect(() => {
|
||||||
|
isPausedRef.current = false;
|
||||||
|
scrollPositionRef.current = 0;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-scroll effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (filteredCategories.length === 0) return;
|
||||||
|
|
||||||
|
const startAnimation = () => {
|
||||||
|
if (!carouselRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPausedRef.current = false;
|
||||||
|
|
||||||
|
const itemWidth = 146; // 130px + 16px gap
|
||||||
|
const totalWidth = filteredCategories.length * itemWidth;
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
if (!animationIdRef.current || isPausedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollPositionRef.current += 0.5;
|
||||||
|
|
||||||
|
if (scrollPositionRef.current >= totalWidth) {
|
||||||
|
scrollPositionRef.current = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (carouselRef.current) {
|
||||||
|
const transform = `translateX(-${scrollPositionRef.current}px)`;
|
||||||
|
carouselRef.current.style.transform = transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
animationIdRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isPausedRef.current) {
|
||||||
|
animationIdRef.current = requestAnimationFrame(animate);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try immediately, then with increasing delays
|
||||||
|
if (!startAnimation()) {
|
||||||
|
const timeout1 = setTimeout(() => {
|
||||||
|
if (!startAnimation()) {
|
||||||
|
const timeout2 = setTimeout(() => {
|
||||||
|
if (!startAnimation()) {
|
||||||
|
const timeout3 = setTimeout(startAnimation, 2000);
|
||||||
|
return () => clearTimeout(timeout3);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
return () => clearTimeout(timeout2);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isPausedRef.current = true;
|
||||||
|
clearTimeout(timeout1);
|
||||||
|
if (animationIdRef.current) {
|
||||||
|
cancelAnimationFrame(animationIdRef.current);
|
||||||
|
}
|
||||||
|
if (resumeTimeoutRef.current) {
|
||||||
|
clearTimeout(resumeTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isPausedRef.current = true;
|
||||||
|
if (animationIdRef.current) {
|
||||||
|
cancelAnimationFrame(animationIdRef.current);
|
||||||
|
}
|
||||||
|
if (resumeTimeoutRef.current) {
|
||||||
|
clearTimeout(resumeTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [filteredCategories]);
|
||||||
|
|
||||||
|
// Additional effect for when ref becomes available
|
||||||
|
useEffect(() => {
|
||||||
|
if (filteredCategories.length > 0 && carouselRef.current && !animationIdRef.current) {
|
||||||
|
isPausedRef.current = false;
|
||||||
|
|
||||||
|
const itemWidth = 146;
|
||||||
|
const totalWidth = filteredCategories.length * itemWidth;
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
if (!animationIdRef.current || isPausedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollPositionRef.current += 0.5;
|
||||||
|
|
||||||
|
if (scrollPositionRef.current >= totalWidth) {
|
||||||
|
scrollPositionRef.current = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (carouselRef.current) {
|
||||||
|
const transform = `translateX(-${scrollPositionRef.current}px)`;
|
||||||
|
carouselRef.current.style.transform = transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
animationIdRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isPausedRef.current) {
|
||||||
|
animationIdRef.current = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manual navigation
|
||||||
|
const moveCarousel = (direction) => {
|
||||||
|
if (!carouselRef.current) return;
|
||||||
|
|
||||||
|
// Pause auto-scroll
|
||||||
|
isPausedRef.current = true;
|
||||||
|
if (animationIdRef.current) {
|
||||||
|
cancelAnimationFrame(animationIdRef.current);
|
||||||
|
animationIdRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemWidth = 146;
|
||||||
|
const moveAmount = itemWidth * 3;
|
||||||
|
const totalWidth = filteredCategories.length * itemWidth;
|
||||||
|
|
||||||
|
if (direction === "left") {
|
||||||
|
scrollPositionRef.current -= moveAmount;
|
||||||
|
if (scrollPositionRef.current < 0) {
|
||||||
|
scrollPositionRef.current = totalWidth + scrollPositionRef.current;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scrollPositionRef.current += moveAmount;
|
||||||
|
if (scrollPositionRef.current >= totalWidth) {
|
||||||
|
scrollPositionRef.current = scrollPositionRef.current % totalWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply smooth transition
|
||||||
|
carouselRef.current.style.transition = "transform 0.5s ease-in-out";
|
||||||
|
carouselRef.current.style.transform = `translateX(-${scrollPositionRef.current}px)`;
|
||||||
|
|
||||||
|
// Remove transition after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
if (carouselRef.current) {
|
||||||
|
carouselRef.current.style.transition = "none";
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// Clear existing timeout
|
||||||
|
if (resumeTimeoutRef.current) {
|
||||||
|
clearTimeout(resumeTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume auto-scroll after 3 seconds
|
||||||
|
resumeTimeoutRef.current = setTimeout(() => {
|
||||||
|
isPausedRef.current = false;
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
if (!animationIdRef.current || isPausedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollPositionRef.current += 0.5;
|
||||||
|
|
||||||
|
if (scrollPositionRef.current >= totalWidth) {
|
||||||
|
scrollPositionRef.current = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (carouselRef.current) {
|
||||||
|
carouselRef.current.style.transform = `translateX(-${scrollPositionRef.current}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
animationIdRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationIdRef.current = requestAnimationFrame(animate);
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
carouselRef,
|
||||||
|
scrollPositionRef,
|
||||||
|
animationIdRef,
|
||||||
|
isPausedRef,
|
||||||
|
resumeTimeoutRef,
|
||||||
|
filteredCategories,
|
||||||
|
setFilteredCategories,
|
||||||
|
moveCarousel
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarouselContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CarouselContext;
|
||||||
381
src/i18n/index.js
Normal file
381
src/i18n/index.js
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
|
||||||
|
// Import all translation files
|
||||||
|
import translationDE from './locales/de/index.js';
|
||||||
|
import translationEN from './locales/en/index.js';
|
||||||
|
import translationAR from './locales/ar/translation.js';
|
||||||
|
import translationBG from './locales/bg/translation.js';
|
||||||
|
import translationCS from './locales/cs/translation.js';
|
||||||
|
import translationEL from './locales/el/translation.js';
|
||||||
|
import translationES from './locales/es/translation.js';
|
||||||
|
import translationFR from './locales/fr/translation.js';
|
||||||
|
import translationHR from './locales/hr/translation.js';
|
||||||
|
import translationHU from './locales/hu/translation.js';
|
||||||
|
import translationIT from './locales/it/translation.js';
|
||||||
|
import translationPL from './locales/pl/translation.js';
|
||||||
|
import translationRO from './locales/ro/translation.js';
|
||||||
|
import translationRU from './locales/ru/translation.js';
|
||||||
|
import translationSK from './locales/sk/translation.js';
|
||||||
|
import translationSL from './locales/sl/translation.js';
|
||||||
|
import translationSR from './locales/sr/translation.js';
|
||||||
|
import translationSV from './locales/sv/translation.js';
|
||||||
|
import translationTR from './locales/tr/translation.js';
|
||||||
|
import translationUK from './locales/uk/translation.js';
|
||||||
|
import translationZH from './locales/zh/translation.js';
|
||||||
|
|
||||||
|
// Import legal translations for all languages
|
||||||
|
// German
|
||||||
|
import legalAgbDE from './locales/de/legal-agb.js';
|
||||||
|
import legalDatenschutzDE from './locales/de/legal-datenschutz.js';
|
||||||
|
import legalImpressumDE from './locales/de/legal-impressum.js';
|
||||||
|
import legalWiderrufDE from './locales/de/legal-widerruf.js';
|
||||||
|
import legalBatterieDE from './locales/de/legal-batterie.js';
|
||||||
|
|
||||||
|
// English
|
||||||
|
import legalAgbEN from './locales/en/legal-agb.js';
|
||||||
|
import legalDatenschutzEN from './locales/en/legal-datenschutz.js';
|
||||||
|
import legalImpressumEN from './locales/en/legal-impressum.js';
|
||||||
|
import legalWiderrufEN from './locales/en/legal-widerruf.js';
|
||||||
|
import legalBatterieEN from './locales/en/legal-batterie.js';
|
||||||
|
|
||||||
|
// Arabic
|
||||||
|
import legalAgbAR from './locales/ar/legal-agb.js';
|
||||||
|
import legalDatenschutzAR from './locales/ar/legal-datenschutz.js';
|
||||||
|
import legalImpressumAR from './locales/ar/legal-impressum.js';
|
||||||
|
import legalWiderrufAR from './locales/ar/legal-widerruf.js';
|
||||||
|
import legalBatterieAR from './locales/ar/legal-batterie.js';
|
||||||
|
|
||||||
|
// Bulgarian
|
||||||
|
import legalAgbBG from './locales/bg/legal-agb.js';
|
||||||
|
import legalDatenschutzBG from './locales/bg/legal-datenschutz.js';
|
||||||
|
import legalImpressumBG from './locales/bg/legal-impressum.js';
|
||||||
|
import legalWiderrufBG from './locales/bg/legal-widerruf.js';
|
||||||
|
import legalBatterieBG from './locales/bg/legal-batterie.js';
|
||||||
|
|
||||||
|
// Czech
|
||||||
|
import legalAgbCS from './locales/cs/legal-agb.js';
|
||||||
|
import legalDatenschutzCS from './locales/cs/legal-datenschutz.js';
|
||||||
|
import legalImpressumCS from './locales/cs/legal-impressum.js';
|
||||||
|
import legalWiderrufCS from './locales/cs/legal-widerruf.js';
|
||||||
|
import legalBatterieCS from './locales/cs/legal-batterie.js';
|
||||||
|
|
||||||
|
// Greek
|
||||||
|
import legalAgbEL from './locales/el/legal-agb.js';
|
||||||
|
import legalDatenschutzEL from './locales/el/legal-datenschutz.js';
|
||||||
|
import legalImpressumEL from './locales/el/legal-impressum.js';
|
||||||
|
import legalWiderrufEL from './locales/el/legal-widerruf.js';
|
||||||
|
import legalBatterieEL from './locales/el/legal-batterie.js';
|
||||||
|
|
||||||
|
// Spanish
|
||||||
|
import legalAgbES from './locales/es/legal-agb.js';
|
||||||
|
import legalDatenschutzES from './locales/es/legal-datenschutz.js';
|
||||||
|
import legalImpressumES from './locales/es/legal-impressum.js';
|
||||||
|
import legalWiderrufES from './locales/es/legal-widerruf.js';
|
||||||
|
import legalBatterieES from './locales/es/legal-batterie.js';
|
||||||
|
|
||||||
|
// French
|
||||||
|
import legalAgbFR from './locales/fr/legal-agb.js';
|
||||||
|
import legalDatenschutzFR from './locales/fr/legal-datenschutz.js';
|
||||||
|
import legalImpressumFR from './locales/fr/legal-impressum.js';
|
||||||
|
import legalWiderrufFR from './locales/fr/legal-widerruf.js';
|
||||||
|
import legalBatterieFR from './locales/fr/legal-batterie.js';
|
||||||
|
|
||||||
|
// Croatian
|
||||||
|
import legalAgbHR from './locales/hr/legal-agb.js';
|
||||||
|
import legalDatenschutzHR from './locales/hr/legal-datenschutz.js';
|
||||||
|
import legalImpressumHR from './locales/hr/legal-impressum.js';
|
||||||
|
import legalWiderrufHR from './locales/hr/legal-widerruf.js';
|
||||||
|
import legalBatterieHR from './locales/hr/legal-batterie.js';
|
||||||
|
|
||||||
|
// Hungarian
|
||||||
|
import legalAgbHU from './locales/hu/legal-agb.js';
|
||||||
|
import legalDatenschutzHU from './locales/hu/legal-datenschutz.js';
|
||||||
|
import legalImpressumHU from './locales/hu/legal-impressum.js';
|
||||||
|
import legalWiderrufHU from './locales/hu/legal-widerruf.js';
|
||||||
|
import legalBatterieHU from './locales/hu/legal-batterie.js';
|
||||||
|
|
||||||
|
// Italian
|
||||||
|
import legalAgbIT from './locales/it/legal-agb.js';
|
||||||
|
import legalDatenschutzIT from './locales/it/legal-datenschutz.js';
|
||||||
|
import legalImpressumIT from './locales/it/legal-impressum.js';
|
||||||
|
import legalWiderrufIT from './locales/it/legal-widerruf.js';
|
||||||
|
import legalBatterieIT from './locales/it/legal-batterie.js';
|
||||||
|
|
||||||
|
// Polish
|
||||||
|
import legalAgbPL from './locales/pl/legal-agb.js';
|
||||||
|
import legalDatenschutzPL from './locales/pl/legal-datenschutz.js';
|
||||||
|
import legalImpressumPL from './locales/pl/legal-impressum.js';
|
||||||
|
import legalWiderrufPL from './locales/pl/legal-widerruf.js';
|
||||||
|
import legalBatteriePL from './locales/pl/legal-batterie.js';
|
||||||
|
|
||||||
|
// Romanian
|
||||||
|
import legalAgbRO from './locales/ro/legal-agb.js';
|
||||||
|
import legalDatenschutzRO from './locales/ro/legal-datenschutz.js';
|
||||||
|
import legalImpressumRO from './locales/ro/legal-impressum.js';
|
||||||
|
import legalWiderrufRO from './locales/ro/legal-widerruf.js';
|
||||||
|
import legalBatterieRO from './locales/ro/legal-batterie.js';
|
||||||
|
|
||||||
|
// Russian
|
||||||
|
import legalAgbRU from './locales/ru/legal-agb.js';
|
||||||
|
import legalDatenschutzRU from './locales/ru/legal-datenschutz.js';
|
||||||
|
import legalImpressumRU from './locales/ru/legal-impressum.js';
|
||||||
|
import legalWiderrufRU from './locales/ru/legal-widerruf.js';
|
||||||
|
import legalBatterieRU from './locales/ru/legal-batterie.js';
|
||||||
|
|
||||||
|
// Slovak
|
||||||
|
import legalAgbSK from './locales/sk/legal-agb.js';
|
||||||
|
import legalDatenschutzSK from './locales/sk/legal-datenschutz.js';
|
||||||
|
import legalImpressumSK from './locales/sk/legal-impressum.js';
|
||||||
|
import legalWiderrufSK from './locales/sk/legal-widerruf.js';
|
||||||
|
import legalBatterieSK from './locales/sk/legal-batterie.js';
|
||||||
|
|
||||||
|
// Slovenian
|
||||||
|
import legalAgbSL from './locales/sl/legal-agb.js';
|
||||||
|
import legalDatenschutzSL from './locales/sl/legal-datenschutz.js';
|
||||||
|
import legalImpressumSL from './locales/sl/legal-impressum.js';
|
||||||
|
import legalWiderrufSL from './locales/sl/legal-widerruf.js';
|
||||||
|
import legalBatterieSL from './locales/sl/legal-batterie.js';
|
||||||
|
|
||||||
|
// Serbian
|
||||||
|
import legalAgbSR from './locales/sr/legal-agb.js';
|
||||||
|
import legalDatenschutzSR from './locales/sr/legal-datenschutz.js';
|
||||||
|
import legalImpressumSR from './locales/sr/legal-impressum.js';
|
||||||
|
import legalWiderrufSR from './locales/sr/legal-widerruf.js';
|
||||||
|
import legalBatterieSR from './locales/sr/legal-batterie.js';
|
||||||
|
|
||||||
|
// Swedish
|
||||||
|
import legalAgbSV from './locales/sv/legal-agb.js';
|
||||||
|
import legalDatenschutzSV from './locales/sv/legal-datenschutz.js';
|
||||||
|
import legalImpressumSV from './locales/sv/legal-impressum.js';
|
||||||
|
import legalWiderrufSV from './locales/sv/legal-widerruf.js';
|
||||||
|
import legalBatterieSV from './locales/sv/legal-batterie.js';
|
||||||
|
|
||||||
|
// Turkish
|
||||||
|
import legalAgbTR from './locales/tr/legal-agb.js';
|
||||||
|
import legalDatenschutzTR from './locales/tr/legal-datenschutz.js';
|
||||||
|
import legalImpressumTR from './locales/tr/legal-impressum.js';
|
||||||
|
import legalWiderrufTR from './locales/tr/legal-widerruf.js';
|
||||||
|
import legalBatterieTR from './locales/tr/legal-batterie.js';
|
||||||
|
|
||||||
|
// Ukrainian
|
||||||
|
import legalAgbUK from './locales/uk/legal-agb.js';
|
||||||
|
import legalDatenschutzUK from './locales/uk/legal-datenschutz.js';
|
||||||
|
import legalImpressumUK from './locales/uk/legal-impressum.js';
|
||||||
|
import legalWiderrufUK from './locales/uk/legal-widerruf.js';
|
||||||
|
import legalBatterieUK from './locales/uk/legal-batterie.js';
|
||||||
|
|
||||||
|
// Chinese
|
||||||
|
import legalAgbZH from './locales/zh/legal-agb.js';
|
||||||
|
import legalDatenschutzZH from './locales/zh/legal-datenschutz.js';
|
||||||
|
import legalImpressumZH from './locales/zh/legal-impressum.js';
|
||||||
|
import legalWiderrufZH from './locales/zh/legal-widerruf.js';
|
||||||
|
import legalBatterieZH from './locales/zh/legal-batterie.js';
|
||||||
|
|
||||||
|
const resources = {
|
||||||
|
de: {
|
||||||
|
translation: translationDE,
|
||||||
|
'legal-agb': legalAgbDE,
|
||||||
|
'legal-datenschutz': legalDatenschutzDE,
|
||||||
|
'legal-impressum': legalImpressumDE,
|
||||||
|
'legal-widerruf': legalWiderrufDE,
|
||||||
|
'legal-batterie': legalBatterieDE
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
translation: translationEN,
|
||||||
|
'legal-agb': legalAgbEN,
|
||||||
|
'legal-datenschutz': legalDatenschutzEN,
|
||||||
|
'legal-impressum': legalImpressumEN,
|
||||||
|
'legal-widerruf': legalWiderrufEN,
|
||||||
|
'legal-batterie': legalBatterieEN
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
translation: translationAR,
|
||||||
|
'legal-agb': legalAgbAR,
|
||||||
|
'legal-datenschutz': legalDatenschutzAR,
|
||||||
|
'legal-impressum': legalImpressumAR,
|
||||||
|
'legal-widerruf': legalWiderrufAR,
|
||||||
|
'legal-batterie': legalBatterieAR
|
||||||
|
},
|
||||||
|
bg: {
|
||||||
|
translation: translationBG,
|
||||||
|
'legal-agb': legalAgbBG,
|
||||||
|
'legal-datenschutz': legalDatenschutzBG,
|
||||||
|
'legal-impressum': legalImpressumBG,
|
||||||
|
'legal-widerruf': legalWiderrufBG,
|
||||||
|
'legal-batterie': legalBatterieBG
|
||||||
|
},
|
||||||
|
cs: {
|
||||||
|
translation: translationCS,
|
||||||
|
'legal-agb': legalAgbCS,
|
||||||
|
'legal-datenschutz': legalDatenschutzCS,
|
||||||
|
'legal-impressum': legalImpressumCS,
|
||||||
|
'legal-widerruf': legalWiderrufCS,
|
||||||
|
'legal-batterie': legalBatterieCS
|
||||||
|
},
|
||||||
|
el: {
|
||||||
|
translation: translationEL,
|
||||||
|
'legal-agb': legalAgbEL,
|
||||||
|
'legal-datenschutz': legalDatenschutzEL,
|
||||||
|
'legal-impressum': legalImpressumEL,
|
||||||
|
'legal-widerruf': legalWiderrufEL,
|
||||||
|
'legal-batterie': legalBatterieEL
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
translation: translationES,
|
||||||
|
'legal-agb': legalAgbES,
|
||||||
|
'legal-datenschutz': legalDatenschutzES,
|
||||||
|
'legal-impressum': legalImpressumES,
|
||||||
|
'legal-widerruf': legalWiderrufES,
|
||||||
|
'legal-batterie': legalBatterieES
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
translation: translationFR,
|
||||||
|
'legal-agb': legalAgbFR,
|
||||||
|
'legal-datenschutz': legalDatenschutzFR,
|
||||||
|
'legal-impressum': legalImpressumFR,
|
||||||
|
'legal-widerruf': legalWiderrufFR,
|
||||||
|
'legal-batterie': legalBatterieFR
|
||||||
|
},
|
||||||
|
hr: {
|
||||||
|
translation: translationHR,
|
||||||
|
'legal-agb': legalAgbHR,
|
||||||
|
'legal-datenschutz': legalDatenschutzHR,
|
||||||
|
'legal-impressum': legalImpressumHR,
|
||||||
|
'legal-widerruf': legalWiderrufHR,
|
||||||
|
'legal-batterie': legalBatterieHR
|
||||||
|
},
|
||||||
|
hu: {
|
||||||
|
translation: translationHU,
|
||||||
|
'legal-agb': legalAgbHU,
|
||||||
|
'legal-datenschutz': legalDatenschutzHU,
|
||||||
|
'legal-impressum': legalImpressumHU,
|
||||||
|
'legal-widerruf': legalWiderrufHU,
|
||||||
|
'legal-batterie': legalBatterieHU
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
translation: translationIT,
|
||||||
|
'legal-agb': legalAgbIT,
|
||||||
|
'legal-datenschutz': legalDatenschutzIT,
|
||||||
|
'legal-impressum': legalImpressumIT,
|
||||||
|
'legal-widerruf': legalWiderrufIT,
|
||||||
|
'legal-batterie': legalBatterieIT
|
||||||
|
},
|
||||||
|
pl: {
|
||||||
|
translation: translationPL,
|
||||||
|
'legal-agb': legalAgbPL,
|
||||||
|
'legal-datenschutz': legalDatenschutzPL,
|
||||||
|
'legal-impressum': legalImpressumPL,
|
||||||
|
'legal-widerruf': legalWiderrufPL,
|
||||||
|
'legal-batterie': legalBatteriePL
|
||||||
|
},
|
||||||
|
ro: {
|
||||||
|
translation: translationRO,
|
||||||
|
'legal-agb': legalAgbRO,
|
||||||
|
'legal-datenschutz': legalDatenschutzRO,
|
||||||
|
'legal-impressum': legalImpressumRO,
|
||||||
|
'legal-widerruf': legalWiderrufRO,
|
||||||
|
'legal-batterie': legalBatterieRO
|
||||||
|
},
|
||||||
|
ru: {
|
||||||
|
translation: translationRU,
|
||||||
|
'legal-agb': legalAgbRU,
|
||||||
|
'legal-datenschutz': legalDatenschutzRU,
|
||||||
|
'legal-impressum': legalImpressumRU,
|
||||||
|
'legal-widerruf': legalWiderrufRU,
|
||||||
|
'legal-batterie': legalBatterieRU
|
||||||
|
},
|
||||||
|
sk: {
|
||||||
|
translation: translationSK,
|
||||||
|
'legal-agb': legalAgbSK,
|
||||||
|
'legal-datenschutz': legalDatenschutzSK,
|
||||||
|
'legal-impressum': legalImpressumSK,
|
||||||
|
'legal-widerruf': legalWiderrufSK,
|
||||||
|
'legal-batterie': legalBatterieSK
|
||||||
|
},
|
||||||
|
sl: {
|
||||||
|
translation: translationSL,
|
||||||
|
'legal-agb': legalAgbSL,
|
||||||
|
'legal-datenschutz': legalDatenschutzSL,
|
||||||
|
'legal-impressum': legalImpressumSL,
|
||||||
|
'legal-widerruf': legalWiderrufSL,
|
||||||
|
'legal-batterie': legalBatterieSL
|
||||||
|
},
|
||||||
|
sr: {
|
||||||
|
translation: translationSR,
|
||||||
|
'legal-agb': legalAgbSR,
|
||||||
|
'legal-datenschutz': legalDatenschutzSR,
|
||||||
|
'legal-impressum': legalImpressumSR,
|
||||||
|
'legal-widerruf': legalWiderrufSR,
|
||||||
|
'legal-batterie': legalBatterieSR
|
||||||
|
},
|
||||||
|
sv: {
|
||||||
|
translation: translationSV,
|
||||||
|
'legal-agb': legalAgbSV,
|
||||||
|
'legal-datenschutz': legalDatenschutzSV,
|
||||||
|
'legal-impressum': legalImpressumSV,
|
||||||
|
'legal-widerruf': legalWiderrufSV,
|
||||||
|
'legal-batterie': legalBatterieSV
|
||||||
|
},
|
||||||
|
tr: {
|
||||||
|
translation: translationTR,
|
||||||
|
'legal-agb': legalAgbTR,
|
||||||
|
'legal-datenschutz': legalDatenschutzTR,
|
||||||
|
'legal-impressum': legalImpressumTR,
|
||||||
|
'legal-widerruf': legalWiderrufTR,
|
||||||
|
'legal-batterie': legalBatterieTR
|
||||||
|
},
|
||||||
|
uk: {
|
||||||
|
translation: translationUK,
|
||||||
|
'legal-agb': legalAgbUK,
|
||||||
|
'legal-datenschutz': legalDatenschutzUK,
|
||||||
|
'legal-impressum': legalImpressumUK,
|
||||||
|
'legal-widerruf': legalWiderrufUK,
|
||||||
|
'legal-batterie': legalBatterieUK
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
translation: translationZH,
|
||||||
|
'legal-agb': legalAgbZH,
|
||||||
|
'legal-datenschutz': legalDatenschutzZH,
|
||||||
|
'legal-impressum': legalImpressumZH,
|
||||||
|
'legal-widerruf': legalWiderrufZH,
|
||||||
|
'legal-batterie': legalBatterieZH
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(LanguageDetector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources,
|
||||||
|
fallbackLng: 'de', // German as fallback since it's your primary language
|
||||||
|
debug: process.env.NODE_ENV === 'development',
|
||||||
|
|
||||||
|
// Language detection options
|
||||||
|
detection: {
|
||||||
|
// Order of language detection methods
|
||||||
|
order: ['localStorage', 'navigator', 'htmlTag'],
|
||||||
|
// Cache the language selection
|
||||||
|
caches: ['localStorage'],
|
||||||
|
// Check for language in localStorage
|
||||||
|
lookupLocalStorage: 'i18nextLng'
|
||||||
|
},
|
||||||
|
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false // React already escapes values
|
||||||
|
},
|
||||||
|
|
||||||
|
// Namespace configuration
|
||||||
|
defaultNS: 'translation',
|
||||||
|
|
||||||
|
// React-specific options
|
||||||
|
react: {
|
||||||
|
useSuspense: false // Disable suspense for class components compatibility
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export withI18n and other utilities for easy access
|
||||||
|
export { withI18n, withTranslation, withLanguage, LanguageContext, LanguageProvider } from './withTranslation.js';
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
25
src/i18n/locales/ar/auth.js
Normal file
25
src/i18n/locales/ar/auth.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export default {
|
||||||
|
"login": "تسجيل الدخول",
|
||||||
|
"register": "تسجيل",
|
||||||
|
"logout": "تسجيل خروج",
|
||||||
|
"profile": "الملف الشخصي",
|
||||||
|
"email": "البريد الإلكتروني",
|
||||||
|
"password": "كلمة المرور",
|
||||||
|
"confirmPassword": "تأكيد كلمة المرور",
|
||||||
|
"forgotPassword": "هل نسيت كلمة المرور؟",
|
||||||
|
"loginWithGoogle": "تسجيل الدخول باستخدام جوجل",
|
||||||
|
"or": "أو",
|
||||||
|
"privacyAccept": "بالنقر على \"تسجيل الدخول باستخدام جوجل\" أوافق على",
|
||||||
|
"privacyPolicy": "سياسة الخصوصية",
|
||||||
|
"passwordMinLength": "يجب أن تكون كلمة المرور 8 أحرف على الأقل",
|
||||||
|
"newPasswordMinLength": "يجب أن تكون كلمة المرور الجديدة 8 أحرف على الأقل",
|
||||||
|
"menu": {
|
||||||
|
"profile": "الملف الشخصي",
|
||||||
|
"myProfile": "ملفي الشخصي",
|
||||||
|
"checkout": "إتمام الشراء",
|
||||||
|
"orders": "الطلبات",
|
||||||
|
"settings": "الإعدادات",
|
||||||
|
"adminDashboard": "لوحة تحكم المسؤول",
|
||||||
|
"adminUsers": "مستخدمو المسؤول"
|
||||||
|
}
|
||||||
|
};
|
||||||
39
src/i18n/locales/ar/cart.js
Normal file
39
src/i18n/locales/ar/cart.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export default {
|
||||||
|
"title": "العربة",
|
||||||
|
"empty": "فارغ",
|
||||||
|
"addToCart": "أضف إلى العربة",
|
||||||
|
"preorderCutting": "اطلب مسبقًا كقطع",
|
||||||
|
"continueShopping": "تابع التسوق",
|
||||||
|
"proceedToCheckout": "المتابعة إلى الدفع",
|
||||||
|
"productCount": "{{count}} {{count, plural, one {منتج} other {منتجات}}}",
|
||||||
|
"productSingular": "منتج",
|
||||||
|
"productPlural": "منتجات",
|
||||||
|
"removeFromCart": "إزالة من العربة",
|
||||||
|
"openCart": "افتح العربة",
|
||||||
|
"availableFrom": "متاح من {{date}}",
|
||||||
|
"backToOrder": "← العودة إلى الطلب",
|
||||||
|
"summary": {
|
||||||
|
"title": "ملخص الطلب",
|
||||||
|
"goodsNet": "البضائع (صافي):",
|
||||||
|
"shippingNet": "الشحن (صافي):",
|
||||||
|
"totalGoods": "إجمالي البضائع:",
|
||||||
|
"shippingCosts": "تكاليف الشحن:",
|
||||||
|
"total": "الإجمالي:",
|
||||||
|
"totalWeight": "الوزن الكلي: {{weight}} كجم",
|
||||||
|
"freeFrom100": "(مجاني من €100)",
|
||||||
|
"free": "مجاني"
|
||||||
|
},
|
||||||
|
"itemCount": {
|
||||||
|
"singular": "منتج",
|
||||||
|
"plural": "منتجات"
|
||||||
|
},
|
||||||
|
"sync": {
|
||||||
|
"title": "مزامنة العربة",
|
||||||
|
"description": "لديك عربة محفوظة في حسابك. يرجى اختيار كيفية المتابعة:",
|
||||||
|
"deleteServer": "حذف عربة الخادم",
|
||||||
|
"useServer": "استخدام عربة الخادم",
|
||||||
|
"merge": "دمج العربات",
|
||||||
|
"currentCart": "عربتك الحالية",
|
||||||
|
"serverCart": "العربة المحفوظة في ملفك الشخصي"
|
||||||
|
}
|
||||||
|
};
|
||||||
3
src/i18n/locales/ar/chat.js
Normal file
3
src/i18n/locales/ar/chat.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
"privacyRead": "تم القراءة والموافقة",
|
||||||
|
};
|
||||||
34
src/i18n/locales/ar/checkout.js
Normal file
34
src/i18n/locales/ar/checkout.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export default {
|
||||||
|
"invoiceAddress": "عنوان الفاتورة",
|
||||||
|
"deliveryAddress": "عنوان التوصيل",
|
||||||
|
"saveForFuture": "احفظ للطلبات المستقبلية",
|
||||||
|
"pickupDate": "لمين التاريخ مطلوب استلام القصاصات؟",
|
||||||
|
"note": "ملاحظة",
|
||||||
|
"sameAddress": "عنوان التوصيل هو نفسه عنوان الفاتورة",
|
||||||
|
"termsAccept": "لقد قرأت الشروط والأحكام، سياسة الخصوصية، وأحكام حق الانسحاب",
|
||||||
|
"selectDeliveryMethod": "اختر طريقة الشحن",
|
||||||
|
"selectPaymentMethod": "اختر طريقة الدفع",
|
||||||
|
"orderSummary": "ملخص الطلب",
|
||||||
|
"addressValidationError": "يرجى التحقق من بياناتك في حقول العنوان.",
|
||||||
|
"processingOrder": "يتم معالجة الطلب...",
|
||||||
|
"completeOrder": "إتمام الطلب",
|
||||||
|
"termsValidationError": "يرجى قبول الشروط والأحكام، سياسة الخصوصية، وحق الانسحاب للمتابعة.",
|
||||||
|
"addressFields": {
|
||||||
|
"firstName": "الاسم الأول",
|
||||||
|
"lastName": "اسم العائلة",
|
||||||
|
"addressSupplement": "إضافة للعنوان",
|
||||||
|
"street": "الشارع",
|
||||||
|
"houseNumber": "رقم المنزل",
|
||||||
|
"postalCode": "الرمز البريدي",
|
||||||
|
"city": "المدينة",
|
||||||
|
"country": "البلد"
|
||||||
|
},
|
||||||
|
"validationErrors": {
|
||||||
|
"firstNameRequired": "الاسم الأول مطلوب",
|
||||||
|
"lastNameRequired": "اسم العائلة مطلوب",
|
||||||
|
"streetRequired": "الشارع مطلوب",
|
||||||
|
"houseNumberRequired": "رقم المنزل مطلوب",
|
||||||
|
"postalCodeRequired": "الرمز البريدي مطلوب",
|
||||||
|
"cityRequired": "المدينة مطلوبة"
|
||||||
|
}
|
||||||
|
};
|
||||||
19
src/i18n/locales/ar/common.js
Normal file
19
src/i18n/locales/ar/common.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export default {
|
||||||
|
"loading": "جارٍ التحميل...",
|
||||||
|
"error": "خطأ",
|
||||||
|
"close": "إغلاق",
|
||||||
|
"save": "حفظ",
|
||||||
|
"cancel": "إلغاء",
|
||||||
|
"ok": "موافق",
|
||||||
|
"yes": "نعم",
|
||||||
|
"no": "لا",
|
||||||
|
"next": "التالي",
|
||||||
|
"back": "رجوع",
|
||||||
|
"edit": "تعديل",
|
||||||
|
"delete": "حذف",
|
||||||
|
"add": "إضافة",
|
||||||
|
"remove": "إزالة",
|
||||||
|
"products": "منتجات",
|
||||||
|
"product": "منتج",
|
||||||
|
"days": "أيام"
|
||||||
|
};
|
||||||
35
src/i18n/locales/ar/delivery.js
Normal file
35
src/i18n/locales/ar/delivery.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export default {
|
||||||
|
"methods": {
|
||||||
|
"dhl": "DHL",
|
||||||
|
"dpd": "DPD",
|
||||||
|
"sperrgut": "بضائع ضخمة",
|
||||||
|
"sperrgutName": "بضائع ضخمة",
|
||||||
|
"pickup": "استلام من المتجر"
|
||||||
|
},
|
||||||
|
"descriptions": {
|
||||||
|
"standard": "الشحن العادي",
|
||||||
|
"standardFree": "الشحن العادي - مجاني للطلبات فوق 100€!",
|
||||||
|
"notAvailable": "غير متاح للاختيار لأن عنصر واحد أو أكثر يمكن استلامه فقط",
|
||||||
|
"bulky": "للعناصر الكبيرة والثقيلة",
|
||||||
|
"pickupOnly": "الاستلام فقط"
|
||||||
|
},
|
||||||
|
"prices": {
|
||||||
|
"free": "مجاني",
|
||||||
|
"freeFrom100": "(مجاني من 100€)",
|
||||||
|
"dhl": "6.99 €",
|
||||||
|
"dpd": "4.90 €",
|
||||||
|
"sperrgut": "28.99 €"
|
||||||
|
},
|
||||||
|
"times": {
|
||||||
|
"cutting14Days": "مدة التوصيل: 14 يوم",
|
||||||
|
"standard2to3Days": "مدة التوصيل: 2-3 أيام",
|
||||||
|
"supplier7to9Days": "مدة التوصيل: 7-9 أيام"
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"title": "اختر طريقة الشحن",
|
||||||
|
"freeShippingInfo": "💡 شحن مجاني للطلبات فوق 100€!",
|
||||||
|
"remainingForFree": "أضف {{amount}}€ أخرى للشحن المجاني.",
|
||||||
|
"congratsFreeShipping": "🎉 مبروك! حصلت على شحن مجاني!",
|
||||||
|
"cartQualifiesFree": "سلة مشترياتك بقيمة {{amount}}€ مؤهلة للشحن المجاني."
|
||||||
|
}
|
||||||
|
};
|
||||||
7
src/i18n/locales/ar/filters.js
Normal file
7
src/i18n/locales/ar/filters.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
"sorting": "الترتيب",
|
||||||
|
"perPage": "لكل صفحة",
|
||||||
|
"availability": "التوفر",
|
||||||
|
"manufacturer": "الشركة المصنعة",
|
||||||
|
"all": "الكل"
|
||||||
|
};
|
||||||
15
src/i18n/locales/ar/footer.js
Normal file
15
src/i18n/locales/ar/footer.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export default {
|
||||||
|
"hours": "السبت 11 صباحًا - 7 مساءً",
|
||||||
|
"address": "شارع تراشنبرجر 14 - دريسدن",
|
||||||
|
"location": "بين محطة بيسشن وميدان تراشنبرجر",
|
||||||
|
"allPricesIncl": "* جميع الأسعار تشمل ضريبة القيمة المضافة القانونية، بالإضافة إلى الشحن",
|
||||||
|
"copyright": "© {{year}} GrowHeads.de",
|
||||||
|
"legal": {
|
||||||
|
"datenschutz": "سياسة الخصوصية",
|
||||||
|
"agb": "الشروط والأحكام",
|
||||||
|
"sitemap": "خريطة الموقع",
|
||||||
|
"impressum": "الإشعار القانوني",
|
||||||
|
"batteriegesetzhinweise": "معلومات قانون البطاريات",
|
||||||
|
"widerrufsrecht": "حق الانسحاب"
|
||||||
|
}
|
||||||
|
};
|
||||||
43
src/i18n/locales/ar/index.js
Normal file
43
src/i18n/locales/ar/index.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import locale from './locale.js';
|
||||||
|
import navigation from './navigation.js';
|
||||||
|
import auth from './auth.js';
|
||||||
|
import cart from './cart.js';
|
||||||
|
import product from './product.js';
|
||||||
|
import search from './search.js';
|
||||||
|
import sorting from './sorting.js';
|
||||||
|
import chat from './chat.js';
|
||||||
|
import delivery from './delivery.js';
|
||||||
|
import checkout from './checkout.js';
|
||||||
|
import payment from './payment.js';
|
||||||
|
import filters from './filters.js';
|
||||||
|
import tax from './tax.js';
|
||||||
|
import footer from './footer.js';
|
||||||
|
import titles from './titles.js';
|
||||||
|
import sections from './sections.js';
|
||||||
|
import pages from './pages.js';
|
||||||
|
import orders from './orders.js';
|
||||||
|
import settings from './settings.js';
|
||||||
|
import common from './common.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
"locale": locale,
|
||||||
|
"navigation": navigation,
|
||||||
|
"auth": auth,
|
||||||
|
"cart": cart,
|
||||||
|
"product": product,
|
||||||
|
"search": search,
|
||||||
|
"sorting": sorting,
|
||||||
|
"chat": chat,
|
||||||
|
"delivery": delivery,
|
||||||
|
"checkout": checkout,
|
||||||
|
"payment": payment,
|
||||||
|
"filters": filters,
|
||||||
|
"tax": tax,
|
||||||
|
"footer": footer,
|
||||||
|
"titles": titles,
|
||||||
|
"sections": sections,
|
||||||
|
"pages": pages,
|
||||||
|
"orders": orders,
|
||||||
|
"settings": settings,
|
||||||
|
"common": common
|
||||||
|
};
|
||||||
69
src/i18n/locales/ar/legal-agb.js
Normal file
69
src/i18n/locales/ar/legal-agb.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
export default {
|
||||||
|
"title": "الشروط والأحكام العامة",
|
||||||
|
"deliveryShippingConditions": "شروط التسليم والشحن",
|
||||||
|
"deliveryTerms": {
|
||||||
|
"1": "يستغرق الشحن ما بين 1 إلى 7 أيام.",
|
||||||
|
"2": "تظل البضاعة ملكًا لشركة Growheads حتى يتم استلام الدفعة كاملة.",
|
||||||
|
"3": "إذا كان هناك اشتباه في تلف البضاعة أثناء النقل أو نقص في الأصناف، يجب الاحتفاظ بتغليف الشحن لفحصه من قبل خبير. يجب أن يؤكد الناقل أي تلف في التغليف على مذكرة التسليم مع تحديد نوع ومدى التلف. يجب الإبلاغ عن أضرار الشحن إلى Growheads فورًا كتابيًا عبر الفاكس أو البريد الإلكتروني أو البريد. لهذا الغرض، يجب التقاط صور للبضاعة التالفة وكذلك لصندوق الشحن التالف مع ملصق العنوان. يجب أيضًا الاحتفاظ بصندوق الشحن التالف. هذه الوثائق مطلوبة للمطالبة بالتعويض من شركة الشحن.",
|
||||||
|
"4": "عند إعادة البضائع المعيبة، يجب على العميل التأكد من تغليف البضائع بشكل صحيح.",
|
||||||
|
"5": "يجب تسجيل جميع المرتجعات مسبقًا لدى Growheads.",
|
||||||
|
"6": "يتحمل العميل مخاطر إرسال الأصناف إلينا، ما لم تكن إعادة البضائع المعيبة.",
|
||||||
|
"7": "يحق لـ Growheads أن تطلب استلام البضائع من خلال Deutsche Post/GLS أو ناقل من اختيارها.",
|
||||||
|
"8": "يتم حساب تكاليف البريد بناءً على الوزن. تحتفظ Growheads بحق تمرير أي زيادات في الأسعار من شركات الشحن (رسوم المرور، رسوم الوقود).",
|
||||||
|
"9": "عادةً ما يتم شحن طرودنا عبر: GLS، DHL و Deutsche Post AG.",
|
||||||
|
"10": "بالنسبة للأصناف الثقيلة أو الضخمة بشكل خاص، نحتفظ بحق فرض رسوم إضافية على تكاليف التسليم. عادةً ما تكون هذه الرسوم مذكورة في قائمة الأسعار.",
|
||||||
|
"11": "يمكن الدفع مقدمًا عن طريق التحويل البنكي إلى الحساب المصرفي المحدد.",
|
||||||
|
"12": "إذا حدث تأخير في التسليم نتحمل مسؤوليته، فإن فترة السماح التي يحق للمشتري تحديدها محدودة بأسبوعين. تبدأ الفترة من استلام Growheads لإشعار فترة السماح.",
|
||||||
|
"13": "يجب الإبلاغ كتابيًا عن العيوب الظاهرة في البضاعة فور التسليم. إذا لم يفعل العميل ذلك، تُستبعد مطالبات الضمان عن العيوب الظاهرة.",
|
||||||
|
"14": "إذا اشتكى العميل من عيب، يجب عليه إعادة البضاعة المعيبة إلينا مع وصف دقيق للعيب. يجب إرفاق نسخة من فاتورتنا مع الشحنة. يجب إعادة البضاعة في التغليف الأصلي أو في تغليف يحمي البضاعة بنفس طريقة التغليف الأصلي لتجنب التلف أثناء النقل العكسي."
|
||||||
|
},
|
||||||
|
"consultationLiability": {
|
||||||
|
"title": "الاستشارة والمسؤولية",
|
||||||
|
"1": "نقدم استشارات فنية تطبيقية بأفضل ما لدينا من معرفة بناءً على خبرتنا ومهاراتنا.",
|
||||||
|
"2": "المشتري مسؤول عن الالتزام باللوائح القانونية المتعلقة بالتخزين والنقل والاستخدام للبضائع."
|
||||||
|
},
|
||||||
|
"paymentConditions": {
|
||||||
|
"title": "شروط الدفع",
|
||||||
|
"1": "تظل البضاعة ملكًا لشركة Growheads حتى يتم استلام الدفعة كاملة.",
|
||||||
|
"2": "تُدفع الفواتير مقدمًا عن طريق التحويل البنكي إلى حسابنا. إذا دفعت مقدمًا، سيتم شحن البضاعة بمجرد تسجيل المبلغ في حسابنا."
|
||||||
|
},
|
||||||
|
"retentionOfTitle": {
|
||||||
|
"title": "الاحتفاظ بالملكية",
|
||||||
|
"content": "تظل البضاعة المسلمة ملكًا لشركة Growheads حتى يسدد المشتري جميع المطالبات الموجهة إليه. إذا قام البائع بإعادة بيع البضاعة، فإنه يتنازل بموجب هذا عن المطالبات الناشئة من البيع لنا. إذا تأخر المشتري في دفعاته، يمكننا في أي وقت طلب إعادة البضاعة دون الانسحاب من العقد."
|
||||||
|
},
|
||||||
|
"distanceSelling": {
|
||||||
|
"title": "معلومات وفقًا لقانون البيع عن بعد",
|
||||||
|
"intro": "تنطبق المعلومات التالية فقط على العقود المبرمة بين Growheads والمستهلكين عن طريق طلب الكتالوج أو الإنترنت أو وسائل الاتصال عن بعد الأخرى. وهي محدودة للمستهلكين داخل الاتحاد الأوروبي.",
|
||||||
|
"sections": {
|
||||||
|
"1": {
|
||||||
|
"title": "الخصائص الأساسية للبضاعة",
|
||||||
|
"content": "يرجى الرجوع إلى الشروحات في الكتالوج أو على موقعنا الإلكتروني لمعرفة الخصائص الأساسية للبضاعة. العروض في كتالوجنا وعلى موقعنا غير ملزمة. الطلبات المقدمة إلينا تعتبر عروضًا ملزمة. يمكن لـ Growheads قبولها خلال 14 يومًا من استلام الطلب عن طريق إرسال تأكيد الطلب أو شحن البضاعة."
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"title": "التحفظ",
|
||||||
|
"content": "إذا لم تكن جميع الأصناف المطلوبة متوفرة للتسليم، نحتفظ بحق التسليم الجزئي إذا كان ذلك معقولًا للعميل. قد تختلف بعض الأصناف عن الصور والوصف في الكتالوج والموقع الإلكتروني، خاصةً البضائع المصنوعة يدويًا. لذلك نحتفظ بحق تسليم بضائع ذات جودة وسعر معادل إذا لزم الأمر."
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"title": "الأسعار والضرائب",
|
||||||
|
"content": "يمكنك العثور على أسعار الأصناف الفردية شاملة ضريبة القيمة المضافة في الكتالوج أو على موقعنا. تصبح الأسعار غير صالحة عند صدور كتالوج جديد."
|
||||||
|
},
|
||||||
|
"4": "جميع الأسعار عرضة للأخطاء أو تقلبات الأسعار. إذا حدث تغيير في السعر، يحق للمشتري ممارسة حقه في الإرجاع.",
|
||||||
|
"5": {
|
||||||
|
"title": "مدة الضمان",
|
||||||
|
"content": "تطبق فترة الضمان القانونية لمدة 24 (أربعة وعشرين) شهرًا. في حالات فردية، قد تنطبق فترات أطول إذا منحها المصنع."
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"title": "حق الإرجاع / حق الانسحاب",
|
||||||
|
"content": "يتمتع العميل بحق إرجاع لمدة 14 يومًا.\nتبدأ الفترة من استلام العميل للبضاعة وتُعتبر محفوظة بإرسال الانسحاب في الوقت المناسب إلى Growheads. تستثنى من ذلك الأغذية والسلع القابلة للتلف، وكذلك المنتجات المصممة خصيصًا أو البضائع التي تم طلبها بناءً على رغبة العميل. يجب أن يتم الإرجاع عن طريق إعادة البضاعة خلال الفترة. إذا لم يكن بالإمكان شحن البضاعة، يجب إرسال طلب الإرجاع إلينا خلال الفترة عن طريق رسالة، بطاقة بريدية، بريد إلكتروني، أو وسيلة دائمة أخرى. يكفي الإرسال في الوقت المناسب إلى العنوان المذكور تحت 7) للحفاظ على المهلة. لا يتطلب الانسحاب سببًا. سيتم رد ثمن الشراء وأي تكاليف تسليم وشحن بعد استلامنا للبضاعة. القيمة الحاسمة هي قيمة البضاعة المعادة وقت الشراء، وليس قيمة الطلب الكامل. عادةً ما يمكن لـ Growheads ترتيب الاستلام منك."
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"title": "اسم وعنوان الشركة، الشكاوى، الاستدعاءات",
|
||||||
|
"content": "Growheads\nTrachenberger Straße 14\n01129 Dresden"
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"title": "مكان التنفيذ والاختصاص القضائي",
|
||||||
|
"content": "مكان التنفيذ والاختصاص القضائي لجميع المطالبات هو دريسدن، ما لم تنص أحكام قانونية إلزامية على خلاف ذلك."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
8
src/i18n/locales/ar/legal-batterie.js
Normal file
8
src/i18n/locales/ar/legal-batterie.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export default {
|
||||||
|
"title": "معلومات قانون البطاريات",
|
||||||
|
"intro": "فيما يتعلق ببيع البطاريات أو تسليم الأجهزة التي تحتوي على بطاريات، نحن ملزمون بإبلاغكم بما يلي:",
|
||||||
|
"returnObligation": "بصفتك مستخدم نهائي، أنت ملزم قانونيًا بإعادة البطاريات المستخدمة. يمكنك إعادة البطاريات القديمة التي نمتلكها أو التي كانت ضمن مجموعتنا كبطاريات جديدة مجانًا إلى مستودع الشحن الخاص بنا (عنوان الشحن).",
|
||||||
|
"symbolsInfo": "الرموز المعروضة على البطاريات تعني ما يلي:",
|
||||||
|
"wasteSymbol": "رمز سلة المهملات المعلمة بعلامة إلغاء يعني أنه لا يجوز التخلص من البطارية مع النفايات المنزلية.",
|
||||||
|
"chemicalSymbols": "Pb = البطارية تحتوي على أكثر من 0.004 بالمئة بالوزن من الرصاص\nCd = البطارية تحتوي على أكثر من 0.002 بالمئة بالوزن من الكادميوم\nHg = البطارية تحتوي على أكثر من 0.0005 بالمئة بالوزن من الزئبق."
|
||||||
|
};
|
||||||
70
src/i18n/locales/ar/legal-datenschutz.js
Normal file
70
src/i18n/locales/ar/legal-datenschutz.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
export default {
|
||||||
|
"title": "سياسة الخصوصية",
|
||||||
|
"responsibleParty": {
|
||||||
|
"title": "الجهة المسؤولة بموجب قانون حماية البيانات:",
|
||||||
|
"company": "Growheads\nTrachenberger Straße 14\n01129 Dresden"
|
||||||
|
},
|
||||||
|
"generalInfo": "ما لم يُذكر خلاف ذلك أدناه، فإن تقديم بياناتك الشخصية ليس مطلوبًا قانونيًا أو تعاقديًا، ولا هو ضروري لإبرام عقد. أنت غير ملزم بتقديم البيانات. عدم تقديمها لا يترتب عليه أي عواقب. هذا ينطبق فقط طالما لم يُذكر خلاف ذلك في عمليات المعالجة التالية. \"البيانات الشخصية\" تعني جميع المعلومات المتعلقة بشخص طبيعي محدد أو قابل للتحديد.",
|
||||||
|
"sections": {
|
||||||
|
"informationDeletion": {
|
||||||
|
"title": "المعلومات، الحذف، الحظر",
|
||||||
|
"content": "يمكنك في أي وقت طلب معلومات عن بياناتك الشخصية، مصدرها والمستلمين لها، وهدف معالجة البيانات، كما يمكنك طلب تصحيح أو حظر أو حذف هذه البيانات مجانًا. يرجى استخدام خيارات الاتصال الموجودة في تذييل الصفحة أو في الإشعار القانوني لهذا الغرض. نحن متاحون أيضًا في أي وقت لأي أسئلة إضافية حول الموضوع. يرجى ملاحظة أننا غير مخولين ولن نقوم بحذف بيانات الفواتير، بيانات البنك، والبيانات التي تم إرسالها لمزود خدمة الشحن. البيانات التي يمكن حذفها تشمل: حسابات العملاء على خادم الويب، وكذلك في نظام إدارة البضائع، والبريد الإلكتروني الذي لا يرتبط مباشرة بطلب.",
|
||||||
|
},
|
||||||
|
"serverLogfiles": {
|
||||||
|
"title": "ملفات سجل الخادم",
|
||||||
|
"content": "يمكنك زيارة مواقعنا الإلكترونية دون تقديم أي معلومات عن نفسك. في كل مرة تصل فيها إلى موقعنا، يتم إرسال بيانات الاستخدام من متصفح الإنترنت الخاص بك وتخزينها في بيانات السجل (ملفات سجل الخادم). تشمل هذه البيانات المخزنة، على سبيل المثال، اسم الصفحة التي تم الوصول إليها، تاريخ ووقت الوصول، كمية البيانات المنقولة، ومزود الخدمة الذي يطلب البيانات. تُستخدم هذه البيانات فقط لضمان التشغيل السلس لموقعنا وتحسين عرضنا. هذه البيانات ليست بيانات شخصية. لا يتم دمج هذه البيانات مع مصادر بيانات أخرى. إذا علمنا بمؤشرات محددة على استخدام غير قانوني، نحتفظ بالحق في مراجعة هذه البيانات لاحقًا.",
|
||||||
|
},
|
||||||
|
"customerAccount": {
|
||||||
|
"title": "حساب العميل",
|
||||||
|
"content": "عند فتح حساب عميل، نجمع بياناتك الشخصية بالقدر المحدد هناك. تهدف معالجة البيانات إلى تحسين تجربة التسوق الخاصة بك وتبسيط معالجة الطلبات. تتم المعالجة بناءً على المادة 6 (1) حرف أ من DSGVO بموافقتك. يمكنك سحب موافقتك في أي وقت بإبلاغنا، دون التأثير على قانونية المعالجة التي تمت بناءً على الموافقة حتى السحب. سيتم حذف حساب العميل الخاص بك بعد ذلك.",
|
||||||
|
},
|
||||||
|
"googleSSO": {
|
||||||
|
"title": "تسجيل الدخول باستخدام Google (تسجيل الدخول الموحد من Google)",
|
||||||
|
"content": "نقدم لك خيار تسجيل الدخول إلى حساب العميل الخاص بك باستخدام حساب Google الخاص بك. عند استخدام وظيفة \"تسجيل الدخول باستخدام Google\"، يتم التحقق من الهوية عبر خدمة Google Single Sign-On. في هذه العملية، قد يتم تخزين ملفات تعريف الارتباط من Google على جهازك، وهي ضرورية لعملية تسجيل الدخول والتحقق من الهوية. كجزء من تسجيل الدخول عبر Google، نتلقى من Google بيانات شخصية معينة للتحقق من هويتك. على وجه الخصوص، تنقل Google لنا اسمك، عنوان بريدك الإلكتروني، وإذا كان مخزنًا في حساب Google الخاص بك، صورة ملفك الشخصي. يتم توفير هذه المعلومات من Google بمجرد تسجيل دخولك إلى متجرنا الإلكتروني باستخدام حساب Google الخاص بك. يمكن لـ Google، كمزود طرف ثالث، الوصول إلى هذه البيانات ومعالجتها؛ وقد يشمل ذلك نقل البيانات إلى الولايات المتحدة الأمريكية. لقد أبرمنا مع Google بنود حماية بيانات قياسية وفقًا للمادة 46 (2) حرف ج من DSGVO لضمان مستوى مناسب من حماية البيانات عند نقل بياناتك. يمكن العثور على مزيد من التفاصيل حول معالجة البيانات بواسطة Google في سياسة الخصوصية الخاصة بـ Google (على https://policies.google.com/privacy?hl=en).",
|
||||||
|
"legalBasis": "تتم معالجة البيانات المتعلقة بتسجيل الدخول عبر Google بناءً على المادة 6 (1) حرف ب من DSGVO (تنفيذ التدابير التمهيدية للعقد وتنفيذ العقد، مثل إنشاء واستخدام حساب العميل الخاص بك) وكذلك المادة 6 (1) حرف ف من DSGVO (مصلحتنا المشروعة في توفير خيار تسجيل دخول سريع ومريح لك).",
|
||||||
|
"voluntaryUse": "استخدام وظيفة \"تسجيل الدخول باستخدام Google\" هو أمر طوعي. بالطبع يمكنك أيضًا استخدام متجرنا الإلكتروني وحساب العميل الخاص بك بدون Google SSO عن طريق التسجيل أو تسجيل الدخول باستخدام بريدك الإلكتروني وكلمة المرور كالمعتاد. إذا اخترت استخدام تسجيل الدخول عبر Google، يمكنك قطع هذا الرابط في أي وقت عن طريق إزالة الاتصال في إعدادات حساب Google الخاص بك.",
|
||||||
|
"yourRights": "فيما يتعلق بالبيانات الشخصية المعالجة عبر Google SSO، لديك الحقوق القانونية كصاحب بيانات. على وجه الخصوص، لديك الحق في الحصول على معلومات حول البيانات المخزنة عنك (المادة 15 DSGVO)، وتصحيح البيانات غير الدقيقة (المادة 16 DSGVO)، أو طلب حذف بياناتك (المادة 17 DSGVO). علاوة على ذلك، لديك الحق في تقييد معالجة بياناتك (المادة 18 DSGVO) والحق في نقل البيانات (المادة 20 DSGVO). إذا استندنا في المعالجة إلى مصلحتنا المشروعة، يمكنك الاعتراض على المعالجة (المادة 21 DSGVO). بالإضافة إلى ذلك، يمكنك الاتصال في أي وقت بالسلطة المختصة لحماية البيانات لتقديم شكوى. تنطبق حقوقك وخياراتك القائمة من بقية سياسة الخصوصية أيضًا على استخدام تسجيل الدخول عبر Google.",
|
||||||
|
},
|
||||||
|
"orders": {
|
||||||
|
"title": "جمع، معالجة واستخدام البيانات الشخصية للطلبات",
|
||||||
|
"content": "عند تقديم طلب، نجمع ونستخدم بياناتك الشخصية فقط بالقدر اللازم لتنفيذ ومعالجة طلبك وللتعامل مع استفساراتك. تقديم البيانات مطلوب لإبرام العقد. عدم تقديمها يؤدي إلى عدم إبرام العقد. تتم المعالجة بناءً على المادة 6 (1) حرف ب من DSGVO وهي ضرورية لتنفيذ عقد معك. لن يتم تمرير بياناتك إلى أطراف ثالثة بدون موافقتك الصريحة. الاستثناء الوحيد هم شركاؤنا في الخدمة الذين نحتاجهم لمعالجة العلاقة التعاقدية أو مزودو الخدمات الذين نستخدمهم في إطار معالجة بالنيابة. بالإضافة إلى المستلمين المذكورين في البنود الخاصة بهذه السياسة، يشمل ذلك على سبيل المثال مستلمي الفئات التالية: مزودو خدمات الشحن، مزودو خدمات الدفع، مزودو خدمات إدارة البضائع، مزودو خدمات معالجة الطلبات، مستضيفو الويب، مزودو خدمات تكنولوجيا المعلومات وتجار الدروبشيبينغ. في جميع الحالات، نلتزم بدقة بالمتطلبات القانونية. نطاق نقل البيانات محدود إلى الحد الأدنى.",
|
||||||
|
},
|
||||||
|
"newsletter": {
|
||||||
|
"title": "استخدام عنوان البريد الإلكتروني لإرسال النشرات الإخبارية",
|
||||||
|
"content": "نستخدم عنوان بريدك الإلكتروني بشكل مستقل عن معالجة العقد فقط لأغراضنا الإعلانية الخاصة لإرسال النشرات الإخبارية، بشرط أن تكون قد وافقت على ذلك صراحة. تتم المعالجة بناءً على المادة 6 (1) حرف أ من DSGVO بموافقتك. يمكنك سحب موافقتك في أي وقت دون التأثير على قانونية المعالجة التي تمت بناءً على الموافقة حتى السحب. يمكنك إلغاء الاشتراك في النشرة الإخبارية في أي وقت باستخدام الرابط المناسب في النشرة أو بإبلاغنا. سيتم بعد ذلك إزالة عنوان بريدك الإلكتروني من قائمة التوزيع. يتم تمرير بياناتك إلى مزود خدمة للتسويق عبر البريد الإلكتروني في إطار معالجة بالنيابة. لا يتم نقلها إلى أطراف ثالثة أخرى. سيتم نقل بياناتك إلى دولة ثالثة يوجد بشأنها قرار كفاية من المفوضية الأوروبية.",
|
||||||
|
},
|
||||||
|
"chatbot": {
|
||||||
|
"title": "استخدام روبوت الدردشة الذكي (OpenAI API)",
|
||||||
|
"content": "نستخدم روبوت دردشة مدعوم بالذكاء الاصطناعي على موقعنا الإلكتروني، يتم تشغيله عبر واجهة برمجة التطبيقات (API) لمزود الخدمة OpenAI. يهدف روبوت الدردشة إلى الرد على استفسارات الزوار بكفاءة وبشكل آلي، مما يوفر وظيفة دعم. عند استخدام روبوت الدردشة، تتم معالجة مدخلاتك بواسطة النظام لتوليد ردود مناسبة. تتم المعالجة بشكل مجهول - لا يتم جمع أو تخزين عناوين IP أو بيانات شخصية أخرى (مثل الاسم أو بيانات الاتصال).",
|
||||||
|
"legalBasis": "الأساس القانوني لاستخدام روبوت الدردشة هو مصلحتنا المشروعة وفقًا للمادة 6 (1) حرف ف من DSGVO. تكمن هذه المصلحة في توفير دعم فعال للزوار وتحسين تجربة المستخدم على موقعنا.",
|
||||||
|
"dataRecipient": "المستلم لبيانات الدردشة هو OpenAI (OpenAI OpCo, LLC) كمزود خدمة فني. تقوم OpenAI بمعالجة محتوى الدردشة المرسل على خوادمها فقط لغرض توليد الردود. تعمل OpenAI كمعالج بيانات وفقًا للمادة 28 من DSGVO ولا تستخدم البيانات لأغراضها الخاصة. لقد أبرمنا عقد معالجة بيانات مع OpenAI يتضمن بنودًا تعاقدية قياسية للاتحاد الأوروبي كضمانات مناسبة لحماية البيانات. يقع المقر الرئيسي لـ OpenAI في الولايات المتحدة الأمريكية؛ يضمن الاتفاق على البنود التعاقدية القياسية مستوى حماية بيانات مناسبًا يتوافق مع الاتحاد الأوروبي عند نقل بياناتك.",
|
||||||
|
"dataRetention": "نحتفظ بطلبات الدردشة الخاصة بك فقط طالما كان ذلك ضروريًا للمعالجة والرد. بمجرد الانتهاء من طلبك، يتم حذف أو إخفاء سجلات الدردشة بسرعة. وفقًا لما صرحت به OpenAI، يتم الاحتفاظ ببيانات الدردشة المعالجة مؤقتًا فقط ويتم حذفها تلقائيًا بعد 30 يومًا كحد أقصى.",
|
||||||
|
"voluntaryUse": "استخدام روبوت الدردشة هو أمر طوعي. إذا لم تستخدم روبوت الدردشة، فلن يتم نقل أي بيانات إلى OpenAI. يرجى عدم إدخال أي بيانات شخصية حساسة في الدردشة.",
|
||||||
|
},
|
||||||
|
"cookies": {
|
||||||
|
"title": "ملفات تعريف الارتباط (Cookies)",
|
||||||
|
"intro": "يستخدم موقعنا ملفات تعريف الارتباط في الحالات التالية:",
|
||||||
|
"payment": "1. عملية الدفع: عند الدفع ببطاقات الائتمان أو التحويلات الفورية (مثل Klarna Instant)، يتم استخدام ملفات تعريف ارتباط ضرورية تقنيًا. تحتوي هذه على سلسلة مميزة تتيح التعرف الفريد على المتصفح. يتم تعيين ملفات تعريف الارتباط بواسطة مزود خدمة الدفع Stripe وهي ضرورية تمامًا لمعالجة المدفوعات بأمان وسلاسة. بدون هذه الملفات، لا يمكن إتمام الطلب باستخدام هذه طرق الدفع. تتم المعالجة بناءً على المادة 6 (1) حرف ب من DSGVO لتنفيذ العقد.",
|
||||||
|
"googleSSO": "2. تسجيل الدخول الموحد من Google (SSO): عند استخدام تسجيل الدخول عبر Google، يتم تعيين ملفات تعريف ارتباط بواسطة Google ضرورية لعملية تسجيل الدخول والتحقق من الهوية. تتيح لك هذه الملفات تسجيل الدخول بسهولة باستخدام حساب Google الخاص بك دون الحاجة لتسجيل الدخول في كل مرة. تتم المعالجة بناءً على المادة 6 (1) حرف ب من DSGVO (تنفيذ العقد) والمادة 6 (1) حرف ف من DSGVO (مصلحة مشروعة في تسجيل دخول سهل الاستخدام).",
|
||||||
|
"otherPayments": "بالنسبة لطرق الدفع الأخرى – الخصم المباشر، الاستلام أو الدفع عند الاستلام – لا يتم استخدام ملفات تعريف ارتباط إضافية، ما لم تستخدم تسجيل الدخول عبر Google.",
|
||||||
|
},
|
||||||
|
"mollie": {
|
||||||
|
"title": "Mollie (معالجة الدفع)",
|
||||||
|
"content": "نستخدم مزود خدمة الدفع Mollie على موقعنا لمعالجة المدفوعات. مزود الخدمة هو Mollie B.V.، Keizersgracht 126، 1015 CW Amsterdam، هولندا. في هذا السياق، يتم نقل البيانات الشخصية اللازمة لمعالجة الدفع إلى Mollie - على وجه الخصوص اسمك، عنوان بريدك الإلكتروني، عنوان الفاتورة، معلومات الدفع (مثل بيانات بطاقة الائتمان) وعنوان IP. تتم معالجة البيانات لغرض معالجة الدفع؛ الأساس القانوني هو المادة 6 (1) حرف ب من DSGVO، لأنها تخدم تنفيذ عقد معك.",
|
||||||
|
"responsibility": "تعالج Mollie أيضًا بعض البيانات كمسؤول مستقل، على سبيل المثال للوفاء بالالتزامات القانونية (مثل مكافحة غسيل الأموال) ومنع الاحتيال. بالإضافة إلى ذلك، أبرمنا عقد معالجة بيانات مع Mollie وفقًا للمادة 28 من DSGVO؛ في إطار هذا العقد، تتصرف Mollie عند معالجة المدفوعات فقط وفقًا لتعليماتنا.",
|
||||||
|
"dataTransfer": "في حال معالجة Mollie بيانات شخصية خارج الاتحاد الأوروبي، وخاصة في الولايات المتحدة الأمريكية، يتم ذلك مع الالتزام بضمانات مناسبة. تستخدم Mollie البنود التعاقدية القياسية للاتحاد الأوروبي وفقًا للمادة 46 من DSGVO لضمان مستوى مناسب من حماية البيانات. ومع ذلك، نشير إلى أن الولايات المتحدة تُعتبر دولة ثالثة قد لا توفر مستوى حماية بيانات كافٍ بموجب قانون حماية البيانات. يمكن العثور على مزيد من المعلومات في سياسة الخصوصية الخاصة بـ Mollie على https://www.mollie.com/en/privacy.",
|
||||||
|
},
|
||||||
|
"dataRetention": {
|
||||||
|
"title": "مدة التخزين",
|
||||||
|
"content": "بعد إتمام معالجة العقد بالكامل، يتم تخزين البيانات في البداية لمدة فترة الضمان، ثم مع مراعاة الفترات القانونية، وخاصة فترات الحفظ الضريبية والتجارية، ثم يتم حذفها بعد انتهاء الفترة، ما لم تكن قد وافقت على المعالجة والاستخدام الإضافيين.",
|
||||||
|
},
|
||||||
|
"dataSubjectRights": {
|
||||||
|
"title": "حقوق صاحب البيانات",
|
||||||
|
"content": "إذا توفرت الشروط القانونية، لديك الحقوق التالية وفقًا للمادة 15 إلى 20 من DSGVO: الحق في الحصول على المعلومات، التصحيح، الحذف، تقييد المعالجة، نقل البيانات. بالإضافة إلى ذلك، وفقًا للمادة 21 (1) من DSGVO، لديك الحق في الاعتراض على المعالجة التي تستند إلى المادة 6 (1) حرف ف من DSGVO، وكذلك على المعالجة لأغراض التسويق المباشر. اتصل بنا إذا رغبت. يمكنك العثور على بيانات الاتصال في إشعارنا القانوني.",
|
||||||
|
},
|
||||||
|
"supervisoryAuthority": {
|
||||||
|
"title": "الحق في تقديم شكوى إلى السلطة الرقابية",
|
||||||
|
"content": "وفقًا للمادة 77 من DSGVO، لديك الحق في تقديم شكوى إلى السلطة الرقابية إذا كنت تعتقد أن معالجة بياناتك الشخصية غير قانونية.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
25
src/i18n/locales/ar/legal-impressum.js
Normal file
25
src/i18n/locales/ar/legal-impressum.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export default {
|
||||||
|
"title": "الإشعار القانوني (Impressum)",
|
||||||
|
"sections": {
|
||||||
|
"operator": {
|
||||||
|
"title": "المشغل والمسؤول عن محتوى هذا المتجر هو:",
|
||||||
|
"content": "Growheads\nMax Schön\nTrachenberger Straße 14\n01129 Dresden"
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"title": "الاتصال:",
|
||||||
|
"content": "البريد الإلكتروني: service@growheads.de"
|
||||||
|
},
|
||||||
|
"vatId": {
|
||||||
|
"title": "رقم ضريبة القيمة المضافة:",
|
||||||
|
"content": "رقم ضريبة القيمة المضافة: DE323017152"
|
||||||
|
},
|
||||||
|
"disclaimer": {
|
||||||
|
"title": "تنصل من المسؤولية:",
|
||||||
|
"content": "لا نتحمل أي مسؤولية عن محتوى عناوين الإنترنت الخارجية المرتبطة بهذه الصفحات. المشغلون المعنيون هم المسؤولون عن محتوى المواقع غير التابعة للشركة."
|
||||||
|
},
|
||||||
|
"copyright": {
|
||||||
|
"title": "بند حقوق النشر:",
|
||||||
|
"content": "المحتوى المعروض هنا يخضع بشكل عام لحقوق النشر ولا يجوز توزيعه إلا بموافقة كتابية.\nحقوق المواد المصورة أو النصية الخاصة بأطراف أخرى ليست مقيدة أو ملغاة بهذا البند."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
11
src/i18n/locales/ar/legal-widerruf.js
Normal file
11
src/i18n/locales/ar/legal-widerruf.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export default {
|
||||||
|
"title": "حق الانسحاب",
|
||||||
|
"withdrawalRight": "لديك الحق في الانسحاب من هذا العقد خلال أربعة عشر يومًا دون إبداء أي سبب. تبدأ فترة الانسحاب من اليوم الذي تستلم فيه أنت أو طرف ثالث تعينه، وليس الناقل، البضائع.",
|
||||||
|
"exerciseWithdrawal": "لممارسة حقك في الانسحاب، يجب عليك إبلاغنا",
|
||||||
|
"contactInfo": "Growheads\nTrachenberger Straße 14\n01129 Dresden\nE-Mail: service@growheads.de",
|
||||||
|
"withdrawalProcess": "ببيان واضح (مثل رسالة مرسلة بالبريد، فاكس أو بريد إلكتروني) عن قرارك بالانسحاب من هذا العقد. يمكنك استخدام نموذج الانسحاب المرفق لهذا الغرض، لكنه ليس إلزاميًا. وللحفاظ على مهلة الانسحاب، يكفي أن ترسل إشعارك بممارسة حق الانسحاب قبل انتهاء فترة الانسحاب.",
|
||||||
|
"consequencesTitle": "عواقب الانسحاب",
|
||||||
|
"consequences": "إذا انسحبت من هذا العقد، سنرد لك جميع المدفوعات التي تلقيناها منك، بما في ذلك تكاليف التوصيل (باستثناء التكاليف الإضافية الناتجة عن اختيارك لنوع توصيل غير أرخص توصيل قياسي نقدمه)، دون تأخير غير مبرر وفي موعد أقصاه أربعة عشر يومًا من اليوم الذي استلمنا فيه إشعار انسحابك من هذا العقد. سنستخدم نفس وسيلة الدفع التي استخدمتها في المعاملة الأصلية لهذا السداد، ما لم يتم الاتفاق معك صراحة على خلاف ذلك؛ ولن تُفرض عليك أي رسوم مقابل هذا السداد. قد نرفض السداد حتى نستلم البضائع مرة أخرى أو تقدم دليلاً على إرسال البضائع، أيهما أسبق. يجب عليك إعادة البضائع أو تسليمها لنا دون تأخير غير مبرر وفي كل الأحوال خلال أربعة عشر يومًا من اليوم الذي تخطرنا فيه بانسحابك من هذا العقد. تُعتبر المهلة محفوظة إذا أرسلت البضائع قبل انتهاء فترة الأربعة عشر يومًا. تتحمل أنت التكاليف المباشرة لإعادة البضائع. أنت مسؤول فقط عن أي انخفاض في قيمة البضائع ناتج عن تعامل غير ضروري لتحديد طبيعة وخصائص وعمل البضائع.",
|
||||||
|
"noWithdrawalTitle": "إشعار بعدم وجود حق الانسحاب",
|
||||||
|
"noWithdrawal": "لا ينطبق حق الانسحاب على البضائع التي تم تصنيعها أو تفصيلها حسب طلب العميل (الأفلام والأنابيب)، لكن يمكن منحه باتفاق. كما أن حاويات الأسمدة التي تم إزالة ختمها أو تدميره بفتحها مستثناة أيضًا من حق الانسحاب."
|
||||||
|
};
|
||||||
3
src/i18n/locales/ar/locale.js
Normal file
3
src/i18n/locales/ar/locale.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
"code": "ar-EG"
|
||||||
|
};
|
||||||
9
src/i18n/locales/ar/navigation.js
Normal file
9
src/i18n/locales/ar/navigation.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export default {
|
||||||
|
"home": "الرئيسية",
|
||||||
|
"aktionen": "العروض",
|
||||||
|
"filiale": "الفرع",
|
||||||
|
"categories": "الفئات",
|
||||||
|
"categoriesOpen": "افتح الفئات",
|
||||||
|
"categoriesClose": "أغلق الفئات",
|
||||||
|
"otherCategories": "فئات أخرى"
|
||||||
|
};
|
||||||
50
src/i18n/locales/ar/orders.js
Normal file
50
src/i18n/locales/ar/orders.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
export default {
|
||||||
|
"status": {
|
||||||
|
"new": "قيد التنفيذ",
|
||||||
|
"pending": "جديد",
|
||||||
|
"processing": "قيد التنفيذ",
|
||||||
|
"cancelled": "ملغاة",
|
||||||
|
"shipped": "تم الشحن",
|
||||||
|
"delivered": "تم التوصيل",
|
||||||
|
"return": "إرجاع",
|
||||||
|
"partialReturn": "إرجاع جزئي",
|
||||||
|
"partialDelivered": "تم التوصيل جزئياً"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"orderNumber": "رقم الطلب",
|
||||||
|
"date": "التاريخ",
|
||||||
|
"status": "الحالة",
|
||||||
|
"items": "العناصر",
|
||||||
|
"total": "الإجمالي",
|
||||||
|
"actions": "الإجراءات",
|
||||||
|
"viewDetails": "عرض التفاصيل"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"viewDetails": "عرض التفاصيل",
|
||||||
|
"cancelOrder": "إلغاء الطلب"
|
||||||
|
},
|
||||||
|
"noOrders": "لم تقم بوضع أي طلبات بعد.",
|
||||||
|
"details": {
|
||||||
|
"title": "تفاصيل الطلب: {{orderId}}",
|
||||||
|
"deliveryAddress": "عنوان التوصيل",
|
||||||
|
"invoiceAddress": "عنوان الفاتورة",
|
||||||
|
"orderDetails": "تفاصيل الطلب",
|
||||||
|
"deliveryMethod": "طريقة التوصيل:",
|
||||||
|
"paymentMethod": "طريقة الدفع:",
|
||||||
|
"notSpecified": "غير محدد",
|
||||||
|
"orderedItems": "العناصر المطلوبة",
|
||||||
|
"item": "العنصر",
|
||||||
|
"quantity": "الكمية",
|
||||||
|
"price": "السعر",
|
||||||
|
"vat": "ضريبة القيمة المضافة",
|
||||||
|
"total": "الإجمالي",
|
||||||
|
"cancelOrder": "إلغاء الطلب"
|
||||||
|
},
|
||||||
|
"cancelConfirm": {
|
||||||
|
"title": "إلغاء الطلب",
|
||||||
|
"message": "هل أنت متأكد أنك تريد إلغاء هذا الطلب؟",
|
||||||
|
"confirm": "إلغاء الطلب",
|
||||||
|
"cancelling": "جارٍ الإلغاء..."
|
||||||
|
},
|
||||||
|
"processing": "يتم إكمال الطلب...",
|
||||||
|
};
|
||||||
10
src/i18n/locales/ar/pages.js
Normal file
10
src/i18n/locales/ar/pages.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export default {
|
||||||
|
"oilPress": {
|
||||||
|
"title": "استعارة معصرة زيت",
|
||||||
|
"comingSoon": "المحتوى قادم قريباً..."
|
||||||
|
},
|
||||||
|
"thcTest": {
|
||||||
|
"title": "اختبار THC",
|
||||||
|
"comingSoon": "المحتوى قادم قريباً..."
|
||||||
|
}
|
||||||
|
};
|
||||||
21
src/i18n/locales/ar/payment.js
Normal file
21
src/i18n/locales/ar/payment.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export default {
|
||||||
|
"successful": "تم الدفع بنجاح!",
|
||||||
|
"failed": "فشل الدفع",
|
||||||
|
"orderCompleted": "🎉 تم إكمال طلبك بنجاح! يمكنك الآن عرض طلباتك.",
|
||||||
|
"orderProcessing": "تمت معالجة دفعتك بنجاح. سيتم إكمال الطلب تلقائيًا.",
|
||||||
|
"paymentError": "لم نتمكن من معالجة دفعتك. يرجى المحاولة مرة أخرى أو اختيار طريقة دفع أخرى.",
|
||||||
|
"viewOrders": "عرض طلباتي",
|
||||||
|
"loadingPaymentComponent": "جارٍ تحميل مكون الدفع...",
|
||||||
|
"methods": {
|
||||||
|
"selectPaymentMethod": "اختر طريقة الدفع",
|
||||||
|
"bankTransfer": "تحويل بنكي",
|
||||||
|
"bankTransferDescription": "ادفع عن طريق التحويل البنكي",
|
||||||
|
"cardPayment": "بطاقة، Sofortüberweisung، Apple Pay، Google Pay، PayPal",
|
||||||
|
"cardPaymentDescription": "ادفع بالبطاقة أو Sofortüberweisung",
|
||||||
|
"cardPaymentMinAmount": "ادفع بالبطاقة أو Sofortüberweisung (الحد الأدنى: €0.50)",
|
||||||
|
"cashOnDelivery": "الدفع عند الاستلام",
|
||||||
|
"cashOnDeliveryDescription": "ادفع عند الاستلام (رسوم إضافية €8.99)",
|
||||||
|
"cashInStore": "الدفع في المتجر",
|
||||||
|
"cashInStoreDescription": "ادفع عند الاستلام",
|
||||||
|
}
|
||||||
|
};
|
||||||
47
src/i18n/locales/ar/product.js
Normal file
47
src/i18n/locales/ar/product.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export default {
|
||||||
|
"loading": "جارٍ تحميل المنتج...",
|
||||||
|
"notFound": "المنتج غير موجود",
|
||||||
|
"notFoundDescription": "المنتج الذي تبحث عنه غير موجود أو تم إزالته.",
|
||||||
|
"backToHome": "العودة إلى الصفحة الرئيسية",
|
||||||
|
"error": "خطأ",
|
||||||
|
"articleNumber": "رقم الصنف",
|
||||||
|
"manufacturer": "الشركة المصنعة",
|
||||||
|
"inclVat": "شامل {{vat}}% ضريبة القيمة المضافة",
|
||||||
|
"priceUnit": "{{price}}/{{unit}}",
|
||||||
|
"new": "جديد",
|
||||||
|
"weeks": "أسابيع",
|
||||||
|
"arriving": "الوصول:",
|
||||||
|
"inclVatFooter": "شامل {{vat}}% ضريبة القيمة المضافة,*",
|
||||||
|
"availability": "التوفر",
|
||||||
|
"inStock": "متوفر في المخزون",
|
||||||
|
"comingSoon": "قريبًا",
|
||||||
|
"deliveryTime": "مدة التوصيل",
|
||||||
|
"inclShort": "شامل",
|
||||||
|
"vatShort": "ضريبة القيمة المضافة",
|
||||||
|
"weight": "الوزن: {{weight}} كجم",
|
||||||
|
"youSave": "أنت توفر: {{amount}}",
|
||||||
|
"cheaperThanIndividual": "أرخص من الشراء بشكل فردي",
|
||||||
|
"pickupPrice": "سعر الاستلام: 19.90 € لكل قطعة.",
|
||||||
|
"consistsOf": "يتكون من:",
|
||||||
|
"loadingComponentDetails": "{{index}}. جارٍ تحميل تفاصيل المكون...",
|
||||||
|
"individualPriceTotal": "إجمالي السعر الفردي:",
|
||||||
|
"setPrice": "سعر المجموعة:",
|
||||||
|
"yourSavings": "توفيرك:",
|
||||||
|
"countDisplay": {
|
||||||
|
"noProducts": "0 منتجات",
|
||||||
|
"oneProduct": "منتج واحد",
|
||||||
|
"multipleProducts": "{{count}} منتجات",
|
||||||
|
"filteredProducts": "{{filtered}} من {{total}} منتجات",
|
||||||
|
"filteredOneProduct": "{{filtered}} من منتج واحد",
|
||||||
|
"xOfYProducts": "{{x}} من {{y}} منتجات"
|
||||||
|
},
|
||||||
|
"removeFiltersToSee": "قم بإزالة الفلاتر لرؤية المنتجات",
|
||||||
|
"outOfStock": "غير متوفر في المخزون",
|
||||||
|
"fromXProducts": "من {{count}} منتجات",
|
||||||
|
"discount": {
|
||||||
|
"from3Products": "من 3 منتجات",
|
||||||
|
"from5Products": "من 5 منتجات",
|
||||||
|
"from7Products": "من 7 منتجات",
|
||||||
|
"moreProductsMoreSavings": "كلما اخترت منتجات أكثر، كلما وفرت أكثر!"
|
||||||
|
}
|
||||||
|
};
|
||||||
5
src/i18n/locales/ar/search.js
Normal file
5
src/i18n/locales/ar/search.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
"placeholder": "ممكن تسألني عن أنواع الحشيش...",
|
||||||
|
"recording": "جاري التسجيل...",
|
||||||
|
"searchProducts": "ابحث عن المنتجات...",
|
||||||
|
};
|
||||||
11
src/i18n/locales/ar/sections.js
Normal file
11
src/i18n/locales/ar/sections.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export default {
|
||||||
|
"seeds": "بذور",
|
||||||
|
"stecklinge": "قصاصات",
|
||||||
|
"oilPress": "استعارة معصرة زيت",
|
||||||
|
"thcTest": "اختبار THC",
|
||||||
|
"address1": "Trachenberger Straße 14",
|
||||||
|
"address2": "01129 Dresden",
|
||||||
|
"showUsPhoto": "ورينا أجمل صورة عندك",
|
||||||
|
"selectSeedRate": "اختار البذرة واضغط تقييم",
|
||||||
|
"indoorSeason": "موسم الزراعة الداخلية بدأ"
|
||||||
|
};
|
||||||
34
src/i18n/locales/ar/settings.js
Normal file
34
src/i18n/locales/ar/settings.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export default {
|
||||||
|
"changePassword": "تغيير كلمة المرور",
|
||||||
|
"currentPassword": "كلمة المرور الحالية",
|
||||||
|
"newPassword": "كلمة المرور الجديدة",
|
||||||
|
"confirmNewPassword": "تأكيد كلمة المرور الجديدة",
|
||||||
|
"updatePassword": "تحديث كلمة المرور",
|
||||||
|
"changeEmail": "تغيير عنوان البريد الإلكتروني",
|
||||||
|
"password": "كلمة المرور",
|
||||||
|
"newEmail": "عنوان البريد الإلكتروني الجديد",
|
||||||
|
"updateEmail": "تحديث البريد الإلكتروني",
|
||||||
|
"apiKey": "مفتاح API",
|
||||||
|
"apiKeyDescription": "استخدم مفتاح API الخاص بك للتكامل مع التطبيقات الخارجية.",
|
||||||
|
"apiDocumentation": "توثيق API:",
|
||||||
|
"copyToClipboard": "نسخ إلى الحافظة",
|
||||||
|
"generate": "إنشاء",
|
||||||
|
"regenerate": "إعادة إنشاء",
|
||||||
|
"apiKeyCopied": "تم نسخ مفتاح API إلى الحافظة",
|
||||||
|
"errors": {
|
||||||
|
"fillAllFields": "يرجى ملء جميع الحقول",
|
||||||
|
"passwordsNotMatch": "كلمات المرور الجديدة غير متطابقة",
|
||||||
|
"passwordTooShort": "يجب أن تكون كلمة المرور الجديدة 8 أحرف على الأقل",
|
||||||
|
"passwordUpdateError": "حدث خطأ أثناء تحديث كلمة المرور",
|
||||||
|
"invalidEmail": "يرجى إدخال عنوان بريد إلكتروني صالح",
|
||||||
|
"emailUpdateError": "حدث خطأ أثناء تحديث عنوان البريد الإلكتروني",
|
||||||
|
"userNotFound": "المستخدم غير موجود",
|
||||||
|
"apiKeyGenerationError": "حدث خطأ أثناء إنشاء مفتاح API"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"passwordUpdated": "تم تحديث كلمة المرور بنجاح",
|
||||||
|
"emailUpdated": "تم تحديث عنوان البريد الإلكتروني بنجاح",
|
||||||
|
"apiKeyGenerated": "تم إنشاء مفتاح API بنجاح",
|
||||||
|
"apiKeyWarning": "احفظ هذا المفتاح بأمان. لأسباب أمنية، سيتم إخفاؤه خلال 10 ثوانٍ."
|
||||||
|
}
|
||||||
|
};
|
||||||
6
src/i18n/locales/ar/sorting.js
Normal file
6
src/i18n/locales/ar/sorting.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
"name": "الاسم",
|
||||||
|
"searchField": "كلمة البحث",
|
||||||
|
"priceLowHigh": "السعر: من الأقل للأعلى",
|
||||||
|
"priceHighLow": "السعر: من الأعلى للأقل"
|
||||||
|
};
|
||||||
12
src/i18n/locales/ar/tax.js
Normal file
12
src/i18n/locales/ar/tax.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export default {
|
||||||
|
"vat": "ضريبة القيمة المضافة",
|
||||||
|
"vat7": "ضريبة القيمة المضافة 7%",
|
||||||
|
"vat19": "ضريبة القيمة المضافة 19%",
|
||||||
|
"vat19WithShipping": "ضريبة القيمة المضافة 19% (شاملة الشحن)",
|
||||||
|
"totalNet": "إجمالي السعر الصافي",
|
||||||
|
"totalGross": "إجمالي السعر الإجمالي بدون الشحن",
|
||||||
|
"subtotal": "المجموع الفرعي",
|
||||||
|
"incl7Vat": "شاملة ضريبة القيمة المضافة 7%",
|
||||||
|
"inclVatWithFooter": "(شاملة {{vat}}% ضريبة القيمة المضافة،*)",
|
||||||
|
"inclVatAmount": "شاملة {{amount}} € ضريبة القيمة المضافة ({{rate}}%)"
|
||||||
|
};
|
||||||
5
src/i18n/locales/ar/titles.js
Normal file
5
src/i18n/locales/ar/titles.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
"home": "بذور وقصاصات القنب الممتازة",
|
||||||
|
"aktionen": "العروض والتخفيضات الحالية",
|
||||||
|
"filiale": "متجرنا في دريسدن",
|
||||||
|
};
|
||||||
3
src/i18n/locales/ar/translation.js
Normal file
3
src/i18n/locales/ar/translation.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import translations from './index.js';
|
||||||
|
|
||||||
|
export default translations;
|
||||||
25
src/i18n/locales/bg/auth.js
Normal file
25
src/i18n/locales/bg/auth.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export default {
|
||||||
|
"login": "Вход",
|
||||||
|
"register": "Регистрация",
|
||||||
|
"logout": "Изход",
|
||||||
|
"profile": "Профил",
|
||||||
|
"email": "Имейл",
|
||||||
|
"password": "Парола",
|
||||||
|
"confirmPassword": "Потвърдете паролата",
|
||||||
|
"forgotPassword": "Забравена парола?",
|
||||||
|
"loginWithGoogle": "Вход с Google",
|
||||||
|
"or": "ИЛИ",
|
||||||
|
"privacyAccept": "С натискане на \"Вход с Google\" приемам",
|
||||||
|
"privacyPolicy": "Политиката за поверителност",
|
||||||
|
"passwordMinLength": "Паролата трябва да е поне 8 символа",
|
||||||
|
"newPasswordMinLength": "Новата парола трябва да е поне 8 символа",
|
||||||
|
"menu": {
|
||||||
|
"profile": "Профил",
|
||||||
|
"myProfile": "Моят профил",
|
||||||
|
"checkout": "Плащане",
|
||||||
|
"orders": "Поръчки",
|
||||||
|
"settings": "Настройки",
|
||||||
|
"adminDashboard": "Админ табло",
|
||||||
|
"adminUsers": "Админ потребители"
|
||||||
|
}
|
||||||
|
};
|
||||||
39
src/i18n/locales/bg/cart.js
Normal file
39
src/i18n/locales/bg/cart.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export default {
|
||||||
|
"title": "Количка",
|
||||||
|
"empty": "празна",
|
||||||
|
"addToCart": "Добави в количката",
|
||||||
|
"preorderCutting": "Предварителна поръчка като резник",
|
||||||
|
"continueShopping": "Продължи пазаруването",
|
||||||
|
"proceedToCheckout": "Продължи към плащане",
|
||||||
|
"productCount": "{{count}} {{count, plural, one {продукт} other {продукта}}}",
|
||||||
|
"productSingular": "продукт",
|
||||||
|
"productPlural": "продукта",
|
||||||
|
"removeFromCart": "Премахни от количката",
|
||||||
|
"openCart": "Отвори количката",
|
||||||
|
"availableFrom": "Наличен от {{date}}",
|
||||||
|
"backToOrder": "← Обратно към поръчката",
|
||||||
|
"summary": {
|
||||||
|
"title": "Обобщение на поръчката",
|
||||||
|
"goodsNet": "Стоки (нето):",
|
||||||
|
"shippingNet": "Доставка (нето):",
|
||||||
|
"totalGoods": "Общо стоки:",
|
||||||
|
"shippingCosts": "Разходи за доставка:",
|
||||||
|
"total": "Общо:",
|
||||||
|
"totalWeight": "Общо тегло: {{weight}} кг",
|
||||||
|
"freeFrom100": "(безплатно над 100€)",
|
||||||
|
"free": "безплатно"
|
||||||
|
},
|
||||||
|
"itemCount": {
|
||||||
|
"singular": "продукт",
|
||||||
|
"plural": "продукта"
|
||||||
|
},
|
||||||
|
"sync": {
|
||||||
|
"title": "Синхронизация на количката",
|
||||||
|
"description": "Имате запазена количка в профила си. Моля, изберете как искате да продължите:",
|
||||||
|
"deleteServer": "Изтрий количката на сървъра",
|
||||||
|
"useServer": "Използвай количката от сървъра",
|
||||||
|
"merge": "Обедини количките",
|
||||||
|
"currentCart": "Вашата текуща количка",
|
||||||
|
"serverCart": "Количка, запазена във вашия профил"
|
||||||
|
}
|
||||||
|
};
|
||||||
3
src/i18n/locales/bg/chat.js
Normal file
3
src/i18n/locales/bg/chat.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
"privacyRead": "Прочетено и прието",
|
||||||
|
};
|
||||||
34
src/i18n/locales/bg/checkout.js
Normal file
34
src/i18n/locales/bg/checkout.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export default {
|
||||||
|
"invoiceAddress": "Адрес за фактура",
|
||||||
|
"deliveryAddress": "Адрес за доставка",
|
||||||
|
"saveForFuture": "Запази за бъдещи поръчки",
|
||||||
|
"pickupDate": "За коя дата е желано вземането на резниците?",
|
||||||
|
"note": "Бележка",
|
||||||
|
"sameAddress": "Адресът за доставка е същият като адреса за фактура",
|
||||||
|
"termsAccept": "Прочетох Общите условия, Политиката за поверителност и разпоредбите за правото на отказ",
|
||||||
|
"selectDeliveryMethod": "Изберете метод на доставка",
|
||||||
|
"selectPaymentMethod": "Изберете метод на плащане",
|
||||||
|
"orderSummary": "Обобщение на поръчката",
|
||||||
|
"addressValidationError": "Моля, проверете въведените данни в полетата за адрес.",
|
||||||
|
"processingOrder": "Поръчката се обработва...",
|
||||||
|
"completeOrder": "Завърши поръчката",
|
||||||
|
"termsValidationError": "Моля, приемете Общите условия, Политиката за поверителност и правото на отказ, за да продължите.",
|
||||||
|
"addressFields": {
|
||||||
|
"firstName": "Име",
|
||||||
|
"lastName": "Фамилия",
|
||||||
|
"addressSupplement": "Допълнение към адреса",
|
||||||
|
"street": "Улица",
|
||||||
|
"houseNumber": "Номер на къща",
|
||||||
|
"postalCode": "Пощенски код",
|
||||||
|
"city": "Град",
|
||||||
|
"country": "Държава"
|
||||||
|
},
|
||||||
|
"validationErrors": {
|
||||||
|
"firstNameRequired": "Името е задължително",
|
||||||
|
"lastNameRequired": "Фамилията е задължителна",
|
||||||
|
"streetRequired": "Улицата е задължителна",
|
||||||
|
"houseNumberRequired": "Номерът на къщата е задължителен",
|
||||||
|
"postalCodeRequired": "Пощенският код е задължителен",
|
||||||
|
"cityRequired": "Градът е задължителен"
|
||||||
|
}
|
||||||
|
};
|
||||||
19
src/i18n/locales/bg/common.js
Normal file
19
src/i18n/locales/bg/common.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export default {
|
||||||
|
"loading": "Зареждане...",
|
||||||
|
"error": "Грешка",
|
||||||
|
"close": "Затвори",
|
||||||
|
"save": "Запази",
|
||||||
|
"cancel": "Отказ",
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Да",
|
||||||
|
"no": "Не",
|
||||||
|
"next": "Напред",
|
||||||
|
"back": "Назад",
|
||||||
|
"edit": "Редактирай",
|
||||||
|
"delete": "Изтрий",
|
||||||
|
"add": "Добави",
|
||||||
|
"remove": "Премахни",
|
||||||
|
"products": "Продукти",
|
||||||
|
"product": "Продукт",
|
||||||
|
"days": "Дни"
|
||||||
|
};
|
||||||
35
src/i18n/locales/bg/delivery.js
Normal file
35
src/i18n/locales/bg/delivery.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export default {
|
||||||
|
"methods": {
|
||||||
|
"dhl": "DHL",
|
||||||
|
"dpd": "DPD",
|
||||||
|
"sperrgut": "Обемни стоки",
|
||||||
|
"sperrgutName": "Обемни стоки",
|
||||||
|
"pickup": "Вземане от магазина"
|
||||||
|
},
|
||||||
|
"descriptions": {
|
||||||
|
"standard": "Стандартна доставка",
|
||||||
|
"standardFree": "Стандартна доставка - БЕЗПЛАТНО при поръчка над 100€!",
|
||||||
|
"notAvailable": "Не може да се избере, защото един или повече артикули могат да се вземат само на място",
|
||||||
|
"bulky": "За големи и тежки артикули",
|
||||||
|
"pickupOnly": "Само вземане на място"
|
||||||
|
},
|
||||||
|
"prices": {
|
||||||
|
"free": "безплатно",
|
||||||
|
"freeFrom100": "(безплатно от 100€)",
|
||||||
|
"dhl": "6.99 €",
|
||||||
|
"dpd": "4.90 €",
|
||||||
|
"sperrgut": "28.99 €"
|
||||||
|
},
|
||||||
|
"times": {
|
||||||
|
"cutting14Days": "Срок за доставка: 14 дни",
|
||||||
|
"standard2to3Days": "Срок за доставка: 2-3 дни",
|
||||||
|
"supplier7to9Days": "Срок за доставка: 7-9 дни"
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"title": "Изберете метод на доставка",
|
||||||
|
"freeShippingInfo": "💡 Безплатна доставка при поръчка над 100€!",
|
||||||
|
"remainingForFree": "Добавете още {{amount}}€ за безплатна доставка.",
|
||||||
|
"congratsFreeShipping": "🎉 Поздравления! Вие получавате безплатна доставка!",
|
||||||
|
"cartQualifiesFree": "Вашата количка на стойност {{amount}}€ се квалифицира за безплатна доставка."
|
||||||
|
}
|
||||||
|
};
|
||||||
7
src/i18n/locales/bg/filters.js
Normal file
7
src/i18n/locales/bg/filters.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
"sorting": "Сортиране",
|
||||||
|
"perPage": "на страница",
|
||||||
|
"availability": "Наличност",
|
||||||
|
"manufacturer": "Производител",
|
||||||
|
"all": "Всички"
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user