Compare commits

..

1 Commits

Author SHA1 Message Date
seb
ccceb8fe78 Add Mollie payment integration to CartTab and OrderProcessingService
- Introduced Mollie payment method in CartTab, allowing users to select it alongside existing payment options.
- Implemented loading and error handling for the Mollie component.
- Updated OrderProcessingService to create Mollie payment intents.
- Adjusted PaymentMethodSelector to switch to Mollie when specific delivery methods are selected.
- Enhanced CartTab to store cart items for Mollie payments and calculate total amounts accordingly.
2025-07-06 02:21:52 +02:00
883 changed files with 4696 additions and 27949 deletions

3
.gitignore vendored
View File

@@ -56,9 +56,6 @@ yarn-error.log*
# Local configuration
src/config.local.js
taxonomy-with-ids.de-DE*
# Local development notes
dev-notes.md
dev-notes.local.md
logs/

66
.vscode/launch.json vendored
View File

@@ -3,76 +3,20 @@
// This will install dependencies before starting the dev server
"version": "0.2.0",
"configurations": [
{
"type": "node-terminal",
"name": "Start with API propxy to seedheads.de (Install Deps)",
"request": "launch",
"command": "npm run start:seedheads",
"preLaunchTask": "npm: install",
"env": {
"NODE_ENV": "development"
},
"envFile": "${workspaceFolder}/.env",
"skipFiles": [
"<node_internals>/**"
]
},
{
"name": "Start",
"cwd": "${workspaceFolder}"
}, {
"type": "node-terminal",
"name": "Start",
"request": "launch",
"command": "npm run start",
"env": {
"NODE_ENV": "development"
},
"envFile": "${workspaceFolder}/.env",
"skipFiles": [
"<node_internals>/**"
]
},
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome - Debug React App",
"url": "https://dev.seedheads.de",
"webRoot": "${workspaceFolder}/src",
"sourceMapPathOverrides": {
"webpack://reactshop/./src/*": "${webRoot}/*",
"webpack://reactshop/src/*": "${webRoot}/*",
"webpack:///src/*": "${webRoot}/*",
"webpack:///./src/*": "${webRoot}/*",
"webpack:///./*": "${workspaceFolder}/*",
"webpack:///./~/*": "${workspaceFolder}/node_modules/*",
"webpack://*": "${workspaceFolder}/*"
},
"smartStep": true,
"skipFiles": [
"<node_internals>/**",
"${workspaceFolder}/node_modules/**",
"${workspaceFolder}/dist/**"
]
},
{
"type": "chrome",
"request": "attach",
"name": "Attach to Chrome - Debug React App",
"port": 9222,
"webRoot": "${workspaceFolder}/src",
"sourceMapPathOverrides": {
"webpack://reactshop/./src/*": "${webRoot}/*",
"webpack://reactshop/src/*": "${webRoot}/*",
"webpack:///src/*": "${webRoot}/*",
"webpack:///./src/*": "${webRoot}/*",
"webpack:///./*": "${workspaceFolder}/*",
"webpack:///./~/*": "${workspaceFolder}/node_modules/*",
"webpack://*": "${workspaceFolder}/*"
},
"smartStep": true,
"skipFiles": [
"<node_internals>/**",
"${workspaceFolder}/node_modules/**",
"${workspaceFolder}/dist/**"
]
"cwd": "${workspaceFolder}"
}
]
}

View File

@@ -1,209 +0,0 @@
# 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!

View File

@@ -11,43 +11,3 @@ Entpacken & Doppelklick auf `start-dev-seedheads.bat` - das Skript wird:
- Abhängigkeiten automatisch installieren falls nötig
- Entwicklungsserver mit API-Proxy zu seedheads.de starten
- Browser öffnen auf http://localhost:9500
## Socket Connection Optimization
The application uses Socket.IO for real-time communication with the server. To improve initial loading performance, sockets are now connected lazily:
- Sockets are created with `autoConnect: false` and only establish a connection when:
- The first `emit` is called on the socket
- An explicit connection is requested via the context methods
### Usage
```jsx
// In a component
import React, { useContext, useEffect } from 'react';
import SocketContext from '../contexts/SocketContext';
import { emitAsync } from '../utils/socketUtils';
const MyComponent = () => {
const { socket, socketB } = useContext(SocketContext);
useEffect(() => {
// The socket will automatically connect when emit is called
socket.emit('someEvent', { data: 'example' });
// Or use the utility for Promise-based responses
emitAsync(socket, 'getData', { id: 123 })
.then(response => console.log(response))
.catch(error => console.error(error));
}, [socket]);
return <div>My Component</div>;
};
```
### Benefits
- Reduced initial page load time
- Connections established only when needed
- Automatic fallback to polling if WebSocket fails
- Promise-based utilities for easier async/await usage

1818
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,14 +13,7 @@
"lint": "eslint src/**/*.{js,jsx}",
"prerender": "node prerender.cjs",
"prerender:prod": "cross-env NODE_ENV=production node prerender.cjs",
"prerender:product": "node prerender-single-product.cjs",
"prerender:product:prod": "cross-env NODE_ENV=production node prerender-single-product.cjs",
"build:prerender": "npm run build:client && npm run prerender:prod",
"translate": "node translate-i18n.js",
"translate:english": "node translate-i18n.js --only-english",
"translate:skip-english": "node translate-i18n.js --skip-english",
"translate:others": "node translate-i18n.js --skip-english",
"validate:products": "node scripts/validate-products-xml.cjs"
"build:prerender": "npm run build:client && npm run prerender:prod"
},
"keywords": [],
"author": "",
@@ -33,19 +26,12 @@
"@mui/material": "^7.1.1",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.3.1",
"async-mutex": "^0.5.0",
"chart.js": "^4.5.0",
"country-flag-icons": "^1.5.19",
"html-react-parser": "^5.2.5",
"i18next": "^25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"openai": "^4.0.0",
"react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.6.0",
"react-router-dom": "^7.6.2",
"sanitize-html": "^2.17.0",
"sharp": "^0.34.2",
"socket.io-client": "^4.7.5"
},
@@ -78,8 +64,6 @@
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.2",
"webpack-node-externals": "^3.0.0",
"xmldom": "^0.6.0",
"xpath": "^0.0.34"
"webpack-node-externals": "^3.0.0"
}
}

View File

@@ -1,208 +0,0 @@
require("@babel/register")({
presets: [
["@babel/preset-env", { targets: { node: "current" } }],
"@babel/preset-react",
],
extensions: [".js", ".jsx"],
ignore: [/node_modules/],
});
// Minimal globals for socket.io-client only - no JSDOM to avoid interference
global.window = {}; // Minimal window object for productCache
global.navigator = { userAgent: "node.js" };
global.URL = require("url").URL;
global.Blob = class MockBlob {
constructor(data, options) {
this.data = data;
this.type = options?.type || "";
}
};
// Import modules
const fs = require("fs");
const path = require("path");
const React = require("react");
const io = require("socket.io-client");
// Initialize i18n for prerendering with German as default
const i18n = require("i18next");
const { initReactI18next } = require("react-i18next");
// Import translation (just German for testing)
const translationDE = require("./src/i18n/locales/de/translation.js").default;
// Initialize i18n
i18n
.use(initReactI18next)
.init({
resources: {
de: { translation: translationDE }
},
lng: 'de',
fallbackLng: 'de',
debug: false,
interpolation: {
escapeValue: false
},
react: {
useSuspense: false
}
});
// Make i18n available globally
global.i18n = i18n;
// Import prerender modules
const config = require("./prerender/config.cjs");
const shopConfig = require("./src/config.js").default;
const { renderPage } = require("./prerender/renderer.cjs");
const { generateProductMetaTags, generateProductJsonLd } = require("./prerender/seo.cjs");
const { fetchProductDetails, saveProductImages } = require("./prerender/data-fetching.cjs");
// Import product component
const PrerenderProduct = require("./src/PrerenderProduct.js").default;
const renderSingleProduct = async (productSeoName) => {
const socketUrl = "http://127.0.0.1:9303";
console.log(`🔌 Connecting to socket at ${socketUrl}...`);
const socket = io(socketUrl, {
path: "/socket.io/",
transports: ["websocket"],
reconnection: false,
timeout: 10000,
});
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
console.error("❌ Timeout: Could not connect to backend after 15 seconds");
socket.disconnect();
reject(new Error("Connection timeout"));
}, 15000);
socket.on("connect", async () => {
console.log(`✅ Socket connected. Fetching product: ${productSeoName}`);
try {
// Fetch product details
const productDetails = await fetchProductDetails(socket, productSeoName);
console.log(`📦 Product found: ${productDetails.product.name}`);
// Save product image to static files
if (productDetails.product) {
console.log(`📷 Saving product image...`);
await saveProductImages(socket, [productDetails.product], "Single Product", config.outputDir);
}
// Set up minimal global cache (empty for single product test)
global.window.productCache = {};
global.productCache = {};
// Create product component
const productComponent = React.createElement(PrerenderProduct, {
productData: productDetails,
t: global.i18n.t.bind(global.i18n),
});
// Generate metadata
const actualSeoName = productDetails.product.seoName || productSeoName;
const filename = `Artikel/${actualSeoName}`;
const location = `/Artikel/${actualSeoName}`;
const description = `Product "${productDetails.product.name}" (seoName: ${productSeoName})`;
const metaTags = generateProductMetaTags({
...productDetails.product,
seoName: actualSeoName,
}, shopConfig.baseUrl, shopConfig);
const jsonLdScript = generateProductJsonLd({
...productDetails.product,
seoName: actualSeoName,
}, shopConfig.baseUrl, shopConfig);
const combinedMetaTags = metaTags + "\n" + jsonLdScript;
// Render the page
console.log(`🎨 Rendering product page...`);
const success = renderPage(
productComponent,
location,
filename,
description,
combinedMetaTags,
true, // needsRouter
config,
false, // suppressLogs
productDetails // productData for cache
);
if (success) {
const outputPath = path.resolve(__dirname, config.outputDir, `${filename}.html`);
console.log(`✅ Product page rendered successfully!`);
console.log(`📄 Output file: ${outputPath}`);
console.log(`🌐 Test URL: http://localhost:3000/Artikel/${actualSeoName}`);
// Show file size
if (fs.existsSync(outputPath)) {
const stats = fs.statSync(outputPath);
console.log(`📊 File size: ${Math.round(stats.size / 1024)}KB`);
}
} else {
console.log(`❌ Failed to render product page`);
}
clearTimeout(timeout);
socket.disconnect();
resolve(success);
} catch (error) {
console.error(`❌ Error fetching/rendering product: ${error.message}`);
clearTimeout(timeout);
socket.disconnect();
reject(error);
}
});
socket.on("connect_error", (err) => {
clearTimeout(timeout);
console.error("❌ Socket connection error:", err);
console.log("💡 Make sure the backend server is running on http://127.0.0.1:9303");
reject(err);
});
socket.on("error", (err) => {
clearTimeout(timeout);
console.error("❌ Socket error:", err);
reject(err);
});
});
};
// Get product seoName from command line arguments
const productSeoName = process.argv[2];
if (!productSeoName) {
console.log("❌ Usage: node prerender-single-product.cjs <product-seo-name>");
console.log("📝 Example: node prerender-single-product.cjs led-grow-light-600w");
process.exit(1);
}
console.log(`🚀 Starting single product prerender test...`);
console.log(`🎯 Product SEO name: ${productSeoName}`);
console.log(`🔧 Mode: ${config.isProduction ? 'PRODUCTION' : 'DEVELOPMENT'}`);
console.log(`📁 Output directory: ${config.outputDir}`);
renderSingleProduct(productSeoName)
.then((success) => {
if (success) {
console.log(`\n🎉 Single product prerender completed successfully!`);
process.exit(0);
} else {
console.log(`\n💥 Single product prerender failed!`);
process.exit(1);
}
})
.catch((error) => {
console.error(`\n💥 Single product prerender failed:`, error.message);
process.exit(1);
});

View File

@@ -19,24 +19,6 @@ global.Blob = class MockBlob {
}
};
class CategoryService {
constructor() {
this.get = this.get.bind(this);
}
getSync(categoryId, language = "de") {
const cacheKey = `${categoryId}_${language}`;
return null;
}
async get(categoryId, language = "de") {
const cacheKey = `${categoryId}_${language}`;
return null;
}
}
global.window.categoryService = new CategoryService();
// Import modules
const fs = require("fs");
const path = require("path");
@@ -45,74 +27,6 @@ const io = require("socket.io-client");
const os = require("os");
const { Worker, isMainThread, parentPort, workerData } = require("worker_threads");
// Initialize i18n for prerendering with German as default
const i18n = require("i18next");
const { initReactI18next } = require("react-i18next");
// Import all translation files
const translationDE = require("./src/i18n/locales/de/translation.js").default;
const translationEN = require("./src/i18n/locales/en/translation.js").default;
const translationAR = require("./src/i18n/locales/ar/translation.js").default;
const translationBG = require("./src/i18n/locales/bg/translation.js").default;
const translationCS = require("./src/i18n/locales/cs/translation.js").default;
const translationEL = require("./src/i18n/locales/el/translation.js").default;
const translationES = require("./src/i18n/locales/es/translation.js").default;
const translationFR = require("./src/i18n/locales/fr/translation.js").default;
const translationHR = require("./src/i18n/locales/hr/translation.js").default;
const translationHU = require("./src/i18n/locales/hu/translation.js").default;
const translationIT = require("./src/i18n/locales/it/translation.js").default;
const translationPL = require("./src/i18n/locales/pl/translation.js").default;
const translationRO = require("./src/i18n/locales/ro/translation.js").default;
const translationRU = require("./src/i18n/locales/ru/translation.js").default;
const translationSK = require("./src/i18n/locales/sk/translation.js").default;
const translationSL = require("./src/i18n/locales/sl/translation.js").default;
const translationSR = require("./src/i18n/locales/sr/translation.js").default;
const translationSV = require("./src/i18n/locales/sv/translation.js").default;
const translationTR = require("./src/i18n/locales/tr/translation.js").default;
const translationUK = require("./src/i18n/locales/uk/translation.js").default;
const translationZH = require("./src/i18n/locales/zh/translation.js").default;
// Initialize i18n for prerendering
i18n
.use(initReactI18next)
.init({
resources: {
de: { translation: translationDE },
en: { translation: translationEN },
ar: { translation: translationAR },
bg: { translation: translationBG },
cs: { translation: translationCS },
el: { translation: translationEL },
es: { translation: translationES },
fr: { translation: translationFR },
hr: { translation: translationHR },
hu: { translation: translationHU },
it: { translation: translationIT },
pl: { translation: translationPL },
ro: { translation: translationRO },
ru: { translation: translationRU },
sk: { translation: translationSK },
sl: { translation: translationSL },
sr: { translation: translationSR },
sv: { translation: translationSV },
tr: { translation: translationTR },
uk: { translation: translationUK },
zh: { translation: translationZH }
},
lng: 'de', // Default to German for prerendering
fallbackLng: 'de',
debug: false,
interpolation: {
escapeValue: false
},
react: {
useSuspense: false
}
});
// Make i18n available globally for components
global.i18n = i18n;
// Import split modules
const config = require("./prerender/config.cjs");
@@ -121,6 +35,7 @@ const shopConfig = require("./src/config.js").default;
const { renderPage } = require("./prerender/renderer.cjs");
const {
collectAllCategories,
writeCombinedCssFile,
} = require("./prerender/utils.cjs");
const {
generateProductMetaTags,
@@ -166,7 +81,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
const socketUrl = "http://127.0.0.1:9303";
const workerSocket = io(socketUrl, {
path: "/socket.io/",
transports: ["websocket"],
transports: ["polling", "websocket"],
reconnection: false,
timeout: 10000,
});
@@ -192,7 +107,6 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
const actualSeoName = productDetails.product.seoName || productSeoName;
const productComponent = React.createElement(PrerenderProduct, {
productData: productDetails,
t: global.i18n.t.bind(global.i18n),
});
const filename = `Artikel/${actualSeoName}`;
@@ -219,8 +133,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
combinedMetaTags,
true,
config,
true, // Suppress logs during parallel rendering to avoid interfering with progress bar
productDetails // Pass product data for cache population
true // Suppress logs during parallel rendering to avoid interfering with progress bar
);
if (success) {
@@ -368,14 +281,14 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
const renderApp = async (categoryData, socket) => {
if (categoryData) {
global.window.categoryCache = {
"209_de": categoryData,
global.window.productCache = {
categoryTree_209: { categoryTree: categoryData, timestamp: Date.now() },
};
// @note Make cache available to components during rendering
global.categoryCache = global.window.categoryCache;
global.productCache = global.window.productCache;
} else {
global.window.categoryCache = {};
global.categoryCache = {};
global.window.productCache = {};
global.productCache = {};
}
// Helper to call renderPage with config
@@ -525,14 +438,7 @@ const renderApp = async (categoryData, socket) => {
let categoryPagesRendered = 0;
let categoriesWithProducts = 0;
const allCategoriesPlusNeu = [...allCategories, {
id: "neu",
name: "Neuheiten",
seoName: "neu",
parentId: 209
}];
for (const category of allCategoriesPlusNeu) {
for (const category of allCategories) {
// Skip categories without seoName
if (!category.seoName) {
console.log(
@@ -666,7 +572,8 @@ const renderApp = async (categoryData, socket) => {
);
}
// No longer writing combined CSS file - each page has its own embedded CSS
// Write the combined CSS file after all pages are rendered
writeCombinedCssFile(config.globalCssCollection, config.outputDir);
// Generate XML sitemap with all rendered pages
console.log("\n🗺 Generating XML sitemap...");
@@ -723,26 +630,6 @@ const renderApp = async (categoryData, socket) => {
console.log(` - File verification: ⚠️ ${verifyError.message}`);
}
// Validate XML against Google Shopping schema
try {
const ProductsXmlValidator = require('./scripts/validate-products-xml.cjs');
const validator = new ProductsXmlValidator(productsXmlPath);
const validationResults = await validator.validate();
if (validationResults.valid) {
console.log(` - Schema validation: ✅ Valid Google Shopping RSS 2.0`);
} else {
console.log(` - Schema validation: ⚠️ ${validationResults.summary.errorCount} errors, ${validationResults.summary.warningCount} warnings`);
// Show first few errors for quick debugging
if (validationResults.errors.length > 0) {
console.log(` - First error: ${validationResults.errors[0].message}`);
}
}
} catch (validationError) {
console.log(` - Schema validation: ⚠️ Validation failed: ${validationError.message}`);
}
} catch (error) {
console.error(`❌ Error generating products.xml: ${error.message}`);
console.log("\n⚠ Skipping products.xml generation due to errors");
@@ -834,7 +721,7 @@ const fetchCategoryDataAndRender = () => {
const socket = io(socketUrl, {
path: "/socket.io/",
transports: [ "websocket"],
transports: ["polling", "websocket"], // Using polling first is more robust
reconnection: false,
timeout: 10000,
});

View File

@@ -50,18 +50,10 @@ const getWebpackEntrypoints = () => {
return entrypoints;
};
// Read global CSS styles - use webpack processed CSS in production, raw CSS in development
let globalCss = '';
if (isProduction) {
// In production, webpack has already processed fonts and inlined CSS
// Don't read raw src/index.css as it has unprocessed font paths
globalCss = ''; // CSS will be handled by webpack's inlined CSS
} else {
// In development, read raw CSS and fix font paths for prerender
globalCss = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'index.css'), 'utf8');
// Fix relative font paths for prerendered HTML (remove ../public to make them relative to public root)
globalCss = globalCss.replace(/url\('\.\.\/public/g, "url('");
}
// Read global CSS styles and fix font paths for prerender
let globalCss = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'index.css'), 'utf8');
// Fix relative font paths for prerendered HTML (remove ../public to make them relative to public root)
globalCss = globalCss.replace(/url\('\.\.\/public/g, "url('");
// Global CSS collection
const globalCssCollection = new Set();

View File

@@ -37,15 +37,9 @@ const fetchCategoryProducts = (socket, categoryId) => {
reject(new Error(`Timeout fetching products for category ${categoryId}`));
}, 5000);
// Prerender system fetches German version by default
socket.emit(
"getCategoryProducts",
{
full: true,
categoryId: categoryId === "neu" ? "neu" : parseInt(categoryId),
language: 'de',
requestTranslation: false
},
{ full:true, categoryId: parseInt(categoryId) },
(response) => {
clearTimeout(timeout);
if (response && response.products !== undefined) {
@@ -74,13 +68,7 @@ const fetchProductDetails = (socket, productSeoName) => {
);
}, 5000);
// Prerender system fetches German version by default
socket.emit("getProductView", {
seoName: productSeoName,
nocount: true,
language: 'de',
requestTranslation: false
}, (response) => {
socket.emit("getProductView", { seoName: productSeoName, nocount: true }, (response) => {
clearTimeout(timeout);
if (response && response.product) {
response.product.seoName = productSeoName;
@@ -199,7 +187,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
const imageBuffer = await fetchProductImage(socket, bildId);
// If overlay exists, apply it to the image
if (false && fs.existsSync(overlayPath)) {
if (fs.existsSync(overlayPath)) {
try {
// Get image dimensions to center the overlay
const baseImage = sharp(Buffer.from(imageBuffer));

View File

@@ -17,8 +17,7 @@ const renderPage = (
metaTags = "",
needsRouter = false,
config,
suppressLogs = false,
productData = null
suppressLogs = false
) => {
const {
isProduction,
@@ -27,7 +26,7 @@ const renderPage = (
globalCssCollection,
webpackEntrypoints,
} = config;
const { optimizeCss } = require("./utils.cjs");
const { writeCombinedCssFile, optimizeCss } = require("./utils.cjs");
// @note Set prerender fallback flag in global environment for CategoryBox during SSR
if (typeof global !== "undefined" && global.window) {
@@ -52,20 +51,26 @@ const renderPage = (
);
let renderedMarkup;
let pageSpecificCss = ""; // Declare outside try block for broader scope
try {
renderedMarkup = ReactDOMServer.renderToString(pageElement);
const emotionChunks = extractCriticalToChunks(renderedMarkup);
// Collect CSS from this page for direct inlining (no global accumulation)
// Collect CSS from this page
if (emotionChunks.styles.length > 0) {
const oldSize = globalCssCollection.size;
emotionChunks.styles.forEach((style) => {
if (style.css) {
pageSpecificCss += style.css + "\n";
globalCssCollection.add(style.css);
}
});
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) {
console.error(`❌ Rendering failed for ${filename}:`, error);
@@ -121,12 +126,26 @@ const renderPage = (
}
});
// Inline page-specific CSS directly (no shared prerender.css file)
if (pageSpecificCss.trim()) {
// Use advanced CSS optimization on page-specific CSS
const optimizedPageCss = optimizeCss(pageSpecificCss);
inlinedCss += optimizedPageCss;
if (!suppressLogs) console.log(` ✅ Inlined page-specific CSS (${Math.round(optimizedPageCss.length / 1024)}KB)`);
// Read and inline prerender CSS to eliminate render-blocking request
try {
const prerenderCssPath = path.resolve(__dirname, "..", outputDir, "prerender.css");
if (fs.existsSync(prerenderCssPath)) {
const prerenderCssContent = fs.readFileSync(prerenderCssPath, "utf8");
// Use advanced CSS optimization
const optimizedPrerenderCss = optimizeCss(prerenderCssContent);
inlinedCss += optimizedPrerenderCss;
if (!suppressLogs) console.log(` ✅ Inlined prerender CSS (${Math.round(optimizedPrerenderCss.length / 1024)}KB)`);
} else {
// Fallback to external loading if prerender.css doesn't exist yet
additionalTags += `<link rel="preload" href="/prerender.css" as="style" onload="this.onload=null;this.rel='stylesheet'">`;
additionalTags += `<noscript><link rel="stylesheet" href="/prerender.css"></noscript>`;
if (!suppressLogs) console.log(` ⚠️ prerender.css not found for inlining, using async loading`);
}
} catch (error) {
// Fallback to external loading
additionalTags += `<link rel="preload" href="/prerender.css" as="style" onload="this.onload=null;this.rel='stylesheet'">`;
additionalTags += `<noscript><link rel="stylesheet" href="/prerender.css"></noscript>`;
if (!suppressLogs) console.log(` ⚠️ Error reading prerender.css: ${error.message}, using async loading`);
}
// Add JavaScript files
@@ -163,47 +182,23 @@ const renderPage = (
content: ${JSON.stringify(renderedMarkup)},
timestamp: ${Date.now()}
};
// DEBUG: Multiple alerts throughout the loading process
// Debug alerts removed
</script>
`;
// @note Create script to populate window.productCache with ONLY the static category tree
let productCacheScript = '';
if (typeof global !== "undefined" && global.window && global.window.categoryCache) {
if (typeof global !== "undefined" && global.window && global.window.productCache) {
// Only include the static categoryTree_209, not any dynamic data that gets added during rendering
const staticCache = {};
if (global.window.categoryCache["209_de"]) {
staticCache["209_de"] = global.window.categoryCache["209_de"];
if (global.window.productCache.categoryTree_209) {
staticCache.categoryTree_209 = global.window.productCache.categoryTree_209;
}
const staticCacheData = JSON.stringify(staticCache);
productCacheScript = `
<script>
// Populate window.categoryCache with static category tree only
window.categoryCache = ${staticCacheData};
</script>
`;
}
// Create script to populate window.productDetailCache for individual product pages
let productDetailCacheScript = '';
if (productData && productData.product) {
// Cache the entire response object (includes product, attributes, etc.)
// Use language-aware cache key (prerender defaults to German)
const productDetailCacheData = JSON.stringify(productData);
const language = 'de'; // Prerender system caches German version
const cacheKey = `product_${productData.product.seoName}_${language}`;
productDetailCacheScript = `
<script>
// Populate window.productDetailCache with complete product data for SPA hydration
if (!window.productDetailCache) {
window.productDetailCache = {};
}
window.productDetailCache['${cacheKey}'] = ${productDetailCacheData};
// Populate window.productCache with static category tree only
window.productCache = ${staticCacheData};
</script>
`;
}
@@ -219,7 +214,7 @@ const renderPage = (
template = template.replace(
"</head>",
`${resourceHints}${combinedCssTag}${additionalTags}${metaTags}${prerenderFallbackScript}${productCacheScript}${productDetailCacheScript}</head>`
`${resourceHints}${combinedCssTag}${additionalTags}${metaTags}${prerenderFallbackScript}${productCacheScript}</head>`
);
const rootDivRegex = /<div id="root"[\s\S]*?>[\s\S]*?<\/div>/;
@@ -227,10 +222,8 @@ const renderPage = (
let newHtml;
if (rootDivRegex.test(template)) {
if (!suppressLogs) console.log(` 📝 Root div found, replacing with ${renderedMarkup.length} chars of markup`);
newHtml = template.replace(rootDivRegex, replacementHtml);
} else {
if (!suppressLogs) console.log(` ⚠️ No root div found, appending to body`);
newHtml = template.replace("<body>", `<body>${replacementHtml}`);
}
@@ -251,9 +244,6 @@ const renderPage = (
console.log(` - Total inlined CSS: ${Math.round(combinedCss.length / 1024)}KB`);
console.log(` - Render-blocking CSS eliminated: ${inlinedCss ? 'YES' : 'NO'}`);
console.log(` - Fallback content saved to window.__PRERENDER_FALLBACK__`);
if (productDetailCacheScript) {
console.log(` - Product detail cache populated for SPA hydration`);
}
}
return true;

View File

@@ -11,102 +11,6 @@ Crawl-delay: 0
return robotsTxt;
};
// Helper function to determine unit pricing data based on product data
const determineUnitPricingData = (product) => {
const result = {
unit_pricing_measure: null,
unit_pricing_base_measure: null
};
// Unit mapping from German to Google Shopping accepted units
const unitMapping = {
// Volume (German -> Google)
'Milliliter': 'ml',
'milliliter': 'ml',
'ml': 'ml',
'Liter': 'l',
'liter': 'l',
'l': 'l',
'Zentiliter': 'cl',
'zentiliter': 'cl',
'cl': 'cl',
// Weight (German -> Google)
'Gramm': 'g',
'gramm': 'g',
'g': 'g',
'Kilogramm': 'kg',
'kilogramm': 'kg',
'kg': 'kg',
'Milligramm': 'mg',
'milligramm': 'mg',
'mg': 'mg',
// Length (German -> Google)
'Meter': 'm',
'meter': 'm',
'm': 'm',
'Zentimeter': 'cm',
'zentimeter': 'cm',
'cm': 'cm',
// Count (German -> Google)
'Stück': 'ct',
'stück': 'ct',
'Stk': 'ct',
'stk': 'ct',
'ct': 'ct',
'Blatt': 'sheet',
'blatt': 'sheet',
'sheet': 'sheet'
};
// Helper function to convert German unit to Google Shopping unit
const convertUnit = (unit) => {
if (!unit) return null;
const trimmedUnit = unit.trim();
return unitMapping[trimmedUnit] || trimmedUnit.toLowerCase();
};
// unit_pricing_measure: The quantity unit of the product as it's sold
if (product.fEinheitMenge && product.cEinheit) {
const amount = parseFloat(product.fEinheitMenge);
const unit = convertUnit(product.cEinheit);
if (amount > 0 && unit) {
result.unit_pricing_measure = `${amount} ${unit}`;
}
}
// unit_pricing_base_measure: The base quantity unit for unit pricing
if (product.cGrundEinheit && product.cGrundEinheit.trim()) {
const baseUnit = convertUnit(product.cGrundEinheit);
if (baseUnit) {
// Base measure usually needs a quantity (like 100g, 1l, etc.)
// If it's just a unit, we'll add a default quantity
if (baseUnit.match(/^[a-z]+$/)) {
// For weight/volume units, use standard base quantities
if (['g', 'kg', 'mg'].includes(baseUnit)) {
result.unit_pricing_base_measure = baseUnit === 'kg' ? '1 kg' : '100 g';
} else if (['ml', 'l', 'cl'].includes(baseUnit)) {
result.unit_pricing_base_measure = baseUnit === 'l' ? '1 l' : '100 ml';
} else if (['m', 'cm'].includes(baseUnit)) {
result.unit_pricing_base_measure = baseUnit === 'm' ? '1 m' : '100 cm';
} else {
result.unit_pricing_base_measure = `1 ${baseUnit}`;
}
} else {
result.unit_pricing_base_measure = baseUnit;
}
}
}
return result;
};
const fs = require('fs');
const path = require('path');
const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const currentDate = new Date().toISOString();
@@ -119,130 +23,124 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const getGoogleProductCategory = (categoryId) => {
const categoryMappings = {
// Seeds & Plants
689: "543561", // Seeds (Saatgut)
706: "543561", // Stecklinge (cuttings) ebenfalls Pflanzen/Saatgut
376: "2802", // Grow-Sets Pflanzen- & Kräuteranbausets
689: "Home & Garden > Plants > Seeds",
706: "Home & Garden > Plants", // Stecklinge (cuttings)
376: "Home & Garden > Plants > Plant & Herb Growing Kits", // Grow-Sets
// Headshop & Accessories
709: "4082", // Headshop Rauchzubehör
711: "4082", // Headshop > Bongs Rauchzubehör
714: "4082", // Headshop > Bongs > Zubehör Rauchzubehör
748: "4082", // Headshop > Bongs > Köpfe Rauchzubehör
749: "4082", // Headshop > Bongs > Chillums/Diffusoren/Kupplungen Rauchzubehör
896: "3151", // Headshop > Vaporizer Vaporizer
710: "5109", // Headshop > Grinder Gewürzmühlen (Küchenhelfer)
709: "Arts & Entertainment > Hobbies & Creative Arts", // Headshop
711: "Arts & Entertainment > Hobbies & Creative Arts", // Bongs
714: "Arts & Entertainment > Hobbies & Creative Arts", // Zubehör
748: "Arts & Entertainment > Hobbies & Creative Arts", // Köpfe
749: "Arts & Entertainment > Hobbies & Creative Arts", // Chillums / Diffusoren / Kupplungen
896: "Electronics > Electronics Accessories", // Vaporizer
710: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Grinder
// Measuring & Packaging
186: "5631", // Headshop > Wiegen & Verpacken Aufbewahrung/Zubehör
187: "4767", // Headshop > Waagen Personenwaagen (Medizinisch)
346: "7118", // Headshop > Vakuumbeutel Vakuumierer-Beutel
355: "606", // Headshop > Boveda & Integra Boost Luftentfeuchter (nächstmögliche)
407: "3561", // Headshop > Grove Bags Aufbewahrungsbehälter
449: "1496", // Headshop > Cliptütchen Lebensmittelverpackungsmaterial
539: "3110", // Headshop > Gläser & Dosen Lebensmittelbehälter
186: "Business & Industrial", // Wiegen & Verpacken
187: "Business & Industrial > Science & Laboratory > Lab Equipment", // Waagen
346: "Home & Garden > Kitchen & Dining > Food Storage", // Vakuumbeutel
355: "Home & Garden > Kitchen & Dining > Food Storage", // Boveda & Integra Boost
407: "Home & Garden > Kitchen & Dining > Food Storage", // Grove Bags
449: "Home & Garden > Kitchen & Dining > Food Storage", // Cliptütchen
539: "Home & Garden > Kitchen & Dining > Food Storage", // Gläser & Dosen
// Lighting & Equipment
694: "3006", // Lampen Lampen (Beleuchtung)
261: "3006", // Zubehör > Lampenzubehör Lampen
694: "Home & Garden > Lighting", // Lampen
261: "Home & Garden > Lighting", // Lampenzubehör
// Plants & Growing
691: "500033", // Dünger Dünger
692: "5633", // Zubehör > Dünger-Zubehör Zubehör für Gartenarbeit
693: "5655", // Zelte Zelte
691: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger
692: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger - Zubehör
693: "Sporting Goods > Outdoor Recreation > Camping & Hiking > Tents", // Zelte
// Pots & Containers
219: "113", // Töpfe Blumentöpfe & Pflanzgefäße
220: "3173", // Töpfe > Untersetzer Gartentopfuntersetzer und Trays
301: "113", // Töpfe > Stofftöpfe (Blumentöpfe/Pflanzgefäße)
317: "113", // Töpfe > Air-Pot (Blumentöpfe/Pflanzgefäße)
364: "113", // Töpfe > Kunststofftöpfe (Blumentöpfe/Pflanzgefäße)
292: "3568", // Bewässerung > Trays & Fluttische Bewässerungssysteme
219: "Home & Garden > Decor > Planters & Pots", // Töpfe
220: "Home & Garden > Decor > Planters & Pots", // Untersetzer
301: "Home & Garden > Decor > Planters & Pots", // Stofftöpfe
317: "Home & Garden > Decor > Planters & Pots", // Air-Pot
364: "Home & Garden > Decor > Planters & Pots", // Kunststofftöpfe
292: "Home & Garden > Decor > Planters & Pots", // Trays & Fluttische
// Ventilation & Climate
703: "2802", // Grow-Sets > Abluft-Sets (verwendet Pflanzen-Kräuter-Anbausets)
247: "1700", // Belüftung Ventilatoren (Klimatisierung)
214: "1700", // Belüftung > Umluft-Ventilatoren Ventilatoren
308: "1700", // Belüftung > Ab- und Zuluft Ventilatoren
609: "1700", // Belüftung > Ab- und Zuluft > Schalldämpfer Ventilatoren
248: "1700", // Belüftung > Aktivkohlefilter Ventilatoren (nächstmögliche)
392: "1700", // Belüftung > Ab- und Zuluft > Zuluftfilter Ventilatoren
658: "606", // Belüftung > Luftbe- und -entfeuchter Luftentfeuchter
310: "2802", // Anzucht > Heizmatten Pflanzen- & Kräuteranbausets
379: "5631", // Belüftung > Geruchsneutralisation Haushaltsbedarf: Aufbewahrung
703: "Home & Garden > Outdoor Power Tools", // Abluft-Sets
247: "Home & Garden > Outdoor Power Tools", // Belüftung
214: "Home & Garden > Outdoor Power Tools", // Umluft-Ventilatoren
308: "Home & Garden > Outdoor Power Tools", // Ab- und Zuluft
609: "Home & Garden > Outdoor Power Tools", // Schalldämpfer
248: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Aktivkohlefilter
392: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Zuluftfilter
658: "Home & Garden > Climate Control > Dehumidifiers", // Luftbe- und entfeuchter
310: "Home & Garden > Climate Control > Heating", // Heizmatten
379: "Home & Garden > Household Supplies > Air Fresheners", // Geruchsneutralisation
// Irrigation & Watering
221: "3568", // Bewässerung Bewässerungssysteme (Gesamt)
250: "6318", // Bewässerung > Schläuche Gartenschläuche
297: "500100", // Bewässerung > Pumpen Bewässerung-/Sprinklerpumpen
354: "3780", // Bewässerung > Sprüher Sprinkler & Sprühköpfe
372: "3568", // Bewässerung > AutoPot Bewässerungssysteme
389: "3568", // Bewässerung > Blumat Bewässerungssysteme
405: "6318", // Bewässerung > Schläuche Gartenschläuche
425: "3568", // Bewässerung > Wassertanks Bewässerungssysteme
480: "3568", // Bewässerung > Tropfer Bewässerungssysteme
519: "3568", // Bewässerung > Pumpsprüher Bewässerungssysteme
221: "Home & Garden > Lawn & Garden > Watering Equipment", // Bewässerung
250: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
297: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpen
354: "Home & Garden > Lawn & Garden > Watering Equipment", // Sprüher
372: "Home & Garden > Lawn & Garden > Watering Equipment", // AutoPot
389: "Home & Garden > Lawn & Garden > Watering Equipment", // Blumat
405: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
425: "Home & Garden > Lawn & Garden > Watering Equipment", // Wassertanks
480: "Home & Garden > Lawn & Garden > Watering Equipment", // Tropfer
519: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpsprüher
// Growing Media & Soils
242: "543677", // Böden Gartenerde
243: "543677", // Böden > Erde Gartenerde
269: "543677", // Böden > Kokos Gartenerde
580: "543677", // Böden > Perlite & Blähton Gartenerde
242: "Home & Garden > Lawn & Garden > Fertilizers", // Böden
243: "Home & Garden > Lawn & Garden > Fertilizers", // Erde
269: "Home & Garden > Lawn & Garden > Fertilizers", // Kokos
580: "Home & Garden > Lawn & Garden > Fertilizers", // Perlite & Blähton
// Propagation & Starting
286: "2802", // Anzucht Pflanzen- & Kräuteranbausets
298: "2802", // Anzucht > Steinwolltrays Pflanzen- & Kräuteranbausets
421: "2802", // Anzucht > Vermehrungszubehör Pflanzen- & Kräuteranbausets
489: "2802", // Anzucht > EazyPlug & Jiffy Pflanzen- & Kräuteranbausets
359: "3103", // Anzucht > Gewächshäuser Gewächshäuser
286: "Home & Garden > Plants", // Anzucht
298: "Home & Garden > Plants", // Steinwolltrays
421: "Home & Garden > Plants", // Vermehrungszubehör
489: "Home & Garden > Plants", // EazyPlug & Jiffy
359: "Home & Garden > Outdoor Structures > Greenhouses", // Gewächshäuser
// Tools & Equipment
373: "3568", // Bewässerung > GrowTool Bewässerungssysteme
403: "3999", // Bewässerung > Messbecher & mehr Messbecher & Dosierlöffel
259: "756", // Zubehör > Ernte & Verarbeitung > Pressen Nudelmaschinen
280: "2948", // Zubehör > Ernte & Verarbeitung > Erntescheeren Küchenmesser
258: "684", // Zubehör > Ernte & Verarbeitung Abfallzerkleinerer
278: "5057", // Zubehör > Ernte & Verarbeitung > Extraktion Slush-Eis-Maschinen
302: "7332", // Zubehör > Ernte & Verarbeitung > Erntemaschinen Gartenmaschinen
373: "Home & Garden > Tools > Hand Tools", // GrowTool
403: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Messbecher & mehr
259: "Home & Garden > Tools > Hand Tools", // Pressen
280: "Home & Garden > Tools > Hand Tools", // Erntescheeren
258: "Home & Garden > Tools", // Ernte & Verarbeitung
278: "Home & Garden > Tools", // Extraktion
302: "Home & Garden > Tools", // Erntemaschinen
// Hardware & Plumbing
222: "3568", // Bewässerung > PE-Teile Bewässerungssysteme
374: "1700", // Belüftung > Ab- und Zuluft > Verbindungsteile Ventilatoren
222: "Hardware > Plumbing", // PE-Teile
374: "Hardware > Plumbing > Plumbing Fittings", // Verbindungsteile
// Electronics & Control
314: "1700", // Belüftung > Steuergeräte Ventilatoren
408: "1700", // Belüftung > Steuergeräte > GrowControl Ventilatoren
344: "1207", // Zubehör > Messgeräte Messwerkzeuge & Messwertgeber
555: "4555", // Zubehör > Anbauzubehör > Mikroskope Mikroskope
314: "Electronics > Electronics Accessories", // Steuergeräte
408: "Electronics > Electronics Accessories", // GrowControl
344: "Business & Industrial > Science & Laboratory > Lab Equipment", // Messgeräte
555: "Business & Industrial > Science & Laboratory > Lab Equipment > Microscopes", // Mikroskope
// Camping & Outdoor
226: "5655", // Zubehör > Zeltzubehör Zelte
226: "Sporting Goods > Outdoor Recreation > Camping & Hiking", // Zeltzubehör
// Plant Care & Protection
239: "4085", // Zubehör > Anbauzubehör > Pflanzenschutz Herbizide
240: "5633", // Zubehör > Anbauzubehör Zubehör für Gartenarbeit
239: "Home & Garden > Lawn & Garden > Pest Control", // Pflanzenschutz
240: "Home & Garden > Plants", // Anbauzubehör
// Office & Media
424: "4377", // Zubehör > Anbauzubehör > Etiketten & Schilder Etiketten & Anhängerschilder
387: "543541", // Zubehör > Anbauzubehör > Literatur Bücher
424: "Office Supplies > Labels", // Etiketten & Schilder
387: "Media > Books", // Literatur
// General categories
686: "1700", // Belüftung > Aktivkohlefilter > Zubehör Ventilatoren
741: "1700", // Belüftung > Ab- und Zuluft > Zubehör Ventilatoren
294: "3568", // Bewässerung > Zubehör Bewässerungssysteme
695: "5631", // Zubehör Haushaltsbedarf: Aufbewahrung
293: "5631", // Zubehör > Ernte & Verarbeitung > Trockennetze Haushaltsbedarf: Aufbewahrung
4: "5631", // Zubehör > Anbauzubehör > Sonstiges Haushaltsbedarf: Aufbewahrung
450: "5631", // Zubehör > Anbauzubehör > Restposten Haushaltsbedarf: Aufbewahrung
705: "Home & Garden", // Set-Konfigurator
686: "Home & Garden", // Zubehör
741: "Home & Garden", // Zubehör
294: "Home & Garden", // Zubehör
695: "Home & Garden", // Zubehör
293: "Home & Garden", // Trockennetze
4: "Home & Garden", // Sonstiges
450: "Home & Garden", // Restposten
};
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;
return categoryMappings[categoryId] || "Home & Garden > Plants";
};
let productsXml = `<?xml version="1.0" encoding="UTF-8"?>
@@ -252,7 +150,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
<link>${baseUrl}</link>
<description>${config.descriptions.short}</description>
<lastBuildDate>${currentDate}</lastBuildDate>
<language>de-DE</language>`;
<language>${config.language}</language>`;
// Helper function to clean text content of problematic characters
const cleanTextContent = (text) => {
@@ -299,10 +197,6 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
let processedCount = 0;
let skippedCount = 0;
// Track products with missing data for logging
const productsNeedingWeight = [];
const productsNeedingDescription = [];
// Category IDs to skip (seeds, plants, headshop items)
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710];
@@ -322,96 +216,23 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
return;
}
// 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
// Skip products without GTIN
if (!product.gtin || !product.gtin.toString().trim()) {
skippedCount++;
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
if (!product.pictureList || !product.pictureList.trim()) {
skippedCount++;
return;
}
// Check if product has weight data - validate BEFORE building XML
if (!product.weight || isNaN(product.weight)) {
// Track products without weight
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 rawDescription = product.description
? cleanTextContent(product.description).substring(0, 500)
: `${product.name || 'Product'} - Art.-Nr.: ${product.articleNumber || 'N/A'}`;
const cleanDescription = escapeXml(rawDescription) || "Produktbeschreibung nicht verfügbar";
// Clean product name
@@ -442,12 +263,6 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
// Generate availability
const availability = product.available ? "in stock" : "out of stock";
// Skip products that are out of stock
if (!product.available) {
skippedCount++;
return;
}
// Generate price (ensure it's a valid number)
const price = product.price && !isNaN(product.price)
? `${parseFloat(product.price).toFixed(2)} ${config.currency}`
@@ -459,8 +274,8 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
return;
}
// Generate GTIN/EAN if available (use the already validated gtinString)
const gtin = gtinString ? escapeXml(gtinString) : null;
// Generate GTIN/EAN if available
const gtin = product.gtin ? escapeXml(product.gtin.toString().trim()) : null;
// Generate product ID (using articleNumber or seoName)
const rawProductId = product.articleNumber || product.seoName || `product_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
@@ -471,7 +286,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const googleCategory = getGoogleProductCategory(categoryId);
const escapedGoogleCategory = escapeXml(googleCategory);
// Build item XML with proper formatting (all validation passed, safe to write XML)
// Build item XML with proper formatting
productsXml += `
<item>
<g:id>${productId}</g:id>
@@ -497,21 +312,10 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
<g:gtin>${gtin}</g:gtin>`;
}
// Add weight (we know it exists at this point since we validated it earlier)
// Convert from kg to grams (multiply by 1000)
const weightInGrams = parseFloat(product.weight) * 1000;
// Add weight if available
if (product.weight && !isNaN(product.weight)) {
productsXml += `
<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>`;
<g:shipping_weight>${parseFloat(product.weight).toFixed(2)} g</g:shipping_weight>`;
}
productsXml += `
@@ -531,47 +335,6 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
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;
};

View File

@@ -183,13 +183,7 @@ This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in
categoryLlmsTxt += `## ${globalIndex}. ${product.name}
- **Product URL**: ${baseUrl}/Artikel/${product.seoName}
`;
if (product.kurzBeschreibung) {
categoryLlmsTxt += `- **Desc:** ${product.kurzBeschreibung}\n`;
}
categoryLlmsTxt += `- **Article Number**: ${product.articleNumber || 'N/A'}
- **Article Number**: ${product.articleNumber || 'N/A'}
- **Price**: €${product.price || '0.00'}
- **Brand**: ${product.manufacturer || config.brandName}
- **Availability**: ${product.available ? 'In Stock' : 'Out of Stock'}`;

View File

@@ -1,6 +1,5 @@
const generateProductMetaTags = (product, baseUrl, config) => {
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
const imageUrl =
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
@@ -8,14 +7,8 @@ const generateProductMetaTags = (product, baseUrl, config) => {
.trim()}.jpg`
: `${baseUrl}/assets/images/nopicture.jpg`;
// Clean description for meta (remove HTML tags and limit length)
const cleanDescription = product.kurzBeschreibung
? product.kurzBeschreibung
.replace(/<[^>]*>/g, "")
.replace(/\n/g, " ")
.substring(0, 160)
: product.description
const cleanDescription = product.description
? product.description
.replace(/<[^>]*>/g, "")
.replace(/\n/g, " ")
@@ -54,11 +47,6 @@ const generateProductMetaTags = (product, baseUrl, config) => {
<!-- Additional Meta Tags -->
<meta name="robots" content="index, follow">
<link rel="canonical" href="${productUrl}">
<!-- Store image URL in window object -->
<script>
window.productImageUrl = "${imageUrl}";
</script>
`;
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="de" data-i18n-lang="de">
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

View File

@@ -1,344 +0,0 @@
#!/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;

View File

@@ -1,11 +1,10 @@
import React, { useState, useEffect, lazy, Suspense } from "react";
import { createTheme } from "@mui/material/styles";
import React, { useState, useEffect, useRef, useContext, lazy, Suspense } from "react";
import {
Routes,
Route,
Navigate,
useLocation,
useNavigate
useNavigate,
} from "react-router-dom";
import { ThemeProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline";
@@ -15,27 +14,23 @@ import Fab from "@mui/material/Fab";
import Tooltip from "@mui/material/Tooltip";
import SmartToyIcon from "@mui/icons-material/SmartToy";
import PaletteIcon from "@mui/icons-material/Palette";
import ScienceIcon from "@mui/icons-material/Science";
import BugReportIcon from "@mui/icons-material/BugReport";
import { CarouselProvider } from "./contexts/CarouselContext.js";
import SocketProvider from "./providers/SocketProvider.js";
import SocketContext from "./contexts/SocketContext.js";
import config from "./config.js";
import ScrollToTop from "./components/ScrollToTop.js";
// Import i18n
import './i18n/index.js';
import { LanguageProvider } from './i18n/withTranslation.js';
import i18n from './i18n/index.js';
//import TelemetryService from './services/telemetryService.js';
import Header from "./components/Header.js";
import Footer from "./components/Footer.js";
import MainPageLayout from "./components/MainPageLayout.js";
import Home from "./pages/Home.js";
// TEMPORARILY DISABLE ALL LAZY LOADING TO ELIMINATE CircularProgress
import Content from "./components/Content.js";
import ProductDetail from "./components/ProductDetail.js";
import ProfilePage from "./pages/ProfilePage.js";
import ResetPassword from "./pages/ResetPassword.js";
// Lazy load all route components to reduce initial bundle size
const Content = lazy(() => import(/* webpackChunkName: "content" */ "./components/Content.js"));
const ProductDetailWithSocket = lazy(() => import(/* webpackChunkName: "product-detail" */ "./components/ProductDetailWithSocket.js"));
const ProfilePageWithSocket = lazy(() => import(/* webpackChunkName: "profile" */ "./pages/ProfilePage.js"));
const ResetPassword = lazy(() => import(/* webpackChunkName: "reset-password" */ "./pages/ResetPassword.js"));
// Lazy load admin pages - only loaded when admin users access them
const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js"));
@@ -45,7 +40,7 @@ const ServerLogsPage = lazy(() => import(/* webpackChunkName: "admin-logs" */ ".
// Lazy load legal pages - rarely accessed
const Datenschutz = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Datenschutz.js"));
const AGB = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/AGB.js"));
//const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js")); <Route path="/404" element={<NotFound404 />} />
const NotFound404 = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/NotFound404.js"));
const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js"));
const Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
@@ -55,54 +50,55 @@ const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./page
const GrowTentKonfigurator = lazy(() => import(/* webpackChunkName: "konfigurator" */ "./pages/GrowTentKonfigurator.js"));
const ChatAssistant = lazy(() => import(/* webpackChunkName: "chat" */ "./components/ChatAssistant.js"));
// Lazy load separate pages that are truly different
const PresseverleihPage = lazy(() => import(/* webpackChunkName: "presseverleih" */ "./pages/PresseverleihPage.js"));
const ThcTestPage = lazy(() => import(/* webpackChunkName: "thc-test" */ "./pages/ThcTestPage.js"));
// Lazy load payment success page
const PaymentSuccess = lazy(() => import(/* webpackChunkName: "payment" */ "./components/PaymentSuccess.js"));
// Lazy load prerender component (development testing only)
const PrerenderHome = lazy(() => import(/* webpackChunkName: "prerender-home" */ "./PrerenderHome.js"));
// Import theme from separate file to reduce main bundle size
import defaultTheme from "./theme.js";
// Lazy load theme customizer for development only
const ThemeCustomizerDialog = lazy(() => import(/* webpackChunkName: "theme-customizer" */ "./components/ThemeCustomizerDialog.js"));
import { createTheme } from "@mui/material/styles";
const deleteMessages = () => {
console.log("Deleting messages");
window.chatMessages = [];
};
// Component to initialize telemetry service with socket
const TelemetryInitializer = ({ socket }) => {
const telemetryServiceRef = useRef(null);
const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
useEffect(() => {
if (socket && !telemetryServiceRef.current) {
//telemetryServiceRef.current = new TelemetryService(socket);
//telemetryServiceRef.current.init();
}
return () => {
if (telemetryServiceRef.current) {
telemetryServiceRef.current.destroy();
telemetryServiceRef.current = null;
}
};
}, [socket]);
return null; // This component doesn't render anything
};
const AppContent = ({ currentTheme, onThemeChange }) => {
// State to manage chat visibility
const [isChatOpen, setChatOpen] = useState(false);
const [authVersion, setAuthVersion] = useState(0);
// @note Theme customizer state for development mode
const [isThemeCustomizerOpen, setThemeCustomizerOpen] = useState(false);
// State to track active category for article pages
const [articleCategoryId, setArticleCategoryId] = useState(null);
// Remove duplicate theme state since it's passed as prop
// const [dynamicTheme, setDynamicTheme] = useState(createTheme(defaultTheme));
// Get current location
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
if (location.hash && location.hash.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.hash && location.hash.startsWith("#ORD-")) {
if (location.pathname !== "/profile") {
navigate(`/profile${location.hash}`, { replace: true });
}
}
}
}, [location, navigate]);
useEffect(() => {
@@ -115,44 +111,10 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
};
}, []);
// Clear article category when navigating away from article pages
useEffect(() => {
const isArticlePage = location.pathname.startsWith('/Artikel/');
const isCategoryPage = location.pathname.startsWith('/Kategorie/');
const isHomePage = location.pathname === '/';
// Only clear article category when navigating to non-article pages
// (but keep it when going from category to article)
if (!isArticlePage && !isCategoryPage && !isHomePage) {
setArticleCategoryId(null);
}
}, [location.pathname]);
// Read article category from navigation state (when coming from product click)
useEffect(() => {
if (location.state && location.state.articleCategoryId !== undefined) {
if (location.state.articleCategoryId !== null) {
setArticleCategoryId(location.state.articleCategoryId);
}
// Clear the state so it doesn't persist on page refresh
navigate(location.pathname, { replace: true, state: {} });
}
}, [location.state, navigate, location.pathname]);
// Extract categoryId from pathname if on category route, or use article category
// Extract categoryId from pathname if on category route
const getCategoryId = () => {
const match = location.pathname.match(/^\/Kategorie\/(.+)$/);
if (match) {
return match[1];
}
// For article pages, use the article category if available
const isArticlePage = location.pathname.startsWith('/Artikel/');
if (isArticlePage && articleCategoryId) {
return articleCategoryId;
}
return null;
return match ? match[1] : null;
};
const categoryId = getCategoryId();
@@ -177,40 +139,35 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
setThemeCustomizerOpen(!isThemeCustomizerOpen);
};
// Handler to open GitHub issue reporting
const handleReportIssue = () => {
const issueTitle = encodeURIComponent("Fehlerbericht");
const issueBody = encodeURIComponent(
`**Seite:** ${window.location.href}
**Browser:** ${navigator.userAgent.split(' ')[0]}
**Datum:** ${new Date().toLocaleDateString('de-DE')}
**Problem:**
[Beschreibe kurz das Problem]
**So ist es passiert:**
1.
2.
**Was sollte passieren:**
[Was erwartet wurde]`
);
const githubIssueUrl = `https://github.com/Growheads-de/shopFrontEnd/issues/new?title=${issueTitle}&body=${issueBody}`;
window.open(githubIssueUrl, '_blank');
};
// Check if we're in development mode
const isDevelopment = process.env.NODE_ENV === "development";
// Check if current route is a prerender test route
const isPrerenderTestRoute = isDevelopment && location.pathname === "/prerenderTest/home";
const {socket,socketB} = useContext(SocketContext);
console.log("AppContent: socket", socket);
// If it's a prerender test route, render it standalone without app layout
if (isPrerenderTestRoute) {
return (
<LanguageProvider i18n={i18n}>
<ThemeProvider theme={dynamicTheme}>
<CssBaseline />
<Suspense fallback={
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
}}
>
<CircularProgress color="primary" />
</Box>
}>
<PrerenderHome />
</Suspense>
</ThemeProvider>
</LanguageProvider>
);
}
// Regular app layout for all other routes
return (
<Box
sx={{
@@ -223,17 +180,10 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
}}
>
<ScrollToTop />
<TelemetryInitializer socket={socket} />
<Header active categoryId={categoryId} key={authVersion} />
<Box sx={{ flexGrow: 1 }}>
<Suspense fallback={
// Use prerender fallback if available, otherwise show loading spinner
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
<div
dangerouslySetInnerHTML={{
__html: window.__PRERENDER_FALLBACK__.content,
}}
/>
) : (
<Box
sx={{
display: "flex",
@@ -244,53 +194,47 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
>
<CircularProgress color="primary" />
</Box>
)
}>
<CarouselProvider>
<Routes>
{/* Main pages using unified component */}
<Route path="/" element={<MainPageLayout />} />
<Route path="/aktionen" element={<MainPageLayout />} />
<Route path="/filiale" element={<MainPageLayout />} />
{/* Home page with text only */}
<Route path="/" element={<Home />} />
{/* Category page - Render Content in parallel */}
<Route
path="/Kategorie/:categoryId"
element={<Content/>}
element={<Content socket={socket} socketB={socketB} />}
/>
{/* Single product page */}
<Route
path="/Artikel/:seoName"
element={<ProductDetail/>}
element={<ProductDetailWithSocket />}
/>
{/* Search page - Render Content in parallel */}
<Route path="/search" element={<Content/>} />
<Route path="/search" element={<Content socket={socket} socketB={socketB} />} />
{/* Profile page */}
<Route path="/profile" element={<ProfilePage/>} />
{/* Payment success page for Mollie redirects */}
<Route path="/payment/success" element={<PaymentSuccess />} />
<Route path="/profile" element={<ProfilePageWithSocket />} />
{/* Reset password page */}
<Route
path="/resetPassword"
element={<ResetPassword/>}
element={<ResetPassword socket={socket} socketB={socketB} />}
/>
{/* Admin page */}
<Route path="/admin" element={<AdminPage/>} />
<Route path="/admin" element={<AdminPage socket={socket} socketB={socketB} />} />
{/* Admin Users page */}
<Route path="/admin/users" element={<UsersPage/>} />
<Route path="/admin/users" element={<UsersPage socket={socket} socketB={socketB} />} />
{/* Admin Server Logs page */}
<Route path="/admin/logs" element={<ServerLogsPage/>} />
<Route path="/admin/logs" element={<ServerLogsPage socket={socket} socketB={socketB} />} />
{/* Legal pages */}
<Route path="/datenschutz" element={<Datenschutz />} />
<Route path="/agb" element={<AGB />} />
<Route path="/404" element={<NotFound404 />} />
<Route path="/sitemap" element={<Sitemap />} />
<Route path="/impressum" element={<Impressum />} />
<Route
@@ -300,34 +244,20 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
<Route path="/widerrufsrecht" element={<Widerrufsrecht />} />
{/* Grow Tent Configurator */}
<Route path="/Konfigurator" element={<GrowTentKonfigurator/>} />
{/* Separate pages that are truly different */}
<Route path="/presseverleih" element={<PresseverleihPage />} />
<Route path="/thc-test" element={<ThcTestPage />} />
<Route path="/Konfigurator" element={<GrowTentKonfigurator />} />
{/* Fallback for undefined routes */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</CarouselProvider>
</Suspense>
</Box>
{/* Conditionally render the Chat Assistant */}
{isChatOpen && (
<Suspense fallback={
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
<div
dangerouslySetInnerHTML={{
__html: window.__PRERENDER_FALLBACK__.content,
}}
/>
) : (
<CircularProgress size={20} />
)
}>
<Suspense fallback={<CircularProgress size={20} />}>
<ChatAssistant
open={isChatOpen}
onClose={handleChatClose}
socket={socket}
/>
</Suspense>
)}
@@ -349,7 +279,7 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
</Fab>
</Tooltip>
{/* GitHub Issue Reporter FAB
{/* GitHub Issue Reporter FAB */}
<Tooltip title="Fehler oder Problem melden" placement="left">
<Fab
color="error"
@@ -364,7 +294,7 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
>
<BugReportIcon sx={{ fontSize: "1.2rem" }} />
</Fab>
</Tooltip>*/}
</Tooltip>
{/* Development-only Theme Customizer FAB */}
{isDevelopment && (
@@ -385,38 +315,9 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
</Tooltip>
)}
{/* Development-only Prerender Test FAB */}
{isDevelopment && (
<Tooltip title="Test Prerender Home" placement="left">
<Fab
color="warning"
aria-label="prerender test"
size="small"
sx={{
position: "fixed",
bottom: 31,
right: 75,
}}
onClick={() => navigate('/prerenderTest/home')}
>
<ScienceIcon sx={{ fontSize: "1.2rem" }} />
</Fab>
</Tooltip>
)}
{/* Development-only Theme Customizer Dialog */}
{isDevelopment && isThemeCustomizerOpen && (
<Suspense fallback={
typeof window !== "undefined" && window.__PRERENDER_FALLBACK__ ? (
<div
dangerouslySetInnerHTML={{
__html: window.__PRERENDER_FALLBACK__.content,
}}
/>
) : (
<CircularProgress size={20} />
)
}>
<Suspense fallback={<CircularProgress size={20} />}>
<ThemeCustomizerDialog
open={isThemeCustomizerOpen}
onClose={() => setThemeCustomizerOpen(false)}
@@ -442,22 +343,30 @@ const App = () => {
setDynamicTheme(createTheme(newTheme));
};
// Make config globally available for language switching
useEffect(() => {
window.shopConfig = config;
}, []);
return (
<LanguageProvider i18n={i18n}>
<ThemeProvider theme={dynamicTheme}>
<CssBaseline />
<SocketProvider
url={config.apiBaseUrl}
fallback={
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<CircularProgress color="primary" />
</Box>
}
>
<AppContent
currentTheme={currentTheme}
dynamicTheme={dynamicTheme}
onThemeChange={handleThemeChange}
/>
</SocketProvider>
</ThemeProvider>
</LanguageProvider>
);
};

View File

@@ -3,8 +3,7 @@ import { Box, AppBar, Toolbar, Container} from '@mui/material';
import { Routes, Route } from 'react-router-dom';
import Footer from './components/Footer.js';
import { Logo, CategoryList } from './components/header/index.js';
import MainPageLayout from './components/MainPageLayout.js';
import { CarouselProvider } from './contexts/CarouselContext.js';
import Home from './pages/Home.js';
const PrerenderAppContent = (socket) => (
<Box
@@ -45,11 +44,9 @@ const PrerenderAppContent = (socket) => (
</AppBar>
<Box sx={{ flexGrow: 1 }}>
<CarouselProvider>
<Routes>
<Route path="/" element={<MainPageLayout />} />
<Route path="/" element={<Home />} />
</Routes>
</CarouselProvider>
</Box>
<Footer/>

View File

@@ -3,7 +3,7 @@ import { Box, AppBar, Toolbar, Container, Typography, Grid, Card, CardMedia, Car
import Footer from './components/Footer.js';
import { Logo, SearchBar, CategoryList } from './components/header/index.js';
const PrerenderCategory = ({ categoryId, categoryName, categorySeoName: _categorySeoName, productData }) => {
const PrerenderCategory = ({ categoryId, categoryName, categorySeoName, productData }) => {
const products = productData?.products || [];
return (

View File

@@ -1,13 +1,13 @@
import React from 'react';
import {
const React = require('react');
const {
Box,
AppBar,
Toolbar,
Container
} from '@mui/material';
import Footer from './components/Footer.js';
import { Logo, CategoryList } from './components/header/index.js';
} = require('@mui/material');
const Footer = require('./components/Footer.js').default;
const { Logo, CategoryList } = require('./components/header/index.js');
const Home = require('./pages/Home.js').default;
class PrerenderHome extends React.Component {
render() {
@@ -28,14 +28,10 @@ class PrerenderHome extends React.Component {
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
React.createElement(
Toolbar,
{ sx: { minHeight: 64, py: { xs: 0.5, sm: 0 } } },
{ sx: { minHeight: 64 } },
React.createElement(
Container,
{ maxWidth: 'lg', sx: {
display: 'flex',
alignItems: 'center',
px: { xs: 0, sm: 3 }
} },
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center' } },
React.createElement(
Box,
{
@@ -53,78 +49,24 @@ class PrerenderHome extends React.Component {
display: 'flex',
alignItems: 'center',
width: '100%',
justifyContent: { xs: 'space-between', sm: 'flex-start' },
minHeight: { xs: 52, sm: 'auto' },
px: { xs: 0, sm: 0 }
justifyContent: { xs: 'space-between', sm: 'flex-start' }
}
},
React.createElement(Logo),
// Invisible SearchBar placeholder on desktop
React.createElement(
Box,
{
sx: {
display: { xs: 'none', sm: 'block' },
flexGrow: 1,
height: 41, // Reserve space for SearchBar
opacity: 0 // Invisible placeholder
}
}
),
// Invisible ButtonGroup placeholder
React.createElement(
Box,
{
sx: {
display: 'flex',
alignItems: { xs: 'flex-end', sm: 'center' },
transform: { xs: 'translateY(4px) translateX(9px)', sm: 'none' },
ml: { xs: 0, sm: 0 },
gap: { xs: 0.5, sm: 1 },
opacity: 0 // Invisible placeholder
}
},
// Placeholder for LanguageSwitcher (approx width)
React.createElement(
Box,
{ sx: { width: 40, height: 40 } }
),
// Placeholder for LoginComponent (approx width)
React.createElement(
Box,
{ sx: { width: 40, height: 40 } }
),
// Placeholder for Cart button (approx width)
React.createElement(
Box,
{ sx: { width: 48, height: 40, ml: 1 } }
)
)
),
// Invisible SearchBar placeholder on mobile
React.createElement(
Box,
{
sx: {
display: { xs: 'block', sm: 'none' },
width: '100%',
mt: { xs: 1, sm: 0 },
mb: { xs: 0.5, sm: 0 },
px: { xs: 0, sm: 0 },
height: 41, // Reserve space for SearchBar
opacity: 0 // Invisible placeholder
}
}
React.createElement(Logo)
)
)
)
),
React.createElement(CategoryList, { categoryId: 209, activeCategoryId: null })
),
React.createElement(
Box,
{ sx: { flexGrow: 1 } },
React.createElement(Home)
),
React.createElement(Footer)
);
}
}
export default PrerenderHome;
module.exports = { default: PrerenderHome };

View File

@@ -66,7 +66,6 @@ class PrerenderKonfigurator extends Component {
15%
</Typography>
<Typography variant="body2">
{/* Note: This is a prerender file - translation key would be: product.discount.from3Products */}
ab 3 Produkten
</Typography>
</Box>
@@ -75,7 +74,6 @@ class PrerenderKonfigurator extends Component {
24%
</Typography>
<Typography variant="body2">
{/* Note: This is a prerender file - translation key would be: product.discount.from5Products */}
ab 5 Produkten
</Typography>
</Box>
@@ -84,13 +82,11 @@ class PrerenderKonfigurator extends Component {
36%
</Typography>
<Typography variant="body2">
{/* Note: This is a prerender file - translation key would be: product.discount.from7Products */}
ab 7 Produkten
</Typography>
</Box>
</Box>
<Typography variant="caption" sx={{ color: '#666', mt: 1, display: 'block' }}>
{/* Note: This is a prerender file - translation key would be: product.discount.moreProductsMoreSavings */}
Je mehr Produkte du auswählst, desto mehr sparst du!
</Typography>
</Paper>

View File

@@ -1,92 +0,0 @@
import React from 'react';
import {
Box,
AppBar,
Toolbar,
Container
} from '@mui/material';
import Footer from './components/Footer.js';
import { Logo } from './components/header/index.js';
import NotFound404 from './pages/NotFound404.js';
class PrerenderNotFound extends React.Component {
render() {
return React.createElement(
Box,
{
sx: {
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
mb: 0,
pb: 0,
bgcolor: 'background.default'
}
},
React.createElement(
AppBar,
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
React.createElement(
Toolbar,
{ sx: { minHeight: 64, py: { xs: 0.5, sm: 0 } } },
React.createElement(
Container,
{
maxWidth: 'lg',
sx: {
display: 'flex',
alignItems: 'center',
px: { xs: 0, sm: 3 }
}
},
React.createElement(
Box,
{ sx: {
display: 'flex',
alignItems: 'center',
width: '100%',
flexDirection: { xs: 'column', sm: 'row' }
}
},
React.createElement(
Box,
{ sx: {
display: 'flex',
alignItems: 'center',
width: '100%',
justifyContent: { xs: 'space-between', sm: 'flex-start' },
minHeight: { xs: 52, sm: 'auto' },
px: { xs: 0, sm: 0 }
}
},
React.createElement(Logo)
),
// Reserve space for SearchBar on mobile (invisible placeholder)
React.createElement(
Box,
{ sx: {
display: { xs: 'block', sm: 'none' },
width: '100%',
mt: { xs: 1, sm: 0 },
mb: { xs: 0.5, sm: 0 },
px: { xs: 0, sm: 0 },
height: 40, // Reserve space for SearchBar
opacity: 0 // Invisible placeholder
}
}
)
)
)
)
),
React.createElement(
Box,
{ sx: { flexGrow: 1 } },
React.createElement(NotFound404)
),
React.createElement(Footer)
);
}
}
export default PrerenderNotFound;

View File

@@ -1,25 +1,18 @@
import React from 'react';
import {
const React = require('react');
const {
Container,
Typography,
Card,
CardMedia,
Grid,
Box,
Chip,
Stack,
AppBar,
Toolbar,
Button
} from '@mui/material';
import sanitizeHtml from 'sanitize-html';
import Footer from './components/Footer.js';
import { Logo } from './components/header/index.js';
import ProductImage from './components/ProductImage.js';
// Utility function to clean product names by removing trailing number in parentheses
const cleanProductName = (name) => {
if (!name) return "";
// Remove patterns like " (1)", " (3)", " (10)" at the end of the string
return name.replace(/\s*\(\d+\)\s*$/, "").trim();
};
Toolbar
} = require('@mui/material');
const Footer = require('./components/Footer.js').default;
const { Logo } = require('./components/header/index.js');
class PrerenderProduct extends React.Component {
render() {
@@ -27,29 +20,21 @@ class PrerenderProduct extends React.Component {
if (!productData) {
return React.createElement(
Box,
{ sx: { p: 4, textAlign: "center" } },
Container,
{ maxWidth: 'lg', sx: { py: 4 } },
React.createElement(
Typography,
{ variant: 'h5', gutterBottom: true },
'Produkt nicht gefunden'
),
React.createElement(
Typography,
null,
'Das gesuchte Produkt existiert nicht oder wurde entfernt.'
{ variant: 'h4', component: 'h1', gutterBottom: true },
'Product not found'
)
);
}
const product = productData.product;
const attributes = productData.attributes || [];
// Format price with tax
const priceWithTax = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(product.price);
const mainImage = product.pictureList && product.pictureList.trim()
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.jpg`
: '/assets/images/nopicture.jpg';
return React.createElement(
Box,
@@ -68,497 +53,137 @@ class PrerenderProduct extends React.Component {
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
React.createElement(
Toolbar,
{ sx: { minHeight: 64, py: { xs: 0.5, sm: 0 } } },
{ sx: { minHeight: 64 } },
React.createElement(
Container,
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center', px: { xs: 0, sm: 3 } } },
// Desktop: simple layout, Mobile: column layout with SearchBar space
React.createElement(
Box,
{
sx: {
display: 'flex',
alignItems: 'center',
width: '100%',
flexDirection: { xs: 'column', sm: 'row' }
}
},
// First row: Logo and invisible placeholders to match SPA layout
React.createElement(
Box,
{
sx: {
display: 'flex',
alignItems: 'center',
width: '100%',
justifyContent: { xs: 'space-between', sm: 'flex-start' }, // Match SPA layout
minHeight: { xs: 52, sm: 'auto' },
px: { xs: 0, sm: 0 }
}
},
React.createElement(Logo),
// Invisible SearchBar placeholder on desktop to match SPA spacing
React.createElement(
Box,
{
sx: {
display: { xs: 'none', sm: 'block' },
flexGrow: 1,
mx: { xs: 0, sm: 2, md: 4 },
visibility: 'hidden',
height: 40 // Match SearchBar height
}
}
),
// Invisible ButtonGroup placeholder to match SPA spacing
React.createElement(
Box,
{
sx: {
display: { xs: 'flex', sm: 'flex' },
alignItems: { xs: 'flex-end', sm: 'center' },
transform: { xs: 'translateY(4px) translateX(9px)', sm: 'none' },
ml: { xs: 0, sm: 0 },
visibility: 'hidden',
width: { xs: 'auto', sm: '120px' }, // Approximate ButtonGroup width
height: 40
}
}
)
),
// Second row: SearchBar placeholder only on mobile
React.createElement(
Box,
{
sx: {
display: { xs: 'block', sm: 'none' },
width: '100%',
mt: { xs: 1, sm: 0 },
mb: { xs: 0.5, sm: 0 },
px: { xs: 0, sm: 0 },
height: 41, // Small TextField height
visibility: 'hidden'
}
}
)
)
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center' } },
React.createElement(Logo)
)
)
),
React.createElement(
Box,
{ sx: { flexGrow: 1 } },
React.createElement(
Container,
{
maxWidth: "lg",
sx: {
p: { xs: 2, md: 2 },
pb: { xs: 4, md: 8 },
flexGrow: 1
}
},
// Back button (breadcrumbs section)
{ maxWidth: 'lg', sx: { py: 4, flexGrow: 1 } },
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,
}
},
Grid,
{ container: true, spacing: 4 },
// Product Image
React.createElement(
Box,
{
sx: {
ml: { xs: 0, md: 0 },
display: "inline-flex",
px: 0,
py: 1,
backgroundColor: "#2e7d32", // primary dark green
borderRadius: 1,
}
},
Grid,
{ item: true, xs: 12, md: 6 },
React.createElement(
Typography,
{ variant: "body2", color: "text.secondary" },
Card,
{ sx: { height: '100%' } },
React.createElement(
'a',
CardMedia,
{
href: "#",
onClick: (e) => {
e.preventDefault();
if (window.history.length > 1) {
window.history.back();
} else {
window.location.href = '/';
component: 'img',
height: '400',
image: mainImage,
alt: product.name,
sx: { objectFit: 'contain', p: 2 }
}
},
style: {
paddingLeft: 16,
paddingRight: 16,
paddingTop: 8,
paddingBottom: 8,
textDecoration: "none",
color: "#fff",
fontWeight: "bold",
cursor: "pointer"
}
},
this.props.t ? this.props.t('common.back') : 'Zurück'
)
)
)
),
// Product Details
React.createElement(
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
Grid,
{ item: true, xs: 12, md: 6 },
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) =>
{ spacing: 3 },
React.createElement(
Chip,
{
key: attribute.kMerkmalWert || index,
label: attribute.cWert,
disabled: true,
sx: { mb: 1 }
}
)
)
Typography,
{ variant: 'h3', component: 'h1', gutterBottom: true },
product.name
),
// Right side - action buttons (exact replica with invisible versions)
React.createElement(
Stack,
{ direction: 'column', spacing: 1, sx: { flexShrink: 0 } },
// "Frage zum Artikel" button - exact replica but invisible
Typography,
{ variant: 'h6', color: 'text.secondary' },
'Artikelnummer: '+product.articleNumber+' '+(product.gtin ? ` | GTIN: ${product.gtin}` : "")
),
React.createElement(
Button,
{
variant: "outlined",
size: "small",
sx: {
fontSize: "0.75rem",
px: 1.5,
py: 0.5,
minWidth: "auto",
whiteSpace: "nowrap",
visibility: "hidden",
pointerEvents: "none"
}
},
"Frage zum Artikel"
),
// "Artikel Bewerten" button - exact replica but invisible
React.createElement(
Button,
{
variant: "outlined",
size: "small",
sx: {
fontSize: "0.75rem",
px: 1.5,
py: 0.5,
minWidth: "auto",
whiteSpace: "nowrap",
visibility: "hidden",
pointerEvents: "none"
}
},
"Artikel Bewerten"
),
// "Verfügbarkeit anfragen" button - conditional, exact replica but invisible
(product.available !== 1 && product.availableSupplier !== 1) && React.createElement(
Button,
{
variant: "outlined",
size: "small",
sx: {
fontSize: "0.75rem",
px: 1.5,
py: 0.5,
minWidth: "auto",
whiteSpace: "nowrap",
borderColor: "warning.main",
color: "warning.main",
"&:hover": {
borderColor: "warning.dark",
backgroundColor: "warning.light"
},
visibility: "hidden",
pointerEvents: "none"
}
},
"Verfügbarkeit anfragen"
)
)
),
// Weight
(product.weight && product.weight > 0) ? React.createElement(
Box,
{ sx: { mb: 2 } },
{ sx: { mt: 1 } },
React.createElement(
Typography,
{ variant: 'h4', color: 'primary', fontWeight: 'bold' },
new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(product.price)
),
product.vat && React.createElement(
Typography,
{ variant: 'body2', color: 'text.secondary' },
(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,
`inkl. ${product.vat}% MwSt.`
),
React.createElement(
Typography,
{
variant: "h4",
color: "primary",
sx: { fontWeight: "bold" }
variant: 'body1',
color: product.available ? 'success.main' : 'error.main',
fontWeight: 'medium',
sx: { mt: 1 }
},
priceWithTax
),
// VAT info (exact match to SPA - direct Typography, no wrapper)
React.createElement(
Typography,
{ variant: 'body2', color: 'text.secondary' },
(this.props.t ? this.props.t('product.inclVat', { vat: product.vat }) : `inkl. ${product.vat}% MwSt.`) +
(product.cGrundEinheit && product.fGrundPreis ?
`; ${new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(product.fGrundPreis)}/${product.cGrundEinheit}` :
"")
),
// Shipping class (exact match to SPA - direct Typography, conditional render)
product.versandklasse &&
product.versandklasse != "standard" &&
product.versandklasse != "kostenlos" && React.createElement(
Typography,
{ variant: 'body2', color: 'text.secondary' },
product.versandklasse
product.available ? '✅ Verfügbar' : '❌ Nicht verfügbar'
)
),
// 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)",
}
},
{ sx: { mt: 2 } },
React.createElement(
Box,
{
sx: {
mt: 2,
lineHeight: 1.7,
"& p": { mt: 0, mb: 2 },
"& strong": { fontWeight: 600 },
}
},
Typography,
{ variant: 'h6', gutterBottom: true },
'Beschreibung'
),
React.createElement(
'div',
{
dangerouslySetInnerHTML: {
__html: sanitizeHtml(product.description, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
allowedAttributes: {
'*': ['class', 'style'],
'a': ['href', 'title'],
'img': ['src', 'alt', 'width', 'height']
},
disallowedTagsMode: 'discard'
})
},
dangerouslySetInnerHTML: { __html: product.description },
style: {
fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
fontSize: '1rem',
lineHeight: '1.7',
color: '#333'
lineHeight: '1.5',
color: '#33691E'
}
}
)
),
// 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'
}
)
)
)
)
)
)
)
@@ -568,4 +193,4 @@ class PrerenderProduct extends React.Component {
}
}
export default PrerenderProduct;
module.exports = { default: PrerenderProduct };

View File

@@ -1,11 +1,17 @@
import React from 'react';
import {
const React = require('react');
const {
Box,
AppBar,
Toolbar,
Container,
Typography,
List,
ListItem,
ListItemText
} from '@mui/material';
import LegalPage from './pages/LegalPage.js';
} = require('@mui/material');
const Footer = require('./components/Footer.js').default;
const { Logo, CategoryList } = require('./components/header/index.js');
const LegalPage = require('./pages/LegalPage.js').default;
const PrerenderSitemap = ({ categoryData }) => {
// Process category data to flatten the hierarchy
@@ -128,4 +134,4 @@ const PrerenderSitemap = ({ categoryData }) => {
return React.createElement(LegalPage, { title: 'Sitemap', content: content });
};
export default PrerenderSitemap;
module.exports = { default: PrerenderSitemap };

View File

@@ -10,7 +10,6 @@ import AddIcon from "@mui/icons-material/Add";
import RemoveIcon from "@mui/icons-material/Remove";
import ShoppingCartIcon from "@mui/icons-material/ShoppingCart";
import DeleteIcon from "@mui/icons-material/Delete";
import { withI18n } from "../i18n/withTranslation.js";
if (!Array.isArray(window.cart)) window.cart = [];
@@ -52,14 +51,11 @@ class AddToCartButton extends Component {
seoName: this.props.seoName,
pictureList: this.props.pictureList,
price: this.props.price,
fGrundPreis: this.props.fGrundPreis,
cGrundEinheit: this.props.cGrundEinheit,
quantity: 1,
weight: this.props.weight,
vat: this.props.vat,
versandklasse: this.props.versandklasse,
availableSupplier: this.props.availableSupplier,
komponenten: this.props.komponenten,
available: this.props.available
});
} else {
@@ -154,17 +150,12 @@ class AddToCartButton extends Component {
},
}}
>
{this.props.t ? this.props.t('cart.availableFrom', {
date: new Date(incoming).toLocaleDateString("de-DE", {
Ab{" "}
{new Date(incoming).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
})
}) : `Ab ${new Date(incoming).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
})}`}
})}
</Button>
);
}
@@ -190,9 +181,7 @@ class AddToCartButton extends Component {
},
}}
>
{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")}
{this.props.steckling ? "Als Steckling vorbestellen" : "In den Korb"}
</Button>
);
}
@@ -216,7 +205,6 @@ class AddToCartButton extends Component {
<IconButton
color="inherit"
onClick={this.handleDecrement}
aria-label="Menge verringern"
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
>
<RemoveIcon />
@@ -266,17 +254,15 @@ class AddToCartButton extends Component {
<IconButton
color="inherit"
onClick={this.handleIncrement}
aria-label="Menge erhöhen"
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
>
<AddIcon />
</IconButton>
<Tooltip title={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'} arrow>
<Tooltip title="Aus dem Warenkorb entfernen" arrow>
<IconButton
color="inherit"
onClick={this.handleClearCart}
aria-label={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'}
sx={{
borderRadius: 0,
"&:hover": { color: "error.light" },
@@ -286,11 +272,10 @@ class AddToCartButton extends Component {
</IconButton>
</Tooltip>
{this.props.cartButton && (
<Tooltip title={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'} arrow>
<Tooltip title="Warenkorb öffnen" arrow>
<IconButton
color="inherit"
onClick={this.toggleCart}
aria-label={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'}
sx={{
borderRadius: 0,
"&:hover": { color: "primary.light" },
@@ -317,7 +302,7 @@ class AddToCartButton extends Component {
fontWeight: "bold",
}}
>
{this.props.t ? this.props.t('product.outOfStock') : 'Out of Stock'}
Out of Stock
</Button>
);
}
@@ -342,9 +327,7 @@ class AddToCartButton extends Component {
},
}}
>
{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")}
{this.props.steckling ? "Als Steckling vorbestellen" : "In den Korb"}
</Button>
);
}
@@ -367,7 +350,6 @@ class AddToCartButton extends Component {
<IconButton
color="inherit"
onClick={this.handleDecrement}
aria-label="Menge verringern"
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
>
<RemoveIcon />
@@ -417,17 +399,15 @@ class AddToCartButton extends Component {
<IconButton
color="inherit"
onClick={this.handleIncrement}
aria-label="Menge erhöhen"
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
>
<AddIcon />
</IconButton>
<Tooltip title={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'} arrow>
<Tooltip title="Aus dem Warenkorb entfernen" arrow>
<IconButton
color="inherit"
onClick={this.handleClearCart}
aria-label={this.props.t ? this.props.t('cart.removeFromCart') : 'Aus dem Warenkorb entfernen'}
sx={{
borderRadius: 0,
"&:hover": { color: "error.light" },
@@ -437,11 +417,10 @@ class AddToCartButton extends Component {
</IconButton>
</Tooltip>
{this.props.cartButton && (
<Tooltip title={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'} arrow>
<Tooltip title="Warenkorb öffnen" arrow>
<IconButton
color="inherit"
onClick={this.toggleCart}
aria-label={this.props.t ? this.props.t('cart.openCart') : 'Warenkorb öffnen'}
sx={{
borderRadius: 0,
"&:hover": { color: "primary.light" },
@@ -457,4 +436,4 @@ class AddToCartButton extends Component {
}
}
export default withI18n()(AddToCartButton);
export default AddToCartButton;

View File

@@ -1,240 +0,0 @@
import React, { Component } from 'react';
import {
Box,
Typography,
TextField,
Button,
Paper,
Alert,
CircularProgress,
FormControl,
FormLabel,
RadioGroup,
FormControlLabel,
Radio
} from '@mui/material';
import { withI18n } from '../i18n/withTranslation.js';
class ArticleAvailabilityForm extends Component {
constructor(props) {
super(props);
this.state = {
name: '',
email: '',
telegramId: '',
notificationMethod: 'email',
message: '',
loading: false,
success: false,
error: null
};
}
handleInputChange = (field) => (event) => {
this.setState({ [field]: event.target.value });
};
handleNotificationMethodChange = (event) => {
this.setState({
notificationMethod: event.target.value,
// Clear the other field when switching methods
email: event.target.value === 'email' ? this.state.email : '',
telegramId: event.target.value === 'telegram' ? this.state.telegramId : ''
});
};
handleSubmit = (event) => {
event.preventDefault();
// Prepare data for API emission
const availabilityData = {
type: 'availability_inquiry',
productId: this.props.productId,
productName: this.props.productName,
name: this.state.name,
notificationMethod: this.state.notificationMethod,
email: this.state.notificationMethod === 'email' ? this.state.email : '',
telegramId: this.state.notificationMethod === 'telegram' ? this.state.telegramId : '',
message: this.state.message,
timestamp: new Date().toISOString()
};
// Emit data via socket
console.log('Availability Inquiry Data to emit:', availabilityData);
window.socketManager.emit('availability_inquiry_submit', availabilityData);
// Set up response handler
window.socketManager.once('availability_inquiry_response', (response) => {
if (response.success) {
this.setState({
loading: false,
success: true,
name: '',
email: '',
telegramId: '',
notificationMethod: 'email',
message: ''
});
} else {
this.setState({
loading: false,
error: response.error || '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);

View File

@@ -1,234 +0,0 @@
import React, { Component } from 'react';
import {
Box,
Typography,
TextField,
Button,
Paper,
Alert,
CircularProgress
} from '@mui/material';
import { withI18n } from '../i18n/withTranslation.js';
import PhotoUpload from './PhotoUpload.js';
class ArticleQuestionForm extends Component {
constructor(props) {
super(props);
this.state = {
name: '',
email: '',
question: '',
photos: [],
loading: false,
success: false,
error: null
};
this.photoUploadRef = React.createRef();
}
handleInputChange = (field) => (event) => {
this.setState({ [field]: event.target.value });
};
handlePhotosChange = (files) => {
this.setState({ photos: files });
};
convertPhotosToBase64 = (photos) => {
return Promise.all(
photos.map(photo => {
return new Promise((resolve, _reject) => {
const reader = new FileReader();
reader.onload = (e) => {
resolve({
name: photo.name,
type: photo.type,
size: photo.size,
data: e.target.result // base64 string
});
};
reader.readAsDataURL(photo);
});
})
);
};
handleSubmit = async (event) => {
event.preventDefault();
this.setState({ loading: true });
try {
// Convert photos to base64
const photosBase64 = await this.convertPhotosToBase64(this.state.photos);
// Prepare data for API emission
const questionData = {
type: 'article_question',
productId: this.props.productId,
productName: this.props.productName,
name: this.state.name,
email: this.state.email,
question: this.state.question,
photos: photosBase64,
timestamp: new Date().toISOString()
};
// Emit data via socket
console.log('Article Question Data to emit:', questionData);
window.socketManager.emit('article_question_submit', questionData);
// Set up response handler
window.socketManager.once('article_question_response', (response) => {
if (response.success) {
this.setState({
loading: false,
success: true,
name: '',
email: '',
question: '',
photos: []
});
// Reset the photo upload component
if (this.photoUploadRef.current) {
this.photoUploadRef.current.reset();
}
} else {
this.setState({
loading: false,
error: response.error || '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);

View File

@@ -1,262 +0,0 @@
import React, { Component } from 'react';
import {
Box,
Typography,
TextField,
Button,
Paper,
Alert,
CircularProgress,
Rating
} from '@mui/material';
import StarIcon from '@mui/icons-material/Star';
import { withI18n } from '../i18n/withTranslation.js';
import PhotoUpload from './PhotoUpload.js';
class ArticleRatingForm extends Component {
constructor(props) {
super(props);
this.state = {
name: '',
email: '',
rating: 0,
review: '',
photos: [],
loading: false,
success: false,
error: null
};
this.photoUploadRef = React.createRef();
}
handleInputChange = (field) => (event) => {
this.setState({ [field]: event.target.value });
};
handleRatingChange = (event, newValue) => {
this.setState({ rating: newValue });
};
handlePhotosChange = (files) => {
this.setState({ photos: files });
};
convertPhotosToBase64 = (photos) => {
return Promise.all(
photos.map(photo => {
return new Promise((resolve, _reject) => {
const reader = new FileReader();
reader.onload = (e) => {
resolve({
name: photo.name,
type: photo.type,
size: photo.size,
data: e.target.result // base64 string
});
};
reader.readAsDataURL(photo);
});
})
);
};
handleSubmit = async (event) => {
event.preventDefault();
this.setState({ loading: true });
try {
// Convert photos to base64
const photosBase64 = await this.convertPhotosToBase64(this.state.photos);
// Prepare data for API emission
const ratingData = {
type: 'article_rating',
productId: this.props.productId,
productName: this.props.productName,
name: this.state.name,
email: this.state.email,
rating: this.state.rating,
review: this.state.review,
photos: photosBase64,
timestamp: new Date().toISOString()
};
// Emit data via socket
console.log('Article Rating Data to emit:', ratingData);
window.socketManager.emit('article_rating_submit', ratingData);
// Set up response handler
window.socketManager.once('article_rating_response', (response) => {
if (response.success) {
this.setState({
loading: false,
success: true,
name: '',
email: '',
rating: 0,
review: '',
photos: []
});
// Reset the photo upload component
if (this.photoUploadRef.current) {
this.photoUploadRef.current.reset();
}
} else {
this.setState({
loading: false,
error: response.error || '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);

View File

@@ -8,7 +8,6 @@ import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableRow from '@mui/material/TableRow';
import CartItem from './CartItem.js';
import { withI18n } from '../i18n/withTranslation.js';
class CartDropdown extends Component {
@@ -54,8 +53,8 @@ class CartDropdown extends Component {
currency: 'EUR'
});
const shippingNetPrice = deliveryCost > 0 ? deliveryCost / (1 + 19 / 100) : 0;
const shippingVat = deliveryCost > 0 ? deliveryCost - shippingNetPrice : 0;
const shippingNetPrice = deliveryCost / (1 + 19 / 100);
const shippingVat = deliveryCost - shippingNetPrice;
const totalVat7 = priceCalculations.vat7;
const totalVat19 = priceCalculations.vat19 + shippingVat;
const totalGross = priceCalculations.totalGross + deliveryCost;
@@ -64,7 +63,7 @@ class CartDropdown extends Component {
<>
<Box sx={{ bgcolor: 'primary.main', color: 'white', p: 2 }}>
<Typography variant="h6">
{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')}
{cartItems.length} {cartItems.length === 1 ? 'Produkt' : 'Produkte'}
</Typography>
</Box>
@@ -74,6 +73,7 @@ class CartDropdown extends Component {
{cartItems.map((item) => (
<CartItem
key={item.id}
socket={this.props.socket}
item={item}
id={item.id}
/>
@@ -83,7 +83,7 @@ class CartDropdown extends Component {
{/* Display total weight if greater than 0 */}
{totalWeight > 0 && (
<Typography variant="subtitle2" sx={{ px: 2, mb: 1 }}>
{this.props.t ? this.props.t('cart.summary.totalWeight', { weight: totalWeight.toFixed(2) }) : `Gesamtgewicht: ${totalWeight.toFixed(2)} kg`}
Gesamtgewicht: {totalWeight.toFixed(2)} kg
</Typography>
)}
@@ -94,7 +94,7 @@ class CartDropdown extends Component {
// Detailed summary with shipping costs
<>
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
{this.props.t ? this.props.t('cart.summary.title') : 'Bestellübersicht'}
Bestellübersicht
</Typography>
{deliveryMethod && (
<Typography variant="body2" sx={{ mb: 1, color: 'text.secondary' }}>
@@ -104,14 +104,14 @@ class CartDropdown extends Component {
<Table size="small">
<TableBody>
<TableRow>
<TableCell>{this.props.t ? this.props.t('cart.summary.goodsNet') : 'Waren (netto):'}</TableCell>
<TableCell>Waren (netto):</TableCell>
<TableCell align="right">
{currencyFormatter.format(priceCalculations.totalNet)}
</TableCell>
</TableRow>
{deliveryCost > 0 && (
<TableRow>
<TableCell>{this.props.t ? this.props.t('cart.summary.shippingNet') : 'Versandkosten (netto):'}</TableCell>
<TableCell>Versandkosten (netto):</TableCell>
<TableCell align="right">
{currencyFormatter.format(shippingNetPrice)}
</TableCell>
@@ -119,7 +119,7 @@ class CartDropdown extends Component {
)}
{totalVat7 > 0 && (
<TableRow>
<TableCell>{this.props.t ? this.props.t('tax.vat7') : '7% Mehrwertsteuer'}:</TableCell>
<TableCell>7% Mehrwertsteuer:</TableCell>
<TableCell align="right">
{currencyFormatter.format(totalVat7)}
</TableCell>
@@ -127,37 +127,28 @@ class CartDropdown extends Component {
)}
{totalVat19 > 0 && (
<TableRow>
<TableCell>{this.props.t ? this.props.t('tax.vat19WithShipping') : '19% Mehrwertsteuer (inkl. Versand)'}:</TableCell>
<TableCell>19% Mehrwertsteuer (inkl. Versand):</TableCell>
<TableCell align="right">
{currencyFormatter.format(totalVat19)}
</TableCell>
</TableRow>
)}
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>{this.props.t ? this.props.t('cart.summary.totalGoods') : 'Gesamtsumme Waren:'}</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtsumme Waren:</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
{currencyFormatter.format(priceCalculations.totalGross)}
</TableCell>
</TableRow>
{deliveryCost > 0 && (
<TableRow>
<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 sx={{ fontWeight: 'bold' }}>Versandkosten:</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
{deliveryCost === 0 ? (
<span style={{ color: '#2e7d32' }}>{this.props.t ? this.props.t('cart.summary.free') : 'kostenlos'}</span>
) : (
currencyFormatter.format(deliveryCost)
)}
{currencyFormatter.format(deliveryCost)}
</TableCell>
</TableRow>
)}
<TableRow sx={{ borderTop: '1px solid #e0e0e0' }}>
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>{this.props.t ? this.props.t('cart.summary.total') : 'Gesamtsumme:'}</TableCell>
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>Gesamtsumme:</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
{currencyFormatter.format(totalGross)}
</TableCell>
@@ -170,14 +161,14 @@ class CartDropdown extends Component {
<Table size="small">
<TableBody>
<TableRow>
<TableCell>{this.props.t ? this.props.t('tax.totalNet') : 'Gesamtnettopreis'}:</TableCell>
<TableCell>Gesamtnettopreis:</TableCell>
<TableCell align="right">
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.totalNet)}
</TableCell>
</TableRow>
{priceCalculations.vat7 > 0 && (
<TableRow>
<TableCell>{this.props.t ? this.props.t('tax.vat7') : '7% Mehrwertsteuer'}:</TableCell>
<TableCell>7% Mehrwertsteuer:</TableCell>
<TableCell align="right">
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.vat7)}
</TableCell>
@@ -185,14 +176,14 @@ class CartDropdown extends Component {
)}
{priceCalculations.vat19 > 0 && (
<TableRow>
<TableCell>{this.props.t ? this.props.t('tax.vat19') : '19% Mehrwertsteuer'}:</TableCell>
<TableCell>19% Mehrwertsteuer:</TableCell>
<TableCell align="right">
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.vat19)}
</TableCell>
</TableRow>
)}
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>{this.props.t ? this.props.t('tax.totalGross') : 'Gesamtbruttopreis ohne Versand'}:</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtbruttopreis ohne Versand:</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.totalGross)}
</TableCell>
@@ -210,7 +201,7 @@ class CartDropdown extends Component {
fullWidth
onClick={onClose}
>
{this.props.t ? this.props.t('cart.continueShopping') : 'Weiter einkaufen'}
Weiter einkaufen
</Button>
)}
@@ -222,7 +213,7 @@ class CartDropdown extends Component {
sx={{ mt: 2 }}
onClick={onCheckout}
>
{this.props.t ? this.props.t('cart.proceedToCheckout') : 'Weiter zur Kasse'}
Weiter zur Kasse
</Button>
)}
</>
@@ -232,4 +223,4 @@ class CartDropdown extends Component {
}
}
export default withI18n()(CartDropdown);
export default CartDropdown;

View File

@@ -6,7 +6,6 @@ import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import { Link } from 'react-router-dom';
import AddToCartButton from './AddToCartButton.js';
import { withI18n } from '../i18n/withTranslation.js';
class CartItem extends Component {
@@ -20,8 +19,8 @@ class CartItem extends Component {
this.setState({image:window.tinyPicCache[picid],loading:false, error: false})
}else{
this.setState({image: null, loading: true, error: false});
window.socketManager.emit('getPic', { bildId:picid, size:'tiny' }, (res) => {
if(this.props.socket){
this.props.socket.emit('getPic', { bildId:picid, size:'tiny' }, (res) => {
if(res.success){
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
this.setState({image: window.tinyPicCache[picid], loading: false});
@@ -30,6 +29,7 @@ class CartItem extends Component {
}
}
}
}
handleIncrement = () => {
const { item, onQuantityChange } = this.props;
@@ -75,25 +75,11 @@ class CartItem extends Component {
component="div"
sx={{ fontWeight: 'bold', mb: 0.5 }}
>
{item.seoName ? (
<Link to={`/Artikel/${item.seoName}`} style={{ textDecoration: 'none', color: 'inherit' }}>
{item.name}
</Link>
) : (
item.name
)}
</Typography>
{item.komponenten && Array.isArray(item.komponenten) && (
<Box sx={{ ml: 2, mb: 1 }}>
{item.komponenten.map((comp, index) => (
<Typography key={index} variant="body2" color="text.secondary">
{comp.name}
</Typography>
))}
</Box>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1, mt: 1 }}>
<Typography
variant="body2"
@@ -130,7 +116,7 @@ class CartItem extends Component {
)}
{item.versandklasse && item.versandklasse != 'standard' && item.versandklasse != 'kostenlos' && (
<Typography variant="body2" color="warning.main" fontWeight="medium" component="div">
{item.versandklasse == 'nur Abholung' ? this.props.t('delivery.descriptions.pickupOnly') : item.versandklasse}
{item.versandklasse}
</Typography>
)}
{item.vat && (
@@ -140,9 +126,9 @@ class CartItem extends Component {
fontStyle="italic"
component="div"
>
{this.props.t ? this.props.t('product.inclShort') : 'inkl.'} {new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(
inkl. {new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(
(item.price * item.quantity) - ((item.price * item.quantity) / (1 + item.vat / 100))
)} {this.props.t ? this.props.t('product.vatShort') : 'MwSt.'} ({item.vat}%)
)} MwSt. ({item.vat}%)
</Typography>
)}
@@ -160,14 +146,11 @@ class CartItem extends Component {
display: "block"
}}
>
{this.props.id?.toString().endsWith("steckling") ?
(this.props.t ? this.props.t('delivery.times.cutting14Days') : "Lieferzeit: 14 Tage") :
item.available == 1 ?
(this.props.t ? this.props.t('delivery.times.standard2to3Days') : "Lieferzeit: 2-3 Tage") :
item.availableSupplier == 1 ?
(this.props.t ? this.props.t('delivery.times.supplier7to9Days') : "Lieferzeit: 7-9 Tage") : ""}
{this.props.id.toString().endsWith("steckling") ? "Lieferzeit: 14 Tage" :
item.available == 1 ? "Lieferzeit: 2-3 Tage" :
item.availableSupplier == 1 ? "Lieferzeit: 7-9 Tage" : ""}
</Typography>
<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}/>
<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}/>
</Box>
</Box>
</ListItem>
@@ -176,4 +159,4 @@ class CartItem extends Component {
}
}
export default withI18n()(CartItem);
export default CartItem;

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useContext } from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import { Link } from 'react-router-dom';
import SocketContext from '../contexts/SocketContext.js';
// @note SwashingtonCP font is now loaded globally via index.css
@@ -16,13 +16,13 @@ const CategoryBox = ({
name,
seoName,
bgcolor,
fontSize = '1.2rem',
fontSize = '0.8rem',
...props
}) => {
const [imageUrl, setImageUrl] = useState(null);
const [imageError, setImageError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const context = useContext(SocketContext);
useEffect(() => {
let objectUrl = null;
@@ -60,10 +60,11 @@ const CategoryBox = ({
return;
}
if (id && !isLoading) {
// If socket is available and connected, fetch the image
if (context && context.socket && context.socket.connected && id && !isLoading) {
setIsLoading(true);
window.socketManager.emit('getCategoryPic', { categoryId: id }, (response) => {
context.socket.emit('getCategoryPic', { categoryId: id }, (response) => {
setIsLoading(false);
if (response.success) {
@@ -118,7 +119,7 @@ const CategoryBox = ({
URL.revokeObjectURL(objectUrl);
}
};
}, [id, isLoading]);
}, [context, context?.socket?.connected, id, isLoading]);
return (
<Paper
@@ -185,7 +186,7 @@ const CategoryBox = ({
fontFamily: 'SwashingtonCP, "Times New Roman", Georgia, serif',
fontWeight: 'normal',
lineHeight: '1.2',
padding: '12px 8px'
padding: '0 8px'
}}>
{name}
</div>

View File

@@ -81,6 +81,18 @@ class ChatAssistant extends Component {
if (prevState.messages !== this.state.messages || prevState.isTyping !== this.state.isTyping) {
this.scrollToBottom();
}
// Handle socket connection changes
const wasConnected = prevProps.socket && prevProps.socket.connected;
const isNowConnected = this.props.socket && this.props.socket.connected;
if (!wasConnected && isNowConnected) {
// Socket just connected, add listeners
this.addSocketListeners();
} else if (wasConnected && !isNowConnected) {
// Socket just disconnected, remove listeners
this.removeSocketListeners();
}
}
componentWillUnmount() {
@@ -92,18 +104,19 @@ class ChatAssistant extends Component {
}
addSocketListeners = () => {
if (this.props.socket && this.props.socket.connected) {
// Remove existing listeners first to avoid duplicates
this.removeSocketListeners();
window.socketManager.on('aiassyResponse', this.handleBotResponse);
window.socketManager.on('aiassyStatus', this.handleStateResponse);
this.props.socket.on('aiassyResponse', this.handleBotResponse);
this.props.socket.on('aiassyStatus', this.handleStateResponse);
}
}
removeSocketListeners = () => {
window.socketManager.off('aiassyResponse', this.handleBotResponse);
window.socketManager.off('aiassyStatus', this.handleStateResponse);
if (this.props.socket) {
this.props.socket.off('aiassyResponse', this.handleBotResponse);
this.props.socket.off('aiassyStatus', this.handleStateResponse);
}
}
handleBotResponse = (msgId,response) => {
@@ -181,8 +194,8 @@ class ChatAssistant extends Component {
};
}, () => {
// Emit message to socket server after state is updated
if (userMessage.trim()) {
window.socketManager.emit('aiassyMessage', userMessage);
if (userMessage.trim() && this.props.socket && this.props.socket.connected) {
this.props.socket.emit('aiassyMessage', userMessage);
}
});
}
@@ -287,10 +300,12 @@ class ChatAssistant extends Component {
reader.onloadend = () => {
const base64Audio = reader.result.split(',')[1];
// Send audio data to server
window.socketManager.emit('aiassyAudioMessage', {
if (this.props.socket && this.props.socket.connected) {
this.props.socket.emit('aiassyAudioMessage', {
audio: base64Audio,
format: 'wav'
});
}
};
};
@@ -374,12 +389,12 @@ class ChatAssistant extends Component {
reader.onloadend = () => {
const base64Image = reader.result.split(',')[1];
// Send image data to server
window.socketManager.emit('aiassyPicMessage', {
if (this.props.socket && this.props.socket.connected) {
this.props.socket.emit('aiassyPicMessage', {
image: base64Image,
format: 'jpeg'
});
}
};
};
@@ -465,16 +480,16 @@ class ChatAssistant extends Component {
elevation={4}
sx={{
position: 'fixed',
bottom: { xs: 0, sm: 80 },
right: { xs: 0, sm: 16 },
left: { xs: 0, sm: 'auto' },
top: { xs: 0, sm: 'auto' },
width: { xs: '100vw', sm: 450, md: 600, lg: 750 },
height: { xs: '100vh', sm: 600, md: 650, lg: 700 },
bottom: { xs: 16, sm: 80 },
right: { xs: 16, sm: 16 },
left: { xs: 16, sm: 'auto' },
top: { xs: 16, sm: 'auto' },
width: { xs: 'calc(100vw - 32px)', sm: 450, md: 600, lg: 750 },
height: { xs: 'calc(100vh - 32px)', sm: 600, md: 650, lg: 700 },
maxWidth: { xs: 'none', sm: 450, md: 600, lg: 750 },
maxHeight: { xs: '100vh', sm: 600, md: 650, lg: 700 },
maxHeight: { xs: 'calc(100vh - 72px)', sm: 600, md: 650, lg: 700 },
bgcolor: 'background.paper',
borderRadius: { xs: 0, sm: 2 },
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
zIndex: 1300,
@@ -503,7 +518,7 @@ class ChatAssistant extends Component {
<Typography component="span" color={this.state.atDatabase ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🛢</Typography>
<Typography component="span" color={this.state.atWeb ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🌐</Typography>
</Typography>
<IconButton onClick={onClose} size="small" aria-label="Assistent schließen" sx={{ color: 'primary.contrastText' }}>
<IconButton onClick={onClose} size="small" sx={{ color: 'primary.contrastText' }}>
<CloseIcon />
</IconButton>
</Box>
@@ -566,8 +581,6 @@ class ChatAssistant extends Component {
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: { xs: 1, sm: 0 },
p: 1,
borderTop: 1,
borderColor: 'divider',
@@ -606,13 +619,11 @@ class ChatAssistant extends Component {
}}
/>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
{isRecording ? (
<IconButton
color="error"
onClick={this.stopRecording}
aria-label="Aufnahme stoppen"
sx={{ ml: { xs: 0, sm: 1 } }}
sx={{ ml: 1 }}
>
<StopIcon />
</IconButton>
@@ -620,8 +631,7 @@ class ChatAssistant extends Component {
<IconButton
color="primary"
onClick={this.startRecording}
aria-label="Sprachaufnahme starten"
sx={{ ml: { xs: 0, sm: 1 } }}
sx={{ ml: 1 }}
disabled={isTyping || inputsDisabled}
>
<MicIcon />
@@ -631,8 +641,7 @@ class ChatAssistant extends Component {
<IconButton
color="primary"
onClick={this.handleImageUpload}
aria-label="Bild hochladen"
sx={{ ml: { xs: 0, sm: 1 } }}
sx={{ ml: 1 }}
disabled={isTyping || isRecording || inputsDisabled}
>
<PhotoCameraIcon />
@@ -640,14 +649,13 @@ class ChatAssistant extends Component {
<Button
variant="contained"
sx={{ ml: { xs: 0, sm: 1 } }}
sx={{ ml: 1 }}
onClick={this.handleSendMessage}
disabled={isTyping || isRecording || inputsDisabled}
>
Senden
</Button>
</Box>
</Box>
</Paper>
);
}

View File

@@ -13,7 +13,6 @@ import CategoryBox from './CategoryBox.js';
import { useParams, useSearchParams } from 'react-router-dom';
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);
@@ -27,13 +26,13 @@ const withRouter = (ClassComponent) => {
};
};
function getCachedCategoryData(categoryId, language = 'de') {
function getCachedCategoryData(categoryId) {
if (!window.productCache) {
window.productCache = {};
}
try {
const cacheKey = `categoryProducts_${categoryId}_${language}`;
const cacheKey = `categoryProducts_${categoryId}`;
const cachedData = window.productCache[cacheKey];
if (cachedData) {
@@ -53,7 +52,7 @@ function getCachedCategoryData(categoryId, language = 'de') {
function getFilteredProducts(unfilteredProducts, attributes, t) {
function getFilteredProducts(unfilteredProducts, attributes) {
const attributeSettings = getAllSettingsWithPrefix('filter_attribute_');
const manufacturerSettings = getAllSettingsWithPrefix('filter_manufacturer_');
const availabilitySettings = getAllSettingsWithPrefix('filter_availability_');
@@ -150,22 +149,22 @@ function getFilteredProducts(unfilteredProducts, attributes, t) {
// Check for "auf Lager" filter (in stock) - it's active when filter_availability is NOT set to '1'
if (availabilityFilter !== '1') {
activeAvailabilityFilters.push({id: '1', name: t ? t('product.inStock') : 'auf Lager'});
activeAvailabilityFilters.push({id: '1', name: 'auf Lager'});
}
// Check for "Neu" filter (new) - only show if there are actually new products and filter is active
if (availabilityFilters.includes('2') && hasNewProducts) {
activeAvailabilityFilters.push({id: '2', name: t ? t('product.new') : 'Neu'});
activeAvailabilityFilters.push({id: '2', name: 'Neu'});
}
// Check for "Bald verfügbar" filter (coming soon) - only show if there are actually coming soon products and filter is active
if (availabilityFilters.includes('3') && hasComingSoonProducts) {
activeAvailabilityFilters.push({id: '3', name: t ? t('product.comingSoon') : 'Bald verfügbar'});
activeAvailabilityFilters.push({id: '3', name: 'Bald verfügbar'});
}
return {filteredProducts,activeAttributeFilters:activeAttributeFiltersWithNames,activeManufacturerFilters:activeManufacturerFiltersWithNames,activeAvailabilityFilters};
}
function setCachedCategoryData(categoryId, data, language = 'de') {
function setCachedCategoryData(categoryId, data) {
if (!window.productCache) {
window.productCache = {};
}
@@ -174,10 +173,9 @@ function setCachedCategoryData(categoryId, data, language = 'de') {
}
try {
const cacheKey = `categoryProducts_${categoryId}_${language}`;
const cacheKey = `categoryProducts_${categoryId}`;
if(data.products) for(const product of data.products) {
const productCacheKey = `product_${product.id}_${language}`;
window.productDetailCache[productCacheKey] = product;
window.productDetailCache[product.id] = product;
}
window.productCache[cacheKey] = {
...data,
@@ -198,76 +196,44 @@ class Content extends Component {
unfilteredProducts: [],
filteredProducts: [],
attributes: [],
childCategories: [],
lastFetchedLanguage: props.i18n?.language || 'de'
childCategories: []
};
}
componentDidMount() {
const currentLanguage = this.props.i18n?.language || 'de';
if(this.props.params.categoryId) {this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage}, () => {
if(this.props.params.categoryId) {this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
this.fetchCategoryData(this.props.params.categoryId);
})}
else if (this.props.searchParams?.get('q')) {
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage}, () => {
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
this.fetchSearchData(this.props.searchParams?.get('q'));
})
}
}
componentDidUpdate(prevProps) {
const currentLanguage = this.props.i18n?.language || 'de';
const categoryChanged = this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId);
const searchChanged = this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q'));
if(categoryChanged) {
if(this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId)) {
window.currentSearchQuery = null;
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage}, () => {
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
this.fetchCategoryData(this.props.params.categoryId);
});
return; // Don't check language change if category changed
}
else if (searchChanged) {
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: [], lastFetchedLanguage: currentLanguage}, () => {
else if (this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q'))) {
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
this.fetchSearchData(this.props.searchParams?.get('q'));
});
return; // Don't check language change if search changed
})
}
// Re-fetch products when language changes to get translated content
const languageChanged = currentLanguage !== this.state.lastFetchedLanguage;
// Handle socket connection changes
const wasConnected = prevProps.socket && prevProps.socket.connected;
const isNowConnected = this.props.socket && this.props.socket.connected;
console.log('Content componentDidUpdate:', {
languageChanged,
lastFetchedLang: this.state.lastFetchedLanguage,
currentLang: currentLanguage,
prevPropsLang: prevProps.i18n?.language,
hasCategoryId: !!this.props.params.categoryId,
categoryId: this.props.params.categoryId,
hasSearchQuery: !!this.props.searchParams?.get('q')
});
if(languageChanged) {
console.log('Content: Language changed! Re-fetching data...');
// Re-fetch current data with new language
// Note: Language is now part of the cache key, so it will automatically fetch fresh data
if(this.props.params.categoryId) {
// Re-fetch category data with new language
console.log('Content: Re-fetching category', this.props.params.categoryId);
this.setState({loaded: false, lastFetchedLanguage: currentLanguage}, () => {
if (!wasConnected && isNowConnected && !this.state.loaded) {
// Socket just connected and we haven't loaded data yet, retry loading
if (this.props.params.categoryId) {
this.fetchCategoryData(this.props.params.categoryId);
});
} else if(this.props.searchParams?.get('q')) {
// Re-fetch search data with new language
console.log('Content: Re-fetching search', this.props.searchParams?.get('q'));
this.setState({loaded: false, lastFetchedLanguage: currentLanguage}, () => {
} else if (this.props.searchParams?.get('q')) {
this.fetchSearchData(this.props.searchParams?.get('q'));
});
} else {
// If not viewing category or search, just re-filter existing products
console.log('Content: Just re-filtering existing products');
this.setState({lastFetchedLanguage: currentLanguage});
this.filterProducts();
}
}
}
@@ -290,8 +256,7 @@ class Content extends Component {
unfilteredProducts: unfilteredProducts,
...getFilteredProducts(
unfilteredProducts,
response.attributes,
this.props.t
response.attributes
),
categoryName: response.categoryName || response.name || null,
dataType: response.dataType,
@@ -305,23 +270,28 @@ class Content extends Component {
fetchCategoryData(categoryId) {
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
const cachedData = getCachedCategoryData(categoryId, currentLanguage);
const cachedData = getCachedCategoryData(categoryId);
if (cachedData) {
this.processDataWithCategoryTree(cachedData, categoryId);
return;
}
if (!this.props.socket || !this.props.socket.connected) {
// Socket not connected yet, but don't show error immediately on first load
// The componentDidUpdate will retry when socket connects
console.log("Socket not connected yet, waiting for connection to fetch category data");
return;
}
console.log(`productList:${categoryId}`);
window.socketManager.off(`productList:${categoryId}`);
this.props.socket.off(`productList:${categoryId}`);
// Track if we've received the full response to ignore stub response if needed
let receivedFullResponse = false;
window.socketManager.on(`productList:${categoryId}`,(response) => {
this.props.socket.on(`productList:${categoryId}`,(response) => {
console.log("getCategoryProducts full response", response);
receivedFullResponse = true;
setCachedCategoryData(categoryId, response, currentLanguage);
setCachedCategoryData(categoryId, response);
if (response && response.products !== undefined) {
this.processDataWithCategoryTree(response, categoryId);
} else {
@@ -329,14 +299,12 @@ class Content extends Component {
}
});
window.socketManager.emit(
"getCategoryProducts",
{ categoryId: categoryId, language: currentLanguage, requestTranslation: currentLanguage === 'de' ? false : true },
this.props.socket.emit("getCategoryProducts", { categoryId: categoryId },
(response) => {
console.log("getCategoryProducts stub response", response);
// Only process stub response if we haven't received the full response yet
if (!receivedFullResponse) {
setCachedCategoryData(categoryId, response, currentLanguage);
setCachedCategoryData(categoryId, response);
if (response && response.products !== undefined) {
this.processDataWithCategoryTree(response, categoryId);
} else {
@@ -350,17 +318,15 @@ class Content extends Component {
}
processDataWithCategoryTree(response, categoryId) {
console.log("---------------processDataWithCategoryTree", response, categoryId);
// Get child categories from the cached category tree
let childCategories = [];
try {
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
if (categoryTreeCache) {
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
if (categoryTreeCache && categoryTreeCache.categoryTree) {
// If categoryId is a string (SEO name), find by seoName, otherwise by ID
const targetCategory = typeof categoryId === 'string'
? this.findCategoryBySeoName(categoryTreeCache, categoryId)
: this.findCategoryById(categoryTreeCache, categoryId);
? this.findCategoryBySeoName(categoryTreeCache.categoryTree, categoryId)
: this.findCategoryById(categoryTreeCache.categoryTree, categoryId);
if (targetCategory && targetCategory.children) {
childCategories = targetCategory.children;
@@ -397,10 +363,14 @@ class Content extends Component {
}
fetchSearchData(query) {
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
window.socketManager.emit(
"getSearchProducts",
{ query, language: currentLanguage, requestTranslation: currentLanguage === 'de' ? false : true },
if (!this.props.socket || !this.props.socket.connected) {
// Socket not connected yet, but don't show error immediately on first load
// The componentDidUpdate will retry when socket connects
console.log("Socket not connected yet, waiting for connection to fetch search data");
return;
}
this.props.socket.emit("getSearchProducts", { query },
(response) => {
if (response && response.products) {
this.processData(response);
@@ -415,8 +385,7 @@ class Content extends Component {
this.setState({
...getFilteredProducts(
this.state.unfilteredProducts,
this.state.attributes,
this.props.t
this.state.attributes
)
});
}
@@ -444,11 +413,13 @@ class Content extends Component {
const seoName = this.props.params.categoryId;
// Get the category tree from cache
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
if (!categoryTreeCache || !categoryTreeCache.categoryTree) {
return null;
}
// Find the category by seoName
const category = this.findCategoryBySeoName(categoryTreeCache, seoName);
const category = this.findCategoryBySeoName(categoryTreeCache.categoryTree, seoName);
return category ? category.id : null;
}
@@ -457,11 +428,13 @@ class Content extends Component {
if (!currentCategoryId) return null;
// Get the category tree from cache
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
if (!categoryTreeCache || !categoryTreeCache.categoryTree) {
return null;
}
// Find the current category in the tree
const currentCategory = this.findCategoryById(categoryTreeCache, currentCategoryId);
const currentCategory = this.findCategoryById(categoryTreeCache.categoryTree, currentCategoryId);
if (!currentCategory) {
return null;
}
@@ -472,7 +445,7 @@ class Content extends Component {
}
// Find the parent category
const parentCategory = this.findCategoryById(categoryTreeCache, currentCategory.parentId);
const parentCategory = this.findCategoryById(categoryTreeCache.categoryTree, currentCategory.parentId);
if (!parentCategory) {
return null;
}
@@ -495,8 +468,6 @@ class Content extends Component {
this.state.unfilteredProducts.length === 0 &&
this.state.childCategories.length > 0;
console.log("showCategoryBoxes", showCategoryBoxes, this.state.unfilteredProducts.length, this.state.childCategories.length);
return (
<Container maxWidth="xl" sx={{ py: { xs: 0, sm: 2 }, px: { xs: 0, sm: 3 }, flexGrow: 1, display: 'grid', gridTemplateRows: '1fr' }}>
@@ -542,8 +513,7 @@ class Content extends Component {
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none'
justifyContent: 'center'
}}>
<KeyboardArrowUpIcon sx={{ color: 'white', fontSize: '1.2rem' }} />
</Box>
@@ -592,8 +562,7 @@ class Content extends Component {
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none'
justifyContent: 'center'
}}>
<KeyboardArrowUpIcon sx={{ color: 'white', fontSize: '1.2rem' }} />
</Box>
@@ -630,10 +599,10 @@ class Content extends Component {
/>
</Box>
{(this.props.params.categoryId == 'Stecklinge' || this.props.params.categoryId == 'Seeds') &&
{(this.getCurrentCategoryId() == 706 || this.getCurrentCategoryId() == 689) &&
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
<Typography variant="h6" sx={{mt:3}}>
{this.props.t ? this.props.t('navigation.otherCategories') : 'Andere Kategorien'}
Andere Kategorien
</Typography>
</Box>
}
@@ -662,26 +631,12 @@ class Content extends Component {
<Box sx={{
height: '100%',
bgcolor: '#e1f0d3',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
backgroundImage: 'url("/assets/images/seeds.jpg")',
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
position: 'relative'
}}>
<img
src="/assets/images/seeds.jpg"
alt="Seeds"
fetchPriority="high"
loading="eager"
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}}
/>
{/* Overlay text - optional */}
<Box sx={{
position: 'absolute',
@@ -692,7 +647,7 @@ class Content extends Component {
p: 2,
}}>
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
{this.props.t('sections.seeds')}
Seeds
</Typography>
</Box>
</Box>
@@ -723,26 +678,12 @@ class Content extends Component {
<Box sx={{
height: '100%',
bgcolor: '#e8f5d6',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
backgroundImage: 'url("/assets/images/cutlings.jpg")',
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
position: 'relative'
}}>
<img
src="/assets/images/cutlings.jpg"
alt="Stecklinge"
fetchPriority="high"
loading="eager"
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}}
/>
{/* Overlay text - optional */}
<Box sx={{
position: 'absolute',
@@ -753,7 +694,7 @@ class Content extends Component {
p: 2,
}}>
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
{this.props.t('sections.stecklinge')}
Stecklinge
</Typography>
</Box>
</Box>
@@ -762,6 +703,8 @@ class Content extends Component {
<Box>
<ProductList
socket={this.props.socket}
socketB={this.props.socketB}
totalProductCount={(this.state.unfilteredProducts || []).length}
products={this.state.filteredProducts || []}
activeAttributeFilters={this.state.activeAttributeFilters || []}
@@ -780,4 +723,4 @@ class Content extends Component {
}
}
export default withRouter(withI18n()(Content));
export default withRouter(Content);

View File

@@ -267,7 +267,7 @@ class Filter extends Component {
)}
</Typography>
{isXsScreen && (
<IconButton size="small" aria-label={isCollapsed ? "Filter erweitern" : "Filter einklappen"} sx={{ p: 0 }}>
<IconButton size="small" sx={{ p: 0 }}>
{isCollapsed ? <ExpandMoreIcon /> : <ExpandLessIcon />}
</IconButton>
)}

View File

@@ -6,7 +6,6 @@ import Link from '@mui/material/Link';
import { Link as RouterLink } from 'react-router-dom';
import { styled } from '@mui/material/styles';
import Paper from '@mui/material/Paper';
import { withI18n } from '../i18n/withTranslation.js';
// Styled component for the router links
const StyledRouterLink = styled(RouterLink)(() => ({
@@ -230,9 +229,9 @@ class Footer extends Component {
alignItems={{ xs: 'center', md: 'left' }}
flexWrap="wrap"
>
<StyledRouterLink to="/datenschutz">{this.props.t ? this.props.t('footer.legal.datenschutz') : 'Datenschutz'}</StyledRouterLink>
<StyledRouterLink to="/agb">{this.props.t ? this.props.t('footer.legal.agb') : 'AGB'}</StyledRouterLink>
<StyledRouterLink to="/sitemap">{this.props.t ? this.props.t('footer.legal.sitemap') : 'Sitemap'}</StyledRouterLink>
<StyledRouterLink to="/datenschutz">Datenschutz</StyledRouterLink>
<StyledRouterLink to="/agb">AGB</StyledRouterLink>
<StyledRouterLink to="/sitemap">Sitemap</StyledRouterLink>
</Stack>
<Stack
@@ -242,12 +241,12 @@ class Footer extends Component {
alignItems={{ xs: 'center', md: 'left' }}
flexWrap="wrap"
>
<StyledRouterLink to="/impressum">{this.props.t ? this.props.t('footer.legal.impressum') : 'Impressum'}</StyledRouterLink>
<StyledRouterLink to="/batteriegesetzhinweise">{this.props.t ? this.props.t('footer.legal.batteriegesetzhinweise') : 'Batteriegesetzhinweise'}</StyledRouterLink>
<StyledRouterLink to="/widerrufsrecht">{this.props.t ? this.props.t('footer.legal.widerrufsrecht') : 'Widerrufsrecht'}</StyledRouterLink>
<StyledRouterLink to="/impressum">Impressum</StyledRouterLink>
<StyledRouterLink to="/batteriegesetzhinweise">Batteriegesetzhinweise</StyledRouterLink>
<StyledRouterLink to="/widerrufsrecht">Widerrufsrecht</StyledRouterLink>
</Stack>
{/* Payment Methods Section
{/* Payment Methods Section */}
<Stack
direction="column"
spacing={1}
@@ -264,7 +263,7 @@ class Footer extends Component {
<Box component="img" src="/assets/images/cards.png" alt="Cash" sx={{ height: { xs: 80, md: 95 } }} />
</Stack>
</Stack>
*/}
{/* Google Services Badge Section */}
<Stack
direction="column"
@@ -275,9 +274,9 @@ class Footer extends Component {
<Stack
direction="row"
spacing={{ xs: 1, md: 2 }}
sx={{pt: '10px', height: { xs: 50, md: 60 }, transform: 'translateY(-3px)'}}
sx={{pb: '10px'}}
justifyContent="center"
alignItems="flex-end"
alignItems="center"
>
<Link
href="https://reviewthis.biz/growheads"
@@ -286,10 +285,7 @@ class Footer extends Component {
sx={{
textDecoration: 'none',
position: 'relative',
zIndex: 9999,
display: 'inline-block',
height: { xs: 57, md: 67 },
lineHeight: 1
zIndex: 9999
}}
onMouseEnter={this.handleReviewsMouseEnter}
onMouseLeave={this.handleReviewsMouseLeave}
@@ -300,7 +296,6 @@ class Footer extends Component {
alt="Google Reviews"
sx={{
height: { xs: 50, md: 60 },
width: { xs: 105, md: 126 },
cursor: 'pointer',
transition: 'all 2s ease',
'&:hover': {
@@ -316,10 +311,7 @@ class Footer extends Component {
sx={{
textDecoration: 'none',
position: 'relative',
zIndex: 9999,
display: 'inline-block',
height: { xs: 47, md: 67 },
lineHeight: 1
zIndex: 9999
}}
onMouseEnter={this.handleMapsMouseEnter}
onMouseLeave={this.handleMapsMouseLeave}
@@ -330,7 +322,6 @@ class Footer extends Component {
alt="Google Maps"
sx={{
height: { xs: 40, md: 50 },
width: { xs: 38, md: 49 },
cursor: 'pointer',
transition: 'all 2s ease',
filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))',
@@ -347,7 +338,7 @@ class Footer extends Component {
{/* Copyright Section */}
<Box sx={{ pb:'20px',textAlign: 'center', filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))', opacity: 0.7 }}>
<Typography variant="body2" sx={{ mb: 1, fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
{this.props.t ? this.props.t('footer.allPricesIncl') : '* Alle Preise inkl. gesetzlicher USt., zzgl. Versand'}
* Alle Preise inkl. gesetzlicher USt., zzgl. Versand
</Typography>
<Typography variant="body2" sx={{ fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
© {new Date().getFullYear()} <StyledDomainLink href="https://growheads.de" target="_blank" rel="noopener noreferrer">GrowHeads.de</StyledDomainLink>
@@ -360,4 +351,4 @@ class Footer extends Component {
}
}
export default withI18n()(Footer);
export default Footer;

View File

@@ -2,7 +2,6 @@ import React, { Component } from 'react';
import Button from '@mui/material/Button';
import GoogleIcon from '@mui/icons-material/Google';
import GoogleAuthContext from '../contexts/GoogleAuthContext.js';
// import { withI18n } from '../i18n/withTranslation.js'; // Temporarily commented out for debugging
class GoogleLoginButton extends Component {
static contextType = GoogleAuthContext;
@@ -187,20 +186,17 @@ class GoogleLoginButton extends Component {
};
render() {
const { disabled, style, className, text = 'Loading...'} = this.props;
const { disabled, style, className, text = 'Mit Google anmelden' } = this.props;
const { isInitializing, isPrompting } = this.state;
const isLoading = isInitializing || isPrompting || (this.context && !this.context.isLoaded);
return (
<Button
variant="contained"
startIcon={<GoogleIcon />}
onClick={this.handleClick}
disabled={disabled || isLoading}
fullWidth
style={{backgroundColor: '#4285F4', color: 'white', ...style }}
style={{ backgroundColor: '#4285F4', color: 'white', ...style }}
className={className}
>
{isLoading ? 'Loading...' : text}
@@ -209,4 +205,4 @@ class GoogleLoginButton extends Component {
}
}
export default GoogleLoginButton; // Temporarily removed withI18n for debugging
export default GoogleLoginButton;

View File

@@ -4,6 +4,7 @@ import Toolbar from '@mui/material/Toolbar';
import Container from '@mui/material/Container';
import Box from '@mui/material/Box';
import SocketContext from '../contexts/SocketContext.js';
import { useLocation } from 'react-router-dom';
// Import extracted components
@@ -11,6 +12,7 @@ import { Logo, SearchBar, ButtonGroupWithRouter, CategoryList } from './header/i
// Main Header Component
class Header extends Component {
static contextType = SocketContext;
constructor(props) {
super(props);
@@ -34,8 +36,9 @@ class Header extends Component {
};
render() {
const { isHomePage, isProfilePage, isAktionenPage, isFilialePage } = this.props;
// Get socket directly from context in render method
const {socket,socketB} = this.context;
const { isHomePage, isProfilePage } = this.props;
return (
<AppBar position="sticky" color="primary" elevation={0} sx={{ zIndex: 1100 }}>
@@ -72,7 +75,7 @@ class Header extends Component {
transform: { xs: 'translateY(4px) translateX(9px)', sm: 'none' },
ml: { xs: 0, sm: 0 }
}}>
<ButtonGroupWithRouter/>
<ButtonGroupWithRouter socket={socket}/>
</Box>
</Box>
@@ -91,7 +94,7 @@ class Header extends Component {
</Box>
</Container>
</Toolbar>
{(isHomePage || this.props.categoryId || isProfilePage || isAktionenPage || isFilialePage || this.props.isArtikel) && <CategoryList categoryId={209} activeCategoryId={this.props.categoryId}/>}
{(isHomePage || this.props.categoryId || isProfilePage) && <CategoryList categoryId={209} activeCategoryId={this.props.categoryId} socket={socket} socketB={socketB} />}
</AppBar>
);
}
@@ -102,14 +105,11 @@ const HeaderWithContext = (props) => {
const location = useLocation();
const isHomePage = location.pathname === '/';
const isProfilePage = location.pathname === '/profile';
const isAktionenPage = location.pathname === '/aktionen';
const isFilialePage = location.pathname === '/filiale';
const isArtikel = location.pathname.startsWith('/Artikel/');
return (
<Header {...props} isHomePage={isHomePage} isArtikel={isArtikel} isProfilePage={isProfilePage} isAktionenPage={isAktionenPage} isFilialePage={isFilialePage} />
<SocketContext.Consumer>
{({socket,socketB}) => <Header {...props} socket={socket} socketB={socketB} isHomePage={isHomePage} isProfilePage={isProfilePage} />}
</SocketContext.Consumer>
);
};

View File

@@ -12,7 +12,9 @@ import LoupeIcon from '@mui/icons-material/Loupe';
class Images extends Component {
constructor(props) {
super(props);
this.state = { mainPic:0,pics:[] };
this.state = { mainPic:0,pics:[]};
console.log('Images constructor',props);
}
componentDidMount () {
@@ -23,9 +25,6 @@ class Images extends Component {
this.updatePics();
}
}
componentWillUnmount() {
window.productImageUrl = null;
}
updatePics = (newMainPic = this.state.mainPic) => {
if (!window.tinyPicCache) window.tinyPicCache = {};
@@ -42,7 +41,6 @@ class Images extends Component {
for(const bildId of bildIds){
if(bildId == mainPicId){
if(window.productImageUrl) continue;
if(window.largePicCache[bildId]){
pics.push(window.largePicCache[bildId]);
@@ -53,10 +51,10 @@ class Images extends Component {
pics.push(window.smallPicCache[bildId]);
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
}else if(window.tinyPicCache[bildId]){
pics.push(window.tinyPicCache[bildId]);
pics.push(bildId);
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
}else{
pics.push(`/assets/images/prod${bildId}.jpg`);
pics.push(bildId);
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
}
}else{
@@ -71,8 +69,7 @@ class Images extends Component {
}
}
}
console.log('DEBUG: pics array contents:', pics);
console.log('DEBUG: pics array types:', pics.map(p => typeof p + ': ' + p));
console.log('pics',pics);
this.setState({ pics, mainPic: newMainPic });
}else{
if(this.state.pics.length > 0) this.setState({ pics:[], mainPic: newMainPic });
@@ -80,9 +77,7 @@ class Images extends Component {
}
loadPic = (size,bildId,index) => {
window.socketManager.emit('getPic', { bildId, size }, (res) => {
this.props.socketB.emit('getPic', { bildId, size }, (res) => {
if(res.success){
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
@@ -106,53 +101,27 @@ class Images extends Component {
}
render() {
// SPA version - full functionality with static fallback
const getImageSrc = () => {
if(window.productImageUrl) return window.productImageUrl;
// If dynamic image is loaded, use it
if (this.state.pics[this.state.mainPic]) {
return this.state.pics[this.state.mainPic];
}
// Otherwise, use static fallback (same as prerender)
if (!this.props.pictureList || !this.props.pictureList.trim()) {
return '/assets/images/nopicture.jpg';
}
return `/assets/images/prod${this.props.pictureList.split(',')[0].trim()}.jpg`;
};
return (
<>
<Box sx={{ position: 'relative', display: 'inline-block', width: '499px', maxWidth: '100%' }}>
{this.state.pics[this.state.mainPic] && (
<Box sx={{ position: 'relative', display: 'inline-block' }}>
<CardMedia
component="img"
height="400"
fetchPriority="high"
loading="eager"
alt={this.props.productName || 'Produktbild'}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = this.props.productName || 'Produktbild';
}
}}
sx={{
objectFit: 'contain',
cursor: 'pointer',
transition: 'transform 0.2s ease-in-out',
width: '499px',
maxWidth: '100%',
'&:hover': {
transform: 'scale(1.02)'
}
}}
image={getImageSrc()}
image={this.state.pics[this.state.mainPic]}
onClick={this.props.onOpenFullscreen}
/>
<IconButton
size="small"
disableRipple
aria-label="Zoom-Symbol"
sx={{
position: 'absolute',
top: 8,
@@ -168,6 +137,7 @@ class Images extends Component {
<LoupeIcon fontSize="small" />
</IconButton>
</Box>
)}
<Stack direction="row" spacing={2} sx={{ justifyContent: 'flex-start', mt: 1,mb: 1 }}>
{this.state.pics.filter(pic => pic !== null && pic !== this.state.pics[this.state.mainPic]).map((pic, filterIndex) => {
// Find the original index in the full pics array
@@ -199,13 +169,6 @@ class Images extends Component {
<CardMedia
component="img"
height="80"
alt={`${this.props.productName || 'Produktbild'} - Bild ${originalIndex + 1}`}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = `${this.props.productName || 'Produktbild'} - Bild ${originalIndex + 1}`;
}
}}
sx={{
objectFit: 'contain',
cursor: 'pointer',
@@ -260,7 +223,6 @@ class Images extends Component {
{/* Close Button */}
<IconButton
onClick={this.props.onCloseFullscreen}
aria-label="Vollbild schließen"
sx={{
position: 'absolute',
top: 16,
@@ -279,13 +241,6 @@ class Images extends Component {
{this.state.pics[this.state.mainPic] && (
<CardMedia
component="img"
alt={this.props.productName || 'Produktbild'}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = this.props.productName || 'Produktbild';
}
}}
sx={{
objectFit: 'contain',
width: '90vw',
@@ -339,13 +294,6 @@ class Images extends Component {
<CardMedia
component="img"
height="60"
alt={`${this.props.productName || 'Produktbild'} - Miniaturansicht ${originalIndex + 1}`}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = `${this.props.productName || 'Produktbild'} - Miniaturansicht ${originalIndex + 1}`;
}
}}
sx={{
objectFit: 'contain',
cursor: 'pointer',

View File

@@ -1,278 +0,0 @@
import React, { Component } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Typography from '@mui/material/Typography';
import { withI18n } from '../i18n/withTranslation.js';
class LanguageSwitcher extends Component {
constructor(props) {
super(props);
this.state = {
anchorEl: null,
loadedFlags: {}
};
}
handleClick = (event) => {
this.setState({ anchorEl: event.currentTarget });
};
handleClose = () => {
this.setState({ anchorEl: null });
};
handleLanguageChange = async (language) => {
const { languageContext } = this.props;
if (languageContext) {
try {
await languageContext.changeLanguage(language);
} catch (error) {
console.error('Failed to change language:', error);
}
}
this.handleClose();
};
// Lazy load flag components
loadFlagComponent = async (lang) => {
if (this.state.loadedFlags[lang]) {
return this.state.loadedFlags[lang];
}
try {
const flagMap = {
'ar': () => import('country-flag-icons/react/3x2').then(m => m.EG),
'bg': () => import('country-flag-icons/react/3x2').then(m => m.BG),
'cs': () => import('country-flag-icons/react/3x2').then(m => m.CZ),
'de': () => import('country-flag-icons/react/3x2').then(m => m.DE),
'el': () => import('country-flag-icons/react/3x2').then(m => m.GR),
'en': () => import('country-flag-icons/react/3x2').then(m => m.US),
'es': () => import('country-flag-icons/react/3x2').then(m => m.ES),
'fr': () => import('country-flag-icons/react/3x2').then(m => m.FR),
'hr': () => import('country-flag-icons/react/3x2').then(m => m.HR),
'hu': () => import('country-flag-icons/react/3x2').then(m => m.HU),
'it': () => import('country-flag-icons/react/3x2').then(m => m.IT),
'pl': () => import('country-flag-icons/react/3x2').then(m => m.PL),
'ro': () => import('country-flag-icons/react/3x2').then(m => m.RO),
'ru': () => import('country-flag-icons/react/3x2').then(m => m.RU),
'sk': () => import('country-flag-icons/react/3x2').then(m => m.SK),
'sl': () => import('country-flag-icons/react/3x2').then(m => m.SI),
'sq': () => import('country-flag-icons/react/3x2').then(m => m.AL),
'sr': () => import('country-flag-icons/react/3x2').then(m => m.RS),
'sv': () => import('country-flag-icons/react/3x2').then(m => m.SE),
'tr': () => import('country-flag-icons/react/3x2').then(m => m.TR),
'uk': () => import('country-flag-icons/react/3x2').then(m => m.UA),
'zh': () => import('country-flag-icons/react/3x2').then(m => m.CN)
};
const flagLoader = flagMap[lang];
if (flagLoader) {
const FlagComponent = await flagLoader();
this.setState(prevState => ({
loadedFlags: {
...prevState.loadedFlags,
[lang]: FlagComponent
}
}));
return FlagComponent;
}
} catch (error) {
console.warn(`Failed to load flag for language: ${lang}`, error);
}
return null;
};
getLanguageFlag = (lang) => {
const FlagComponent = this.state.loadedFlags[lang];
if (FlagComponent) {
return (
<FlagComponent
style={{
width: '20px',
height: '14px',
borderRadius: '2px',
border: '1px solid #ddd'
}}
/>
);
}
// Loading placeholder or fallback
return (
<Box
component="span"
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '20px',
height: '14px',
backgroundColor: '#f5f5f5',
color: '#666',
fontSize: '8px',
fontWeight: 'bold',
borderRadius: '2px',
fontFamily: 'monospace',
border: '1px solid #ddd'
}}
>
{this.getLanguageLabel(lang)}
</Box>
);
};
// Load flags when menu opens
componentDidUpdate(prevProps, prevState) {
const { anchorEl } = this.state;
const { languageContext } = this.props;
if (anchorEl && !prevState.anchorEl && languageContext) {
// Menu just opened, lazy load flags for all languages (not just available ones)
languageContext.allLanguages.forEach(lang => {
if (!this.state.loadedFlags[lang]) {
this.loadFlagComponent(lang);
}
});
}
}
getLanguageLabel = (lang) => {
const labels = {
'ar': 'EG',
'bg': 'BG',
'cs': 'CZ',
'de': 'DE',
'el': 'GR',
'en': 'US',
'es': 'ES',
'fr': 'FR',
'hr': 'HR',
'hu': 'HU',
'it': 'IT',
'pl': 'PL',
'ro': 'RO',
'ru': 'RU',
'sk': 'SK',
'sl': 'SI',
'sq': 'AL',
'sr': 'RS',
'sv': 'SE',
'tr': 'TR',
'uk': 'UA',
'zh': 'CN'
};
return labels[lang] || lang.toUpperCase();
};
getLanguageName = (lang) => {
const names = {
'ar': 'العربية',
'bg': 'Български',
'cs': 'Čeština',
'de': 'Deutsch',
'el': 'Ελληνικά',
'en': 'English',
'es': 'Español',
'fr': 'Français',
'hr': 'Hrvatski',
'hu': 'Magyar',
'it': 'Italiano',
'pl': 'Polski',
'ro': 'Română',
'ru': 'Русский',
'sk': 'Slovenčina',
'sl': 'Slovenščina',
'sq': 'Shqip',
'sr': 'Српски',
'sv': 'Svenska',
'tr': 'Türkçe',
'uk': 'Українська',
'zh': '中文'
};
return names[lang] || lang;
};
render() {
const { languageContext } = this.props;
const { anchorEl } = this.state;
if (!languageContext) {
return null;
}
const { currentLanguage, allLanguages } = languageContext;
const open = Boolean(anchorEl);
return (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Button
aria-controls={open ? 'language-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
onClick={this.handleClick}
color="inherit"
size="small"
sx={{
my: 1,
mx: 0.5,
minWidth: 'auto',
textTransform: 'none',
fontSize: '0.875rem'
}}
>
{this.getLanguageLabel(currentLanguage)}
</Button>
<Menu
id="language-menu"
anchorEl={anchorEl}
open={open}
onClose={this.handleClose}
disableScrollLock={true}
MenuListProps={{
'aria-labelledby': 'language-button',
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
{allLanguages.map((language) => {
return (
<MenuItem
key={language}
onClick={() => this.handleLanguageChange(language)}
selected={language === currentLanguage}
sx={{
minWidth: 160,
display: 'flex',
justifyContent: 'space-between',
gap: 2
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{this.getLanguageFlag(language)}
<Typography variant="body2">
{this.getLanguageName(language)}
</Typography>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
{this.getLanguageLabel(language)}
</Typography>
</MenuItem>
);
})}
</Menu>
</Box>
);
}
}
export default withI18n()(LanguageSwitcher);

View File

@@ -22,8 +22,6 @@ import GoogleLoginButton from './GoogleLoginButton.js';
import CartSyncDialog from './CartSyncDialog.js';
import { localAndArchiveServer, mergeCarts } from '../utils/cartUtils.js';
import config from '../config.js';
import { withI18n } from '../i18n/withTranslation.js';
import GoogleIcon from '@mui/icons-material/Google';
// Lazy load GoogleAuthProvider
const GoogleAuthProvider = lazy(() => import('../providers/GoogleAuthProvider.js'));
@@ -172,7 +170,7 @@ export class LoginComponent extends Component {
handleLogin = () => {
const { email, password } = this.state;
const { location, navigate } = this.props;
const { socket, location, navigate } = this.props;
if (!email || !password) {
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
@@ -186,8 +184,16 @@ export class LoginComponent extends Component {
this.setState({ loading: true, error: '' });
// Call verifyUser socket endpoint
if (!socket || !socket.connected) {
this.setState({
loading: false,
error: 'Verbindung zum Server verloren. Bitte versuchen Sie es erneut.'
});
return;
}
window.socketManager.emit('verifyUser', { email, password }, (response) => {
socket.emit('verifyUser', { email, password }, (response) => {
console.log('LoginComponent: verifyUser', response);
if (response.success) {
sessionStorage.setItem('user', JSON.stringify(response.user));
@@ -209,9 +215,9 @@ export class LoginComponent extends Component {
const serverCartArr = newCart ? Object.values(newCart) : [];
if (serverCartArr.length === 0) {
window.socketManager.emit('updateCart', window.cart);
if (socket && socket.connected) {
socket.emit('updateCart', window.cart);
}
this.handleClose();
dispatchLoginEvent();
} else if (localCartArr.length === 0 && serverCartArr.length > 0) {
@@ -246,6 +252,7 @@ export class LoginComponent extends Component {
handleRegister = () => {
const { email, password, confirmPassword } = this.state;
const { socket } = this.props;
if (!email || !password || !confirmPassword) {
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
@@ -269,8 +276,16 @@ export class LoginComponent extends Component {
this.setState({ loading: true, error: '' });
// Call createUser socket endpoint
if (!socket || !socket.connected) {
this.setState({
loading: false,
error: 'Verbindung zum Server verloren. Bitte versuchen Sie es erneut.'
});
return;
}
window.socketManager.emit('createUser', { email, password }, (response) => {
socket.emit('createUser', { email, password }, (response) => {
if (response.success) {
this.setState({
loading: false,
@@ -278,17 +293,9 @@ export class LoginComponent extends Component {
tabValue: 0 // Switch to login tab
});
} else {
let errorMessage = 'Registrierung fehlgeschlagen';
if (response.cause === 'emailExists') {
errorMessage = 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits. Bitte verwenden Sie eine andere E-Mail-Adresse oder melden Sie sich an.';
} else if (response.message) {
errorMessage = response.message;
}
this.setState({
loading: false,
error: errorMessage
error: response.message || 'Registrierung fehlgeschlagen'
});
}
});
@@ -303,7 +310,22 @@ export class LoginComponent extends Component {
};
handleLogout = () => {
window.socketManager.emit('logout', (response) => {
if (!this.props.socket || !this.props.socket.connected) {
// If socket is not connected, just clear local storage
sessionStorage.removeItem('user');
window.cart = [];
window.dispatchEvent(new CustomEvent('cart'));
window.dispatchEvent(new CustomEvent('userLoggedOut'));
this.setState({
isLoggedIn: false,
user: null,
isAdmin: false,
anchorEl: null
});
return;
}
this.props.socket.emit('logout', (response) => {
if(response.success){
sessionStorage.removeItem('user');
window.dispatchEvent(new CustomEvent('userLoggedIn'));
@@ -320,6 +342,7 @@ export class LoginComponent extends Component {
handleForgotPassword = () => {
const { email } = this.state;
const { socket } = this.props;
if (!email) {
this.setState({ error: 'Bitte geben Sie Ihre E-Mail-Adresse ein' });
@@ -333,8 +356,8 @@ export class LoginComponent extends Component {
this.setState({ loading: true, error: '' });
window.socketManager.emit('resetPassword', {
// Call resetPassword socket endpoint
socket.emit('resetPassword', {
email,
domain: window.location.origin
}, (response) => {
@@ -355,11 +378,13 @@ export class LoginComponent extends Component {
// Google login functionality
handleGoogleLoginSuccess = (credentialResponse) => {
const { location, navigate } = this.props;
const { socket, location, navigate } = this.props;
this.setState({ loading: true, error: '' });
console.log('beforeG',credentialResponse)
window.socketManager.emit('verifyGoogleUser', { credential: credentialResponse.credential }, (response) => {
socket.emit('verifyGoogleUser', { credential: credentialResponse.credential }, (response) => {
console.log('google respo',response);
if (response.success) {
sessionStorage.setItem('user', JSON.stringify(response.user));
@@ -381,7 +406,7 @@ export class LoginComponent extends Component {
const serverCartArr = newCart ? Object.values(newCart) : [];
if (serverCartArr.length === 0) {
window.socketManager.emit('updateCart', window.cart);
socket.emit('updateCart', window.cart);
this.handleClose();
dispatchLoginEvent();
} else if (localCartArr.length === 0 && serverCartArr.length > 0) {
@@ -431,7 +456,7 @@ export class LoginComponent extends Component {
localAndArchiveServer(localCartSync, serverCartSync);
break;
case 'deleteServer':
window.socketManager.emit('updateCart', window.cart)
this.props.socket.emit('updateCart', window.cart)
break;
case 'useServer':
window.cart = serverCartSync;
@@ -485,7 +510,7 @@ export class LoginComponent extends Component {
color={isAdmin ? 'secondary' : 'inherit'}
sx={{ my: 1, mx: 1.5 }}
>
{this.props.t ? this.props.t('auth.profile') : 'Profil'}
Profil
</Button>
<Menu
disableScrollLock={true}
@@ -501,28 +526,14 @@ export class LoginComponent extends Component {
horizontal: 'right',
}}
>
<MenuItem component={Link} to="/profile" onClick={this.handleUserMenuClose}>
{this.props.t ? this.props.t('auth.menu.profile') : 'Profil'}
</MenuItem>
<MenuItem component={Link} to="/profile#cart" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>
{this.props.t ? this.props.t('auth.menu.checkout') : 'Bestellabschluss'}
</MenuItem>
<MenuItem component={Link} to="/profile#orders" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>
{this.props.t ? this.props.t('auth.menu.orders') : 'Bestellungen'}
</MenuItem>
<MenuItem component={Link} to="/profile#settings" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>
{this.props.t ? this.props.t('auth.menu.settings') : 'Einstellungen'}
</MenuItem>
<MenuItem component={Link} to="/profile" onClick={this.handleUserMenuClose}>Profil</MenuItem>
<MenuItem component={Link} to="/profile#cart" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Bestellabschluss</MenuItem>
<MenuItem component={Link} to="/profile#orders" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Bestellungen</MenuItem>
<MenuItem component={Link} to="/profile#settings" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Einstellungen</MenuItem>
<Divider />
{isAdmin ? <MenuItem component={Link} to="/admin" onClick={this.handleUserMenuClose}>
{this.props.t ? this.props.t('auth.menu.adminDashboard') : 'Admin Dashboard'}
</MenuItem> : null}
{isAdmin ? <MenuItem component={Link} to="/admin/users" onClick={this.handleUserMenuClose}>
{this.props.t ? this.props.t('auth.menu.adminUsers') : 'Admin Users'}
</MenuItem> : null}
<MenuItem onClick={this.handleLogout}>
{this.props.t ? this.props.t('auth.logout') : 'Abmelden'}
</MenuItem>
{isAdmin ? <MenuItem component={Link} to="/admin" onClick={this.handleUserMenuClose}>Admin Dashboard</MenuItem> : null}
{isAdmin ? <MenuItem component={Link} to="/admin/users" onClick={this.handleUserMenuClose}>Admin Users</MenuItem> : null}
<MenuItem onClick={this.handleLogout}>Abmelden</MenuItem>
</Menu>
</>
) : (
@@ -532,7 +543,7 @@ export class LoginComponent extends Component {
onClick={this.handleOpen}
sx={{ my: 1, mx: 1.5 }}
>
{this.props.t ? this.props.t('auth.login') : 'Login'}
Login
</Button>
)
)}
@@ -547,10 +558,7 @@ export class LoginComponent extends Component {
<DialogTitle sx={{ bgcolor: 'white', pb: 0 }}>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6" color="#2e7d32" fontWeight="bold">
{tabValue === 0 ?
(this.props.t ? this.props.t('auth.login') : 'Anmelden') :
(this.props.t ? this.props.t('auth.register') : 'Registrieren')
}
{tabValue === 0 ? 'Anmelden' : 'Registrieren'}
</Typography>
<IconButton edge="end" onClick={this.handleClose} aria-label="close">
<CloseIcon />
@@ -570,14 +578,14 @@ export class LoginComponent extends Component {
textColor="inherit"
>
<Tab
label={this.props.t ? this.props.t('auth.login').toUpperCase() : "ANMELDEN"}
label="ANMELDEN"
sx={{
color: tabValue === 0 ? '#2e7d32' : 'inherit',
fontWeight: 'bold'
}}
/>
<Tab
label={this.props.t ? this.props.t('auth.register').toUpperCase() : "REGISTRIEREN"}
label="REGISTRIEREN"
sx={{
color: tabValue === 1 ? '#2e7d32' : 'inherit',
fontWeight: 'bold'
@@ -590,14 +598,7 @@ export class LoginComponent extends Component {
<Box sx={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', mb: 2 }}>
{!privacyConfirmed && (
<Typography variant="caption" sx={{ mb: 1, textAlign: 'center' }}>
{this.props.t ?
<>
{this.props.t('auth.privacyAccept')} <Link to="/datenschutz" style={{ color: '#4285F4' }}>{this.props.t('auth.privacyPolicy')}</Link>
</> :
<>
Mit dem Click auf "Mit Google anmelden" akzeptiere ich die <Link to="/datenschutz" style={{ color: '#4285F4' }}>Datenschutzbestimmungen</Link>
</>
}
</Typography>
)}
{!showGoogleAuth && (
@@ -610,7 +611,7 @@ export class LoginComponent extends Component {
}}
sx={{ width: '100%', backgroundColor: '#4285F4', color: 'white' }}
>
{this.props.t ? this.props.t('auth.loginWithGoogle') : 'Mit Google anmelden'}
Mit Google anmelden
</Button>
)}
@@ -618,18 +619,17 @@ export class LoginComponent extends Component {
<Suspense fallback={
<Button
variant="contained"
startIcon={<GoogleIcon />}
disabled
fullWidth
style={{backgroundColor: '#4285F4', color: 'white' }}
startIcon={<PersonIcon />}
sx={{ width: '100%', backgroundColor: '#4285F4', color: 'white' }}
>
Loading...
Mit Google anmelden
</Button>
}>
<GoogleAuthProvider clientId={config.googleClientId}>
<GoogleLoginButton
onSuccess={this.handleGoogleLoginSuccess}
onError={this.handleGoogleLoginError}
text="Mit Google anmelden"
style={{ width: '100%', backgroundColor: '#4285F4' }}
autoInitiate={true}
/>
@@ -643,9 +643,7 @@ export class LoginComponent extends Component {
{/* OR Divider */}
<Box sx={{ display: 'flex', alignItems: 'center', my: 2 }}>
<Box sx={{ flex: 1, height: '1px', backgroundColor: '#e0e0e0' }} />
<Typography variant="body2" sx={{ px: 2, color: '#757575' }}>
{this.props.t ? this.props.t('auth.or') : 'ODER'}
</Typography>
<Typography variant="body2" sx={{ px: 2, color: '#757575' }}>ODER</Typography>
<Box sx={{ flex: 1, height: '1px', backgroundColor: '#e0e0e0' }} />
</Box>
@@ -656,7 +654,7 @@ export class LoginComponent extends Component {
<Box sx={{ py: 1 }}>
<TextField
margin="dense"
label={this.props.t ? this.props.t('auth.email') : 'E-Mail'}
label="E-Mail"
type="email"
fullWidth
variant="outlined"
@@ -667,7 +665,7 @@ export class LoginComponent extends Component {
<TextField
margin="dense"
label={this.props.t ? this.props.t('auth.password') : 'Passwort'}
label="Passwort"
type="password"
fullWidth
variant="outlined"
@@ -689,7 +687,7 @@ export class LoginComponent extends Component {
'&:hover': { backgroundColor: 'transparent', textDecoration: 'underline' }
}}
>
{this.props.t ? this.props.t('auth.forgotPassword') : 'Passwort vergessen?'}
Passwort vergessen?
</Button>
</Box>
)}
@@ -697,7 +695,7 @@ export class LoginComponent extends Component {
{tabValue === 1 && (
<TextField
margin="dense"
label={this.props.t ? this.props.t('auth.confirmPassword') : 'Passwort bestätigen'}
label="Passwort bestätigen"
type="password"
fullWidth
variant="outlined"
@@ -719,7 +717,7 @@ export class LoginComponent extends Component {
onClick={tabValue === 0 ? this.handleLogin : this.handleRegister}
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
>
{tabValue === 0 ? (this.props.t ? this.props.t('auth.login').toUpperCase() : 'ANMELDEN') : (this.props.t ? this.props.t('auth.register').toUpperCase() : 'REGISTRIEREN')}
{tabValue === 0 ? 'ANMELDEN' : 'REGISTRIEREN'}
</Button>
)}
</Box>
@@ -742,4 +740,4 @@ export class LoginComponent extends Component {
}
}
export default withRouter(withI18n()(LoginComponent));
export default withRouter(LoginComponent);

View File

@@ -1,284 +0,0 @@
import React from "react";
import { useLocation } from "react-router-dom";
import Container from "@mui/material/Container";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Paper from "@mui/material/Paper";
import Grid from "@mui/material/Grid";
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight";
import { Link } from "react-router-dom";
import SharedCarousel from "./SharedCarousel.js";
import { getCombinedAnimatedBorderStyles } from "../utils/animatedBorderStyles.js";
import { useTranslation } from 'react-i18next';
const ContentBox = ({ box, index, pageType, starHovered, setStarHovered, opacity, translatedContent }) => (
<Grid key={`${pageType}-${index}`} item xs={12} sm={6} sx={{ p: 2, width: "50%", position: 'relative' }}>
{index === 0 && pageType === "filiale" && (
<Box
sx={{
position: 'absolute',
top: '-55px',
left: '-45px',
width: '180px',
height: '180px',
zIndex: 999,
pointerEvents: 'none',
'& *': { pointerEvents: 'none' },
filter: 'drop-shadow(6px 6px 12px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 20px rgba(255, 215, 0, 0.8)) drop-shadow(0 0 40px rgba(255, 215, 0, 0.4))',
display: { xs: 'none', sm: 'block' }
}}
>
<svg viewBox="0 0 60 60" width="168" height="168" className="star-rotate-slow-cw" style={{ position: 'absolute', top: '-9px', left: '-9px', transform: 'rotate(20deg)' }}>
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#B8860B" />
</svg>
<svg viewBox="0 0 60 60" width="159" height="159" className="star-rotate-slow-ccw" style={{ position: 'absolute', top: '-4.5px', left: '-4.5px', transform: 'rotate(-25deg)' }}>
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#DAA520" />
</svg>
<svg viewBox="0 0 60 60" width="150" height="150" className="star-rotate-medium-cw">
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#FFD700" />
</svg>
<div style={{ position: 'absolute', top: '45%', left: '43%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', transition: 'opacity 0.3s ease', opacity: starHovered ? 0 : 1 }}>
{translatedContent.showUsPhoto}
</div>
<div style={{ position: 'absolute', top: '45%', left: '43%', transform: 'translate(-50%, -50%) rotate(-10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px', opacity: starHovered ? 1 : 0, transition: 'opacity 0.3s ease' }}>
{translatedContent.selectSeedRate}
</div>
</Box>
)}
{index === 1 && pageType === "filiale" && (
<Box
sx={{
position: 'absolute',
bottom: '-45px',
right: '-65px',
width: '180px',
height: '180px',
zIndex: 999,
pointerEvents: 'none',
'& *': { pointerEvents: 'none' },
filter: 'drop-shadow(6px 6px 12px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 20px rgba(175, 238, 238, 0.8)) drop-shadow(0 0 40px rgba(175, 238, 238, 0.4))',
display: { xs: 'none', sm: 'block' }
}}
>
<svg viewBox="0 0 60 60" width="168" height="168" className="star-rotate-slow-ccw" style={{ position: 'absolute', top: '-9px', left: '-9px', transform: 'rotate(20deg)' }}>
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#5F9EA0" />
</svg>
<svg viewBox="0 0 60 60" width="159" height="159" className="star-rotate-medium-cw" style={{ position: 'absolute', top: '-4.5px', left: '-4.5px', transform: 'rotate(-25deg)' }}>
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#7FCDCD" />
</svg>
<svg viewBox="0 0 60 60" width="150" height="150" className="star-rotate-slow-cw">
<polygon points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20" fill="#AFEEEE" />
</svg>
<div style={{ position: 'absolute', top: '42%', left: '45%', transform: 'translate(-50%, -50%) rotate(10deg)', color: 'white', fontWeight: '900', fontSize: '20px', textShadow: '0px 3px 6px rgba(0,0,0,0.5)', zIndex: 1000, textAlign: 'center', lineHeight: '1.1', width: '135px' }}>
{translatedContent.indoorSeason}
</div>
</Box>
)}
<div className={`animated-border-card ${index === 0 ? 'seeds-card' : 'cutlings-card'}`}>
<Paper
component={Link}
to={box.link}
sx={{
p: 0,
textDecoration: "none",
color: "text.primary",
borderRadius: 2,
overflow: "hidden",
height: { xs: 150, sm: 200, md: 300 },
display: "flex",
flexDirection: "column",
boxShadow: 10,
transition: "all 0.3s ease",
"&:hover": { transform: "translateY(-5px)", boxShadow: 20 },
}}
onMouseEnter={index === 0 && pageType === "filiale" ? () => setStarHovered(true) : undefined}
onMouseLeave={index === 0 && pageType === "filiale" ? () => setStarHovered(false) : undefined}
>
<Box sx={{ height: "100%", bgcolor: box.bgcolor, position: "relative", display: "flex", alignItems: "center", justifyContent: "center" }}>
{opacity === 1 && (
<img src={box.image} alt={box.title} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain", position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)" }} />
)}
<Box sx={{ position: "absolute", bottom: 0, left: 0, right: 0, bgcolor: "rgba(27, 94, 32, 0.8)", p: 1 }}>
<Typography sx={{ fontSize: "1.6rem", color: "white", fontFamily: "SwashingtonCP" }}>{box.title}</Typography>
</Box>
</Box>
</Paper>
</div>
</Grid>
);
const MainPageLayout = () => {
const location = useLocation();
const currentPath = location.pathname;
const { t } = useTranslation();
const [starHovered, setStarHovered] = React.useState(false);
const translatedContent = {
showUsPhoto: t('sections.showUsPhoto'),
selectSeedRate: t('sections.selectSeedRate'),
indoorSeason: t('sections.indoorSeason')
};
const isHome = currentPath === "/";
const isAktionen = currentPath === "/aktionen";
const isFiliale = currentPath === "/filiale";
React.useEffect(() => {
const style = document.createElement('style');
style.textContent = `
@keyframes rotateClockwise {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes rotateCounterClockwise {
from { transform: rotate(0deg); }
to { transform: rotate(-360deg); }
}
.star-rotate-slow-cw {
animation: rotateClockwise 60s linear infinite;
}
.star-rotate-slow-ccw {
animation: rotateCounterClockwise 45s linear infinite;
}
.star-rotate-medium-cw {
animation: rotateClockwise 30s linear infinite;
}
`;
document.head.appendChild(style);
return () => document.head.removeChild(style);
}, []);
const getNavigationConfig = () => {
if (isHome) return { leftNav: { text: t('navigation.aktionen'), link: "/aktionen" }, rightNav: { text: t('navigation.filiale'), link: "/filiale" } };
if (isAktionen) return { leftNav: { text: t('navigation.filiale'), link: "/filiale" }, rightNav: { text: t('navigation.home'), link: "/" } };
if (isFiliale) return { leftNav: { text: t('navigation.home'), link: "/" }, rightNav: { text: t('navigation.aktionen'), link: "/aktionen" } };
return { leftNav: null, rightNav: null };
};
const allTitles = {
home: t('titles.home') ,
aktionen: t('titles.aktionen'),
filiale: t('titles.filiale')
};
const allContentBoxes = {
home: [
{ title: t('sections.seeds'), image: "/assets/images/seeds.jpg", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" },
{ title: t('sections.stecklinge'), image: "/assets/images/cutlings.jpg", bgcolor: "#e8f5d6", link: "/Kategorie/Stecklinge" }
],
aktionen: [
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/presseverleih" },
{ title: t('sections.thcTest'), image: "/assets/images/purpl.jpg", bgcolor: "#e8f5d6", link: "/thc-test" }
],
filiale: [
{ title: t('sections.address1'), image: "/assets/images/filiale1.jpg", bgcolor: "#e1f0d3", link: "/filiale" },
{ title: t('sections.address2'), image: "/assets/images/filiale2.jpg", bgcolor: "#e8f5d6", link: "/filiale" }
]
};
const getOpacity = (pageType) => {
if (pageType === "home" && isHome) return 1;
if (pageType === "aktionen" && isAktionen) return 1;
if (pageType === "filiale" && isFiliale) return 1;
return 0;
};
const navConfig = getNavigationConfig();
const navTexts = [
{ key: 'aktionen', text: t('navigation.aktionen'), link: '/aktionen' },
{ key: 'filiale', text: t('navigation.filiale'), link: '/filiale' },
{ key: 'home', text: t('navigation.home'), link: '/' }
];
return (
<Container maxWidth="lg" sx={{ py: 2 }}>
<style>{getCombinedAnimatedBorderStyles(['seeds', 'cutlings'])}</style>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 4, mt: 2, px: 0, transition: "all 0.3s ease-in-out", flexDirection: { xs: "column", sm: "row" } }}>
<Box sx={{ display: { xs: "block", sm: "none" }, mb: { xs: 2, sm: 0 }, width: "100%", textAlign: "center", position: "relative" }}>
{Object.entries(allTitles).map(([pageType, title]) => (
<Typography key={pageType} variant="h3" component="h1" sx={{
fontFamily: "SwashingtonCP", fontSize: { xs: "2.125rem", sm: "2.125rem", md: "3rem" }, textAlign: "center", color: "primary.main",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)", transition: "opacity 0.5s ease-in-out", opacity: getOpacity(pageType),
position: pageType === "home" ? "relative" : "absolute", top: pageType !== "home" ? 0 : "auto", left: pageType !== "home" ? 0 : "auto",
transform: "none", width: "100%", pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none",
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, wordWrap: "break-word", hyphens: "auto"
}}>{title}</Typography>
))}
</Box>
<Box sx={{ display: { xs: "flex", sm: "contents" }, width: { xs: "100%", sm: "auto" }, justifyContent: { xs: "space-between", sm: "initial" }, alignItems: "center" }}>
<Box sx={{ display: "flex", alignItems: "center", flexShrink: 0, justifyContent: "flex-start", position: "relative", mr: { xs: 0, sm: 2 } }}>
{navTexts.map((navItem, index) => {
const isActive = navConfig.leftNav && navConfig.leftNav.text === navItem.text;
return (
<Box key={navItem.key} component={Link} to={navItem.link} sx={{
display: "flex", alignItems: "center", textDecoration: "none", color: "inherit", transition: "all 0.3s ease",
opacity: isActive ? 1 : 0, position: index === 0 ? "relative" : "absolute", left: index !== 0 ? 0 : "auto",
pointerEvents: isActive ? "auto" : "none", "&:hover": { transform: "translateX(-5px)", color: "primary.main" }
}}>
<ChevronLeft sx={{ fontSize: "2rem", mr: 1 }} />
<Typography sx={{
fontFamily: "SwashingtonCP", fontSize: { xs: "1.25rem", sm: "1.25rem", md: "2.125rem" },
textShadow: "2px 2px 4px rgba(0, 0, 0, 0.3)", lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, whiteSpace: "nowrap"
}}>{navItem.text}</Typography>
</Box>
);
})}
</Box>
<Box sx={{ flex: 1, display: { xs: "none", sm: "flex" }, justifyContent: "center", alignItems: "center", px: 0, position: "relative", minWidth: 0 }}>
{Object.entries(allTitles).map(([pageType, title]) => (
<Typography key={pageType} variant="h3" component="h1" sx={{
fontFamily: "SwashingtonCP", fontSize: { xs: "2.125rem", sm: "2.125rem", md: "3rem" }, textAlign: "center", color: "primary.main",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)", transition: "opacity 0.5s ease-in-out", opacity: getOpacity(pageType),
position: pageType === "home" ? "relative" : "absolute", top: pageType !== "home" ? "50%" : "auto", left: pageType !== "home" ? "50%" : "auto",
transform: pageType !== "home" ? "translate(-50%, -50%)" : "none", width: "100%", pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none",
lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, wordWrap: "break-word", hyphens: "auto"
}}>{title}</Typography>
))}
</Box>
<Box sx={{ display: "flex", alignItems: "center", flexShrink: 0, justifyContent: "flex-end", position: "relative", ml: { xs: 0, sm: 2 } }}>
{navTexts.map((navItem, index) => {
const isActive = navConfig.rightNav && navConfig.rightNav.text === navItem.text;
return (
<Box key={navItem.key} component={Link} to={navItem.link} sx={{
display: "flex", alignItems: "center", textDecoration: "none", color: "inherit", transition: "all 0.3s ease",
opacity: isActive ? 1 : 0, position: index === 0 ? "relative" : "absolute", right: index !== 0 ? 0 : "auto",
pointerEvents: isActive ? "auto" : "none", "&:hover": { transform: "translateX(5px)", color: "primary.main" }
}}>
<Typography sx={{
fontFamily: "SwashingtonCP", fontSize: { xs: "1.25rem", sm: "1.25rem", md: "2.125rem" },
textShadow: "2px 2px 4px rgba(0, 0, 0, 0.3)", lineHeight: { xs: "1.2", sm: "1.2", md: "1.1" }, whiteSpace: "nowrap"
}}>{navItem.text}</Typography>
<ChevronRight sx={{ fontSize: "2rem", ml: 1 }} />
</Box>
);
})}
</Box>
</Box>
</Box>
<Box sx={{ position: "relative", mb: 4 }}>
{Object.entries(allContentBoxes).map(([pageType, contentBoxes]) => (
<Grid key={pageType} container spacing={0} sx={{
transition: "opacity 0.5s ease-in-out", opacity: getOpacity(pageType),
position: pageType === "home" ? "relative" : "absolute", top: 0, left: 0, width: "100%", pointerEvents: getOpacity(pageType) === 1 ? "auto" : "none"
}}>
{contentBoxes.map((box, index) => (
<ContentBox
key={`${pageType}-${index}`}
box={box}
index={index}
pageType={pageType}
starHovered={starHovered}
setStarHovered={setStarHovered}
opacity={getOpacity(pageType)}
translatedContent={translatedContent}
/>
))}
</Grid>
))}
</Box>
<SharedCarousel />
</Container>
);
};
export default MainPageLayout;

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

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

View File

@@ -1,158 +0,0 @@
import React, { Component } from 'react';
import { Navigate } from 'react-router-dom';
import { Box, CircularProgress, Typography } from '@mui/material';
class PaymentSuccess extends Component {
constructor(props) {
super(props);
this.state = {
redirectUrl: null,
processing: true,
error: null
};
}
componentDidMount() {
this.processMolliePayment();
}
processMolliePayment = () => {
try {
// Get the stored payment ID from localStorage
const pendingPayment = localStorage.getItem('pendingPayment');
if (!pendingPayment) {
console.error('No pending payment found in localStorage');
this.setState({
redirectUrl: '/profile#cart',
processing: false,
error: 'No payment information found'
});
return;
}
let paymentData;
try {
paymentData = JSON.parse(pendingPayment);
// Clear the pending payment data
localStorage.removeItem('pendingPayment');
} catch (error) {
console.error('Error parsing pending payment data:', error);
this.setState({
redirectUrl: '/profile#cart',
processing: false,
error: 'Invalid payment data'
});
return;
}
if (!paymentData.paymentId) {
console.error('No payment ID found in stored payment data');
this.setState({
redirectUrl: '/profile#cart',
processing: false,
error: 'Missing payment ID'
});
return;
}
// Check payment status with backend
this.checkMolliePaymentStatus(paymentData.paymentId);
} catch (error) {
console.error('Error processing Mollie payment:', error);
this.setState({
redirectUrl: '/profile#cart',
processing: false,
error: 'Payment processing failed'
});
}
};
checkMolliePaymentStatus = (paymentId) => {
window.socketManager.emit('checkMollieIntent', { paymentId }, (response) => {
if (response.success) {
console.log('Payment Status:', response.payment.status);
console.log('Is Paid:', response.payment.isPaid);
console.log('Order Created:', response.order.created);
if (response.order.orderId) {
console.log('Order ID:', response.order.orderId);
}
// Build the redirect URL with Mollie completion parameters
const profileUrl = new URL('/profile', window.location.origin);
profileUrl.searchParams.set('mollieComplete', 'true');
profileUrl.searchParams.set('mollie_payment_id', paymentId);
profileUrl.searchParams.set('mollie_status', response.payment.status);
profileUrl.searchParams.set('mollie_amount', response.payment.amount);
profileUrl.searchParams.set('mollie_timestamp', Date.now().toString());
profileUrl.searchParams.set('mollie_is_paid', response.payment.isPaid.toString());
if (response.order.orderId) {
profileUrl.searchParams.set('mollie_order_id', response.order.orderId.toString());
}
// Set hash based on payment success: orders for successful payments, cart for failed payments
profileUrl.hash = response.payment.isPaid ? '#orders' : '#cart';
this.setState({
redirectUrl: profileUrl.pathname + profileUrl.search + profileUrl.hash,
processing: false
});
} else {
console.error('Failed to check payment status:', response.error);
this.setState({
redirectUrl: '/profile#cart',
processing: false,
error: response.error || 'Payment verification failed'
});
}
});
};
render() {
const { redirectUrl, processing, error } = this.state;
if (processing) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
minHeight: '60vh',
gap: 2
}}
>
<CircularProgress size={60} />
<Typography variant="h6" color="text.secondary">
Zahlung wird überprüft...
</Typography>
<Typography variant="body2" color="text.secondary">
Bitte warten Sie, während wir Ihre Zahlung bei Mollie überprüfen.
</Typography>
</Box>
);
}
if (error) {
return <Navigate to="/profile#cart" replace />;
}
if (redirectUrl) {
return <Navigate to={redirectUrl} replace />;
}
// Fallback redirect to profile
return <Navigate to="/profile#cart" replace />;
}
}
export default PaymentSuccess;

View File

@@ -1,285 +0,0 @@
import React, { Component } from 'react';
import {
Box,
Button,
Typography,
IconButton,
Paper,
Grid,
Alert
} from '@mui/material';
import Delete from '@mui/icons-material/Delete';
import CloudUpload from '@mui/icons-material/CloudUpload';
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}
aria-label="Bild entfernen"
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;

View File

@@ -7,67 +7,9 @@ import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import AddToCartButton from './AddToCartButton.js';
import { Link, useNavigate } from 'react-router-dom';
import { withI18n } from '../i18n/withTranslation.js';
import { Link } from 'react-router-dom';
import ZoomInIcon from '@mui/icons-material/ZoomIn';
// Helper function to find level 1 category ID from any category ID
const findLevel1CategoryId = (categoryId) => {
try {
const currentLanguage = 'de'; // Default to German
const categoryTreeCache = window.categoryService?.getSync(209, currentLanguage);
if (!categoryTreeCache || !categoryTreeCache.children) {
return null;
}
// Helper function to find category by ID and get its level 1 parent
const findCategoryAndLevel1 = (categories, targetId) => {
for (const category of categories) {
if (category.id === targetId) {
// Found the category, now find its level 1 parent
return findLevel1Parent(categoryTreeCache.children, category);
}
if (category.children && category.children.length > 0) {
const result = findCategoryAndLevel1(category.children, targetId);
if (result) return result;
}
}
return null;
};
// Helper function to find the level 1 parent (direct child of root category 209)
const findLevel1Parent = (level1Categories, category) => {
// If this category's parent is 209, it's already level 1
if (category.parentId === 209) {
return category.id;
}
// Otherwise, find the parent and check if it's level 1
for (const level1Category of level1Categories) {
if (level1Category.id === category.parentId) {
return level1Category.id;
}
// If parent has children, search recursively
if (level1Category.children && level1Category.children.length > 0) {
const result = findLevel1Parent(level1Category.children, category);
if (result) return result;
}
}
return null;
};
return findCategoryAndLevel1(categoryTreeCache.children, parseInt(categoryId));
} catch (error) {
console.error('Error finding level 1 category:', error);
return null;
}
};
class Product extends Component {
constructor(props) {
super(props);
@@ -84,22 +26,8 @@ class Product extends Component {
this.state = {image:window.smallPicCache[bildId],loading:false, error: false}
}else{
this.state = {image: null, loading: true, error: false};
this.loadImage(bildId);
}
}else{
this.state = {image: null, loading: false, error: false};
}
}
componentDidMount() {
this._isMounted = true;
}
loadImage = (bildId) => {
console.log('loadImagevisSocket', bildId);
window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => {
console.log("Product: Fetching image from socketB", this.props.socketB);
this.props.socketB.emit('getPic', { bildId, size:'small' }, (res) => {
if(res.success){
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
if (this._isMounted) {
@@ -117,9 +45,16 @@ class Product extends Component {
this.state.loading = false;
}
}
});
})
}
}else{
this.state = {image: null, loading: false, error: false};
}
}
componentDidMount() {
this._isMounted = true;
}
componentWillUnmount() {
this._isMounted = false;
@@ -130,28 +65,11 @@ class Product extends Component {
// In a real app, this would update a cart state in a parent component or Redux store
}
handleProductClick = (e) => {
e.preventDefault();
const { categoryId } = this.props;
// Find the level 1 category for this product
const level1CategoryId = categoryId ? findLevel1CategoryId(categoryId) : null;
// Navigate to the product page WITH the category information in the state
const navigate = this.props.navigate;
if (navigate) {
navigate(`/Artikel/${this.props.seoName}`, {
state: { articleCategoryId: level1CategoryId }
});
}
}
render() {
const {
id, name, price, available, manufacturer, seoName,
currency, vat, cGrundEinheit, fGrundPreis, thc,
floweringWeeks,incoming, neu, weight, versandklasse, availableSupplier, komponenten
currency, vat, massMenge, massEinheit, thc,
floweringWeeks,incoming, neu, weight, versandklasse, availableSupplier
} = this.props;
const isNew = neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
@@ -255,7 +173,7 @@ class Product extends Component {
zIndex: 1000
}}
>
{this.props.t ? this.props.t('product.new') : 'NEU'}
NEU
</div>
</div>
)}
@@ -322,20 +240,20 @@ class Product extends Component {
transformOrigin: 'top left'
}}
>
{floweringWeeks} {this.props.t ? this.props.t('product.weeks') : 'Wochen'}
{floweringWeeks} Wochen
</div>
)}
<Box
onClick={this.handleProductClick}
component={Link}
to={`/Artikel/${seoName}`}
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
textDecoration: 'none',
color: 'inherit',
cursor: 'pointer'
color: 'inherit'
}}
>
<Box sx={{
@@ -357,14 +275,6 @@ class Product extends Component {
height={ window.innerWidth < 600 ? "240" : "180" }
image="/assets/images/nopicture.jpg"
alt={name}
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}
loading={this.props.priority === 'high' ? 'eager' : 'lazy'}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = name || 'Produktbild';
}
}}
sx={{
objectFit: 'contain',
borderTopLeftRadius: '8px',
@@ -378,14 +288,6 @@ class Product extends Component {
height={ window.innerWidth < 600 ? "240" : "180" }
image={this.state.image}
alt={name}
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}
loading={this.props.priority === 'high' ? 'eager' : 'lazy'}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = name || 'Produktbild';
}
}}
sx={{
objectFit: 'contain',
borderTopLeftRadius: '8px',
@@ -427,49 +329,20 @@ class Product extends Component {
</Typography>
</Box>
<div style={{padding:'0px',margin:'0px'}}>
<div style={{padding:'0px',margin:'0px',minHeight:'3.8em'}}>
<Typography
variant="h6"
color="primary"
sx={{
fontWeight: 'bold',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
position: 'relative'
}}
sx={{ mt: 'auto', fontWeight: 'bold', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
>
<Box sx={{ position: 'relative', display: 'inline-block' }}>
{this.props.rebate && this.props.rebate > 0 && (
<span
style={{
position: 'absolute',
top: -8,
left: -8,
fontWeight: 'bold',
color: 'red',
textDecoration: 'line-through',
opacity: 0.4,
zIndex: 1,
pointerEvents: 'none',
fontSize: 'inherit'
}}
>
{(() => {
const rebatePct = this.props.rebate / 100;
const originalPrice = Math.round((price / (1 - rebatePct)) * 10) / 10;
return new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(originalPrice);
})()}
</span>
)}
<span style={{ position: 'relative', zIndex: 2 }}>{new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price)}</span>
</Box>
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>({this.props.t ? this.props.t('product.inclVatFooter', { vat }) : `incl. ${vat}% USt.,*`})</small>
<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>
</Typography>
</div>
<div style={{ minHeight: '1.5em' }}>
{cGrundEinheit && fGrundPreis && fGrundPreis != price && (<Typography variant="body2" color="text.secondary" sx={{ m: 0,p: 0 }}>
({new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(fGrundPreis)}/{cGrundEinheit})
{massMenge != 1 && massEinheit && (<Typography variant="body2" color="text.secondary" sx={{ m: 0,p: 0 }}>
({new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price/massMenge)}/{massEinheit})
</Typography> )}
</div>
{/*incoming*/}
@@ -481,12 +354,11 @@ class Product extends Component {
component={Link}
to={`/Artikel/${seoName}`}
size="small"
aria-label="Produktdetails anzeigen"
sx={{ mr: 1, color: 'text.secondary' }}
>
<ZoomInIcon />
</IconButton>
<AddToCartButton cartButton={true} availableSupplier={availableSupplier} 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}/>
<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}/>
</Box>
</Card>
</Box>
@@ -494,10 +366,4 @@ class Product extends Component {
}
}
// Wrapper component to provide navigate hook
const ProductWithNavigation = (props) => {
const navigate = useNavigate();
return <Product {...props} navigate={navigate} />;
};
export default withI18n()(ProductWithNavigation);
export default Product;

View File

@@ -1,444 +0,0 @@
import React from 'react';
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight";
import Product from "./Product.js";
import { withTranslation } from 'react-i18next';
import { withLanguage } from '../i18n/withTranslation.js';
const ITEM_WIDTH = 250 + 16; // 250px width + 16px gap
const AUTO_SCROLL_SPEED = 0.5; // px per frame (~60fps, so ~30px/sec)
const AUTOSCROLL_RESTART_DELAY = 5000; // 5 seconds of inactivity before restarting autoscroll
const SCROLLBAR_FLASH_DURATION = 3000; // 3 seconds to show the virtual scrollbar
class ProductCarousel extends React.Component {
_isMounted = false;
products = [];
originalProducts = [];
animationFrame = null;
autoScrollActive = true;
translateX = 0;
inactivityTimer = null;
scrollbarTimer = null;
constructor(props) {
super(props);
const { i18n } = props;
this.state = {
products: [],
currentLanguage: (i18n && i18n.language) || 'de',
showScrollbar: false,
};
this.carouselTrackRef = React.createRef();
}
componentDidMount() {
this._isMounted = true;
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
console.log("ProductCarousel componentDidMount: Loading products for categoryId", this.props.categoryId, "language", currentLanguage);
this.loadProducts(currentLanguage);
}
componentDidUpdate(prevProps) {
console.log("ProductCarousel componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
if(prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
this.setState({ products: [] }, () => {
this.loadProducts(this.props.languageContext?.currentLanguage || this.props.i18n.language);
});
}
}
loadProducts = (language) => {
const { categoryId } = this.props;
window.socketManager.emit(
"getCategoryProducts",
{
categoryId: categoryId === "neu" ? "neu" : categoryId,
language: language,
requestTranslation: language === 'de' ? false : true
},
(response) => {
console.log("ProductCarousel getCategoryProducts response:", response);
if (this._isMounted && response && response.products && response.products.length > 0) {
// Filter products to only show those with pictures
const productsWithPictures = response.products.filter(product =>
product.pictureList && product.pictureList.length > 0
);
console.log("ProductCarousel: Filtered", productsWithPictures.length, "products with pictures from", response.products.length, "total");
if (productsWithPictures.length > 0) {
// Take random 15 products and shuffle them
const shuffledProducts = this.shuffleArray(productsWithPictures.slice(0, 15));
console.log("ProductCarousel: Selected and shuffled", shuffledProducts.length, "products");
this.originalProducts = shuffledProducts;
// Duplicate for seamless looping
this.products = [...shuffledProducts, ...shuffledProducts];
this.setState({ products: this.products });
this.startAutoScroll();
}
}
}
);
}
componentWillUnmount() {
this._isMounted = false;
this.stopAutoScroll();
this.clearInactivityTimer();
this.clearScrollbarTimer();
}
startAutoScroll = () => {
this.autoScrollActive = true;
if (!this.animationFrame) {
this.animationFrame = requestAnimationFrame(this.handleAutoScroll);
}
};
stopAutoScroll = () => {
this.autoScrollActive = false;
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
};
clearInactivityTimer = () => {
if (this.inactivityTimer) {
clearTimeout(this.inactivityTimer);
this.inactivityTimer = null;
}
};
clearScrollbarTimer = () => {
if (this.scrollbarTimer) {
clearTimeout(this.scrollbarTimer);
this.scrollbarTimer = null;
}
};
startInactivityTimer = () => {
this.clearInactivityTimer();
this.inactivityTimer = setTimeout(() => {
if (this._isMounted) {
this.startAutoScroll();
}
}, AUTOSCROLL_RESTART_DELAY);
};
showScrollbarFlash = () => {
this.clearScrollbarTimer();
this.setState({ showScrollbar: true });
this.scrollbarTimer = setTimeout(() => {
if (this._isMounted) {
this.setState({ showScrollbar: false });
}
}, SCROLLBAR_FLASH_DURATION);
};
handleAutoScroll = () => {
if (!this.autoScrollActive || this.originalProducts.length === 0) return;
this.translateX -= AUTO_SCROLL_SPEED;
this.updateTrackTransform();
const originalItemCount = this.originalProducts.length;
const maxScroll = ITEM_WIDTH * originalItemCount;
// Check if we've scrolled past the first set of items
if (Math.abs(this.translateX) >= maxScroll) {
// Reset to beginning seamlessly
this.translateX = 0;
this.updateTrackTransform();
}
this.animationFrame = requestAnimationFrame(this.handleAutoScroll);
};
updateTrackTransform = () => {
if (this.carouselTrackRef.current) {
this.carouselTrackRef.current.style.transform = `translateX(${this.translateX}px)`;
}
};
handleLeftClick = () => {
this.stopAutoScroll();
this.scrollBy(1);
this.showScrollbarFlash();
this.startInactivityTimer();
};
handleRightClick = () => {
this.stopAutoScroll();
this.scrollBy(-1);
this.showScrollbarFlash();
this.startInactivityTimer();
};
scrollBy = (direction) => {
if (this.originalProducts.length === 0) return;
// direction: 1 = left (scroll content right), -1 = right (scroll content left)
const originalItemCount = this.originalProducts.length;
const maxScroll = ITEM_WIDTH * originalItemCount;
this.translateX += direction * ITEM_WIDTH;
// Handle wrap-around when scrolling left (positive translateX)
if (this.translateX > 0) {
this.translateX = -(maxScroll - ITEM_WIDTH);
}
// Handle wrap-around when scrolling right (negative translateX beyond limit)
else if (Math.abs(this.translateX) >= maxScroll) {
this.translateX = 0;
}
this.updateTrackTransform();
// Force scrollbar to update immediately after wrap-around
if (this.state.showScrollbar) {
this.forceUpdate();
}
};
renderVirtualScrollbar = () => {
if (!this.state.showScrollbar || this.originalProducts.length === 0) {
return null;
}
const originalItemCount = this.originalProducts.length;
const viewportWidth = 1080; // carousel container max-width
const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH);
// Calculate which item is currently at the left edge (first visible)
let currentItemIndex;
if (this.translateX === 0) {
currentItemIndex = 0;
} else if (this.translateX > 0) {
const maxScroll = ITEM_WIDTH * originalItemCount;
const effectivePosition = maxScroll + this.translateX;
currentItemIndex = Math.floor(effectivePosition / ITEM_WIDTH);
} else {
currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH);
}
// Ensure we stay within bounds
currentItemIndex = Math.max(0, Math.min(currentItemIndex, originalItemCount - 1));
// Calculate scrollbar position
const lastPossibleFirstItem = Math.max(0, originalItemCount - itemsInView);
const thumbPosition = lastPossibleFirstItem > 0 ? Math.min((currentItemIndex / lastPossibleFirstItem) * 100, 100) : 0;
return (
<div
className="virtual-scrollbar"
style={{
position: 'absolute',
bottom: '5px',
left: '50%',
transform: 'translateX(-50%)',
width: '200px',
height: '4px',
backgroundColor: 'rgba(0, 0, 0, 0.1)',
borderRadius: '2px',
zIndex: 1000,
opacity: this.state.showScrollbar ? 1 : 0,
transition: 'opacity 0.3s ease-in-out'
}}
>
<div
className="scrollbar-thumb"
style={{
position: 'absolute',
top: '0',
left: `${thumbPosition}%`,
width: '20px',
height: '4px',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: '2px',
transform: 'translateX(-50%)',
transition: 'left 0.2s ease-out'
}}
/>
</div>
);
};
render() {
const { t, title } = this.props;
const { products } = this.state;
if(!products || products.length === 0) {
return null;
}
return (
<Box sx={{ mt: 3 }}>
<Typography
variant="h4"
component="h2"
sx={{
mb: 2,
fontFamily: "SwashingtonCP",
color: "primary.main",
textAlign: "center",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
}}
>
{title || t('product.new')}
</Typography>
<div
className="product-carousel-wrapper"
style={{
position: 'relative',
overflowX: 'hidden',
overflowY: 'visible',
width: '100%',
maxWidth: '1200px',
margin: '0 auto',
padding: '0 20px',
boxSizing: 'border-box'
}}
>
{/* Left Arrow */}
<IconButton
aria-label="Vorherige Produkte anzeigen"
onClick={this.handleLeftClick}
style={{
position: 'absolute',
top: '50%',
left: '8px',
transform: 'translateY(-50%)',
zIndex: 1200,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
height: '48px',
borderRadius: '50%'
}}
>
<ChevronLeft />
</IconButton>
{/* Right Arrow */}
<IconButton
aria-label="Nächste Produkte anzeigen"
onClick={this.handleRightClick}
style={{
position: 'absolute',
top: '50%',
right: '8px',
transform: 'translateY(-50%)',
zIndex: 1200,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
height: '48px',
borderRadius: '50%'
}}
>
<ChevronRight />
</IconButton>
<div
className="product-carousel-container"
style={{
position: 'relative',
overflowX: 'hidden',
overflowY: 'visible',
padding: '20px 0',
width: '100%',
maxWidth: '1080px',
margin: '0 auto',
zIndex: 1,
boxSizing: 'border-box'
}}
>
<div
className="product-carousel-track"
ref={this.carouselTrackRef}
style={{
display: 'flex',
gap: '16px',
transition: 'none',
alignItems: 'flex-start',
width: 'fit-content',
overflow: 'visible',
position: 'relative',
transform: 'translateX(0px)',
margin: '0 auto'
}}
>
{products.map((product, index) => (
<div
key={`${product.id}-${index}`}
className="product-carousel-item"
style={{
flex: '0 0 250px',
width: '250px',
maxWidth: '250px',
minWidth: '250px',
boxSizing: 'border-box',
position: 'relative'
}}
>
<Product
id={product.id}
name={product.name}
seoName={product.seoName}
price={product.price}
currency={product.currency}
available={product.available}
manufacturer={product.manufacturer}
vat={product.vat}
cGrundEinheit={product.cGrundEinheit}
fGrundPreis={product.fGrundPreis}
incoming={product.incomingDate}
neu={product.neu}
thc={product.thc}
floweringWeeks={product.floweringWeeks}
versandklasse={product.versandklasse}
weight={product.weight}
pictureList={product.pictureList}
availableSupplier={product.availableSupplier}
komponenten={product.komponenten}
rebate={product.rebate}
categoryId={product.kategorien ? product.kategorien.split(',')[0] : undefined}
priority={index < 6 ? 'high' : 'auto'}
t={t}
/>
</div>
))}
</div>
{/* Virtual Scrollbar */}
{this.renderVirtualScrollbar()}
</div>
</div>
</Box>
);
}
// Shuffle array using Fisher-Yates algorithm
shuffleArray(array) {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
}
export default withTranslation()(withLanguage(ProductCarousel));

View File

@@ -1,4 +0,0 @@
// This file re-exports ProductDetailWithSocket to maintain compatibility with App.js imports
import ProductDetailWithSocket from './ProductDetailWithSocket.js';
export default ProductDetailWithSocket;

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,18 @@
import React from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import SocketContext from '../contexts/SocketContext.js';
import ProductDetailPage from './ProductDetailPage.js';
// Wrapper component for individual product detail page with socket
const ProductDetailWithSocket = () => {
const { seoName } = useParams();
const navigate = useNavigate();
const location = useLocation();
return (
<ProductDetailPage seoName={seoName} navigate={navigate} location={location} />
<SocketContext.Consumer>
{({socket,socketB}) => <ProductDetailPage seoName={seoName} navigate={navigate} location={location} socket={socket} socketB={socketB} />}
</SocketContext.Consumer>
);
};

View File

@@ -4,7 +4,6 @@ import Typography from '@mui/material/Typography';
import Filter from './Filter.js';
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { setSessionSetting, removeSessionSetting, clearAllSessionSettings } from '../utils/sessionStorage.js';
import { withI18n } from '../i18n/withTranslation.js';
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
@@ -47,41 +46,6 @@ class ProductFilters extends Component {
window.addEventListener('resize', this.adjustPaperHeight);
}
componentDidUpdate(prevProps) {
// Regenerate values when products, attributes, or language changes
const productsChanged = this.props.products !== prevProps.products;
const attributesChanged = this.props.attributes !== prevProps.attributes;
const languageChanged = this.props.i18n && prevProps.i18n && this.props.i18n.language !== prevProps.i18n.language;
const tFunctionChanged = this.props.t !== prevProps.t;
if(languageChanged) {
console.log('ProductFilters: Language changed, will update when new data arrives');
}
if(productsChanged || languageChanged || tFunctionChanged) {
console.log('ProductFilters: Updating manufacturers and availability', {
productsChanged,
languageChanged,
tFunctionChanged,
productCount: this.props.products?.length
});
const uniqueManufacturerArray = this._getUniqueManufacturers(this.props.products);
const availabilityValues = this._getAvailabilityValues(this.props.products);
this.setState({uniqueManufacturerArray, availabilityValues});
}
if(attributesChanged || (languageChanged && this.props.attributes)) {
console.log('ProductFilters: Updating attributes', {
attributesChanged,
languageChanged,
attributeCount: this.props.attributes?.length,
firstAttribute: this.props.attributes?.[0]
});
const attributeGroups = this._getAttributeGroups(this.props.attributes);
this.setState({attributeGroups});
}
}
componentWillUnmount() {
// Remove event listener when component unmounts
window.removeEventListener('resize', this.adjustPaperHeight);
@@ -129,14 +93,14 @@ class ProductFilters extends Component {
}
_getAvailabilityValues = (products) => {
const filters = [{id:1,name: this.props.t ? this.props.t('product.inStock') : 'auf Lager'}];
const filters = [{id:1,name:'auf Lager'}];
for(const product of products){
if(isNew(product.neu)){
if(!filters.find(filter => filter.id == 2)) filters.push({id:2,name: this.props.t ? this.props.t('product.new') : 'Neu'});
if(!filters.find(filter => filter.id == 2)) filters.push({id:2,name:'Neu'});
}
if(!product.available && product.incomingDate){
if(!filters.find(filter => filter.id == 3)) filters.push({id:3,name: this.props.t ? this.props.t('product.comingSoon') : 'Bald verfügbar'});
if(!filters.find(filter => filter.id == 3)) filters.push({id:3,name:'Bald verfügbar'});
}
}
return filters
@@ -151,6 +115,19 @@ class ProductFilters extends Component {
return attributeGroups;
}
shouldComponentUpdate(nextProps) {
if(nextProps.products !== this.props.products) {
const uniqueManufacturerArray = this._getUniqueManufacturers(nextProps.products);
const availabilityValues = this._getAvailabilityValues(nextProps.products);
this.setState({uniqueManufacturerArray, availabilityValues});
}
if(nextProps.attributes !== this.props.attributes) {
const attributeGroups = this._getAttributeGroups(nextProps.attributes);
this.setState({attributeGroups});
}
return true;
}
generateAttributeFilters = () => {
const filters = [];
const sortedAttributeGroups = Object.values(this.state.attributeGroups)
@@ -216,7 +193,7 @@ class ProductFilters extends Component {
{this.props.products.length > 0 && (
<><Filter
title={this.props.t ? this.props.t('filters.availability') : 'Verfügbarkeit'}
title="Verfügbarkeit"
options={this.state.availabilityValues}
searchParams={this.props.searchParams}
products={this.props.products}
@@ -259,7 +236,7 @@ class ProductFilters extends Component {
{this.generateAttributeFilters()}
<Filter
title={this.props.t ? this.props.t('filters.manufacturer') : 'Hersteller'}
title="Hersteller"
options={this.state.uniqueManufacturerArray}
filterType="manufacturer"
products={this.props.products}
@@ -280,4 +257,4 @@ class ProductFilters extends Component {
}
}
export default withRouter(withI18n()(ProductFilters));
export default withRouter(ProductFilters);

View File

@@ -1,56 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import CardMedia from '@mui/material/CardMedia';
import Images from './Images.js';
const ProductImage = ({
product,
fullscreenOpen,
onOpenFullscreen,
onCloseFullscreen
}) => {
// Container styling - unified for all versions
const containerSx = {
width: { xs: "100%", sm: "555px" },
maxWidth: "100%",
minHeight: "400px",
background: "#f8f8f8",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
};
return (
<Box sx={containerSx}>
{!product.pictureList && (
<CardMedia
component="img"
height="400"
image="/assets/images/nopicture.jpg"
alt={product.name}
fetchPriority="high"
loading="eager"
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = product.name || 'Produktbild';
}
}}
sx={{ objectFit: "cover" }}
/>
)}
{product.pictureList && (
<Images
pictureList={product.pictureList}
productName={product.name}
fullscreenOpen={fullscreenOpen}
onOpenFullscreen={onOpenFullscreen}
onCloseFullscreen={onCloseFullscreen}
/>
)}
</Box>
);
};
export default ProductImage;

View File

@@ -11,7 +11,6 @@ import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack';
import Product from './Product.js';
import { removeSessionSetting } from '../utils/sessionStorage.js';
import { withI18n } from '../i18n/withTranslation.js';
// Sort products by fuzzy similarity to their name/description
function sortProductsByFuzzySimilarity(products, searchTerm) {
@@ -142,12 +141,12 @@ class ProductList extends Component {
onChange={this.handlePageChange}
color="primary"
size={"large"}
siblingCount={1}
boundaryCount={1}
hideNextButton={true}
hidePrevButton={true}
showFirstButton={false}
showLastButton={false}
siblingCount={window.innerWidth < 600 ? 0 : 1}
boundaryCount={window.innerWidth < 600 ? 1 : 1}
hideNextButton={false}
hidePrevButton={false}
showFirstButton={window.innerWidth >= 600}
showLastButton={window.innerWidth >= 600}
sx={{
'& .MuiPagination-ul': {
flexWrap: 'nowrap',
@@ -185,7 +184,7 @@ class ProductList extends Component {
px: 2
}}>
<Typography variant="h6" color="text.secondary" sx={{ textAlign: 'center' }}>
{this.props.t ? this.props.t('product.removeFiltersToSee') : 'Entferne Filter um Produkte zu sehen'}
Entferne Filter um Produkte zu sehen
</Typography>
</Box>
);
@@ -201,14 +200,14 @@ class ProductList extends Component {
if (!isFiltered) {
// No filters applied
if (filteredCount === 0) return this.props.t ? this.props.t('product.countDisplay.noProducts') : "0 Produkte";
if (filteredCount === 1) return this.props.t ? this.props.t('product.countDisplay.oneProduct') : "1 Produkt";
return this.props.t ? this.props.t('product.countDisplay.multipleProducts', { count: filteredCount }) : `${filteredCount} Produkte`;
if (filteredCount === 0) return "0 Produkte";
if (filteredCount === 1) return "1 Produkt";
return `${filteredCount} Produkte`;
} else {
// Filters applied
if (totalCount === 0) return this.props.t ? this.props.t('product.countDisplay.noProducts') : "0 Produkte";
if (totalCount === 1) return this.props.t ? this.props.t('product.countDisplay.filteredOneProduct', { filtered: filteredCount }) : `${filteredCount} von 1 Produkt`;
return this.props.t ? this.props.t('product.countDisplay.filteredProducts', { filtered: filteredCount, total: totalCount }) : `${filteredCount} von ${totalCount} Produkten`;
if (totalCount === 0) return "0 Produkte";
if (totalCount === 1) return `${filteredCount} von 1 Produkt`;
return `${filteredCount} von ${totalCount} Produkten`;
}
}
@@ -328,13 +327,13 @@ class ProductList extends Component {
minWidth: { xs: 120, sm: 140 }
}}
>
<InputLabel id="sort-by-label">{this.props.t ? this.props.t('filters.sorting') : 'Sortierung'}</InputLabel>
<InputLabel id="sort-by-label">Sortierung</InputLabel>
<Select
size="small"
labelId="sort-by-label"
value={(this.state.sortBy==='searchField')&&(window.currentSearchQuery)?this.state.sortBy:this.state.sortBy==='price-low-high'?this.state.sortBy:this.state.sortBy==='price-low-high'?this.state.sortBy:'name'}
onChange={this.handleSortChange}
label={this.props.t ? this.props.t('filters.sorting') : 'Sortierung'}
label="Sortierung"
MenuProps={{
disableScrollLock: true,
anchorOrigin: {
@@ -354,10 +353,10 @@ class ProductList extends Component {
}
}}
>
<MenuItem value="name">{this.props.t ? this.props.t('sorting.name') : 'Name'}</MenuItem>
{window.currentSearchQuery && <MenuItem value="searchField">{this.props.t ? this.props.t('sorting.searchField') : 'Suchbegriff'}</MenuItem>}
<MenuItem value="price-low-high">{this.props.t ? this.props.t('sorting.priceLowHigh') : 'Preis: Niedrig zu Hoch'}</MenuItem>
<MenuItem value="price-high-low">{this.props.t ? this.props.t('sorting.priceHighLow') : 'Preis: Hoch zu Niedrig'}</MenuItem>
<MenuItem value="name">Name</MenuItem>
{window.currentSearchQuery && <MenuItem value="searchField">Suchbegriff</MenuItem>}
<MenuItem value="price-low-high">Preis: Niedrig zu Hoch</MenuItem>
<MenuItem value="price-high-low">Preis: Hoch zu Niedrig</MenuItem>
</Select>
</FormControl>
@@ -369,12 +368,12 @@ class ProductList extends Component {
minWidth: { xs: 80, sm: 100 }
}}
>
<InputLabel id="products-per-page-label">{this.props.t ? this.props.t('filters.perPage') : 'pro Seite'}</InputLabel>
<InputLabel id="products-per-page-label">pro Seite</InputLabel>
<Select
labelId="products-per-page-label"
value={this.state.itemsPerPage}
onChange={this.handleProductsPerPageChange}
label={this.props.t ? this.props.t('filters.perPage') : 'pro Seite'}
label="pro Seite"
MenuProps={{
disableScrollLock: true,
anchorOrigin: {
@@ -399,7 +398,7 @@ class ProductList extends Component {
>
<MenuItem value={20}>20</MenuItem>
<MenuItem value={50}>50</MenuItem>
<MenuItem value="all">{this.props.t ? this.props.t('filters.all') : 'Alle'}</MenuItem>
<MenuItem value="all">Alle</MenuItem>
</Select>
</FormControl>
@@ -430,7 +429,7 @@ class ProductList extends Component {
<Stack direction="row" spacing={2} sx={{ display: { xs: 'none', sm: 'flex' }, px: { xs: 1, sm: 0 } }}>
<Typography variant="body2" color="text.secondary">
{/*this.props.dataType == 'category' && (<>Kategorie: {this.props.dataParam}</>)}*/}
{this.props.dataType == 'search' && (this.props.t ? this.props.t('search.searchResultsFor', { query: this.props.dataParam }) : `Suchergebnisse für: "${this.props.dataParam}"`)}
{this.props.dataType == 'search' && (<>Suchergebnisse für: "{this.props.dataParam}"</>)}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
{this.getProductCountText()}
@@ -463,21 +462,18 @@ class ProductList extends Component {
available={product.available}
manufacturer={product.manufacturer}
vat={product.vat}
cGrundEinheit={product.cGrundEinheit}
fGrundPreis={product.fGrundPreis}
massMenge={product.massMenge}
massEinheit={product.massEinheit}
incoming={product.incomingDate}
neu={product.neu}
thc={product.thc}
floweringWeeks={product.floweringWeeks}
versandklasse={product.versandklasse}
weight={product.weight}
socket={this.props.socket}
socketB={this.props.socketB}
pictureList={product.pictureList}
availableSupplier={product.availableSupplier}
komponenten={product.komponenten}
rebate={product.rebate}
categoryId={product.kategorien ? product.kategorien.split(',')[0] : undefined}
priority={index < 6 ? 'high' : 'auto'}
t={this.props.t}
/>
</Grid>
))}
@@ -499,4 +495,4 @@ class ProductList extends Component {
}
}
export default withI18n()(ProductList);
export default ProductList;

View File

@@ -1,410 +0,0 @@
import React from 'react';
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight";
import CategoryBox from "./CategoryBox.js";
import ProductCarousel from "./ProductCarousel.js";
import { withTranslation } from 'react-i18next';
import { withLanguage } from '../i18n/withTranslation.js';
const ITEM_WIDTH = 130 + 16; // 130px width + 16px gap
const AUTO_SCROLL_SPEED = 0.5; // px per frame (~60fps, so ~30px/sec)
const AUTOSCROLL_RESTART_DELAY = 5000; // 5 seconds of inactivity before restarting autoscroll
const SCROLLBAR_FLASH_DURATION = 3000; // 3 seconds to show the virtual scrollbar
class SharedCarousel extends React.Component {
_isMounted = false;
categories = [];
originalCategories = [];
animationFrame = null;
autoScrollActive = true;
translateX = 0;
inactivityTimer = null;
scrollbarTimer = null;
constructor(props) {
super(props);
const { i18n } = props;
// Don't load categories in constructor - will be loaded in componentDidMount with correct language
this.state = {
categories: [],
currentLanguage: (i18n && i18n.language) || 'de',
showScrollbar: false,
};
this.carouselTrackRef = React.createRef();
}
componentDidMount() {
this._isMounted = true;
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
// ALWAYS reload categories to ensure correct language
console.log("SharedCarousel componentDidMount: ALWAYS RELOADING categories for language", currentLanguage);
window.categoryService.get(209, currentLanguage).then((response) => {
console.log("SharedCarousel categoryService.get response for language '" + currentLanguage + "':", response);
if (this._isMounted && response.children && response.children.length > 0) {
console.log("SharedCarousel: Setting categories with", response.children.length, "items");
console.log("SharedCarousel: First category name:", response.children[0]?.name);
this.originalCategories = response.children;
// Duplicate for seamless looping
this.categories = [...response.children, ...response.children];
this.setState({ categories: this.categories });
this.startAutoScroll();
}
});
}
componentDidUpdate(prevProps) {
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
if(prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
this.setState({ categories: [] },() => {
window.categoryService.get(209,this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
console.log("response", response);
if (response.children && response.children.length > 0) {
this.originalCategories = response.children;
this.categories = [...response.children, ...response.children];
this.setState({ categories: this.categories });
this.startAutoScroll();
}
});
});
}
}
componentWillUnmount() {
this._isMounted = false;
this.stopAutoScroll();
this.clearInactivityTimer();
this.clearScrollbarTimer();
}
startAutoScroll = () => {
this.autoScrollActive = true;
if (!this.animationFrame) {
this.animationFrame = requestAnimationFrame(this.handleAutoScroll);
}
};
stopAutoScroll = () => {
this.autoScrollActive = false;
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
};
clearInactivityTimer = () => {
if (this.inactivityTimer) {
clearTimeout(this.inactivityTimer);
this.inactivityTimer = null;
}
};
clearScrollbarTimer = () => {
if (this.scrollbarTimer) {
clearTimeout(this.scrollbarTimer);
this.scrollbarTimer = null;
}
};
startInactivityTimer = () => {
this.clearInactivityTimer();
this.inactivityTimer = setTimeout(() => {
if (this._isMounted) {
this.startAutoScroll();
}
}, AUTOSCROLL_RESTART_DELAY);
};
showScrollbarFlash = () => {
this.clearScrollbarTimer();
this.setState({ showScrollbar: true });
this.scrollbarTimer = setTimeout(() => {
if (this._isMounted) {
this.setState({ showScrollbar: false });
}
}, SCROLLBAR_FLASH_DURATION);
};
handleAutoScroll = () => {
if (!this.autoScrollActive || this.originalCategories.length === 0) return;
this.translateX -= AUTO_SCROLL_SPEED;
this.updateTrackTransform();
const originalItemCount = this.originalCategories.length;
const maxScroll = ITEM_WIDTH * originalItemCount;
// Check if we've scrolled past the first set of items
if (Math.abs(this.translateX) >= maxScroll) {
// Reset to beginning seamlessly
this.translateX = 0;
this.updateTrackTransform();
}
this.animationFrame = requestAnimationFrame(this.handleAutoScroll);
};
updateTrackTransform = () => {
if (this.carouselTrackRef.current) {
this.carouselTrackRef.current.style.transform = `translateX(${this.translateX}px)`;
}
};
handleLeftClick = () => {
this.stopAutoScroll();
this.scrollBy(1);
this.showScrollbarFlash();
this.startInactivityTimer();
};
handleRightClick = () => {
this.stopAutoScroll();
this.scrollBy(-1);
this.showScrollbarFlash();
this.startInactivityTimer();
};
scrollBy = (direction) => {
if (this.originalCategories.length === 0) return;
// direction: 1 = left (scroll content right), -1 = right (scroll content left)
const originalItemCount = this.originalCategories.length;
const maxScroll = ITEM_WIDTH * originalItemCount;
this.translateX += direction * ITEM_WIDTH;
// Handle wrap-around when scrolling left (positive translateX)
if (this.translateX > 0) {
this.translateX = -(maxScroll - ITEM_WIDTH);
}
// Handle wrap-around when scrolling right (negative translateX beyond limit)
else if (Math.abs(this.translateX) >= maxScroll) {
this.translateX = 0;
}
this.updateTrackTransform();
// Force scrollbar to update immediately after wrap-around
if (this.state.showScrollbar) {
this.forceUpdate();
}
};
renderVirtualScrollbar = () => {
if (!this.state.showScrollbar || this.originalCategories.length === 0) {
return null;
}
const originalItemCount = this.originalCategories.length;
const viewportWidth = 1080; // carousel container max-width
const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH);
// Calculate which item is currently at the left edge (first visible)
// Map translateX directly to item index using the same logic as scrollBy
let currentItemIndex;
if (this.translateX === 0) {
// At the beginning - item 0 is visible
currentItemIndex = 0;
} else if (this.translateX > 0) {
// Wrapped to show end items (this happens when scrolling left past beginning)
const maxScroll = ITEM_WIDTH * originalItemCount;
const effectivePosition = maxScroll + this.translateX;
currentItemIndex = Math.floor(effectivePosition / ITEM_WIDTH);
} else {
// Normal negative scrolling - calculate which item is at left edge
currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH);
}
// Ensure we stay within bounds
currentItemIndex = Math.max(0, Math.min(currentItemIndex, originalItemCount - 1));
// Calculate scrollbar position: 0% when item 0 is first visible, 100% when last item is first visible
const lastPossibleFirstItem = Math.max(0, originalItemCount - itemsInView);
const thumbPosition = lastPossibleFirstItem > 0 ? Math.min((currentItemIndex / lastPossibleFirstItem) * 100, 100) : 0;
return (
<div
className="virtual-scrollbar"
style={{
position: 'absolute',
bottom: '5px',
left: '50%',
transform: 'translateX(-50%)',
width: '200px',
height: '4px',
backgroundColor: 'rgba(0, 0, 0, 0.1)',
borderRadius: '2px',
zIndex: 1000,
opacity: this.state.showScrollbar ? 1 : 0,
transition: 'opacity 0.3s ease-in-out'
}}
>
<div
className="scrollbar-thumb"
style={{
position: 'absolute',
top: '0',
left: `${thumbPosition}%`,
width: '20px',
height: '4px',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: '2px',
transform: 'translateX(-50%)',
transition: 'left 0.2s ease-out'
}}
/>
</div>
);
};
render() {
const { t } = this.props;
const { categories } = this.state;
if(!categories || categories.length === 0) {
return null;
}
return (
<Box sx={{ mt: 3 }}>
<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
aria-label="Vorherige Kategorien anzeigen"
onClick={this.handleLeftClick}
style={{
position: 'absolute',
top: '50%',
left: '8px',
transform: 'translateY(-50%)',
zIndex: 1200,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
height: '48px',
borderRadius: '50%'
}}
>
<ChevronLeft />
</IconButton>
{/* Right Arrow */}
<IconButton
aria-label="Nächste Kategorien anzeigen"
onClick={this.handleRightClick}
style={{
position: 'absolute',
top: '50%',
right: '8px',
transform: 'translateY(-50%)',
zIndex: 1200,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
height: '48px',
borderRadius: '50%'
}}
>
<ChevronRight />
</IconButton>
<div
className="carousel-container"
style={{
position: 'relative',
overflow: 'hidden',
padding: '20px 0',
width: '100%',
maxWidth: '1080px',
margin: '0 auto',
zIndex: 1,
boxSizing: 'border-box'
}}
>
<div
className="home-carousel-track"
ref={this.carouselTrackRef}
style={{
display: 'flex',
gap: '16px',
transition: 'none',
alignItems: 'flex-start',
width: 'fit-content',
overflow: 'visible',
position: 'relative',
transform: 'translateX(0px)',
margin: '0 auto'
}}
>
{categories.map((category, index) => (
<div
key={`${category.id}-${index}`}
className="carousel-item"
style={{
flex: '0 0 130px',
width: '130px',
maxWidth: '130px',
minWidth: '130px',
height: '130px',
maxHeight: '130px',
minHeight: '130px',
boxSizing: 'border-box',
position: 'relative'
}}
>
<CategoryBox
id={category.id}
name={category.name}
seoName={category.seoName}
image={category.image}
bgcolor={category.bgcolor}
/>
</div>
))}
</div>
{/* Virtual Scrollbar */}
{this.renderVirtualScrollbar()}
</div>
</div>
{/* Product Carousel for "neu" category */}
<ProductCarousel categoryId="neu" />
</Box>
);
}
}
export default withTranslation()(withLanguage(SharedCarousel));

View File

@@ -1,13 +1,12 @@
import React, { Component } from 'react';
import Grid from '@mui/material/Grid';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardMedia from '@mui/material/CardMedia';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import CircularProgress from '@mui/material/CircularProgress';
import { Link } from 'react-router-dom';
import IconButton from '@mui/material/IconButton';
import ZoomInIcon from '@mui/icons-material/ZoomIn';
import Checkbox from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel';
class ExtrasSelector extends Component {
formatPrice(price) {
@@ -17,178 +16,127 @@ class ExtrasSelector extends Component {
}).format(price);
}
// Render product image using working code from GrowTentKonfigurator
renderProductImage(product) {
if (!window.smallPicCache) {
window.smallPicCache = {};
}
const pictureList = product.pictureList;
if (!pictureList || pictureList.length === 0 || !pictureList.split(',').length) {
return (
<CardMedia
component="img"
height="160"
image="/assets/images/nopicture.jpg"
alt={product.name || 'Produktbild'}
sx={{
objectFit: 'contain',
width: '100%'
}}
/>
);
}
const bildId = pictureList.split(',')[0];
if (window.smallPicCache[bildId]) {
return (
<CardMedia
component="img"
height="160"
image={window.smallPicCache[bildId]}
alt={product.name || 'Produktbild'}
sx={{
objectFit: 'contain',
width: '100%'
}}
/>
);
}
// Load image if not cached
if (!this.loadingImages) this.loadingImages = new Set();
if (!this.loadingImages.has(bildId)) {
this.loadingImages.add(bildId);
window.socketManager.emit('getPic', { bildId, size:'small' }, (res) => {
if (res.success) {
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
this.forceUpdate();
}
this.loadingImages.delete(bildId);
});
}
return (
<Box sx={{ height: '160px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress sx={{ color: '#90ffc0' }} />
</Box>
);
}
renderExtraCard(extra) {
const { selectedExtras, onExtraToggle, showImage = true } = this.props;
const isSelected = selectedExtras.includes(extra.id);
return (
<Box sx={{
width: { xs: '100%', sm: '250px' },
<Card
key={extra.id}
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
borderRadius: '8px',
overflow: 'hidden',
cursor: 'pointer',
border: '2px solid',
borderColor: isSelected ? '#2e7d32' : '#e0e0e0',
backgroundColor: isSelected ? '#f1f8e9' : '#ffffff',
'&:hover': {
boxShadow: 6,
boxShadow: 5,
borderColor: isSelected ? '#2e7d32' : '#90caf9'
},
transition: 'all 0.3s ease'
transition: 'all 0.3s ease',
cursor: 'pointer'
}}
onClick={() => onExtraToggle(extra.id)}>
{/* Image */}
onClick={() => onExtraToggle(extra.id)}
>
{showImage && (
<Box sx={{
height: { xs: '240px', sm: '180px' },
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#ffffff'
}}>
{this.renderProductImage(extra)}
</Box>
<CardMedia
component="img"
height="160"
image={extra.image}
alt={extra.name}
sx={{ objectFit: 'cover' }}
/>
)}
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 2 }}>
<FormControlLabel
control={
<Checkbox
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
onExtraToggle(extra.id);
}}
sx={{
color: '#2e7d32',
'&.Mui-checked': { color: '#2e7d32' },
padding: 0
}}
/>
}
label=""
sx={{ margin: 0 }}
onClick={(e) => e.stopPropagation()}
/>
<Typography variant="h6" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
{this.formatPrice(extra.price)}
</Typography>
</Box>
{/* Content */}
<Box sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
{/* Name */}
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
{extra.name}
</Typography>
<Typography gutterBottom>
{extra.kurzBeschreibung}
<Typography variant="body2" color="text.secondary">
{extra.description}
</Typography>
{/* Price with VAT - Same as other sections */}
<Typography variant="h6" sx={{
color: '#2e7d32',
fontWeight: 'bold',
mt: 2,
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap'
}}>
<span>{extra.price ? this.formatPrice(extra.price) : 'Kein Preis'}</span>
{extra.vat && (
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>
(incl. {extra.vat}% MwSt.,*)
</small>
)}
</Typography>
{/* Selection Indicator - Separate line */}
{isSelected && (
<Typography variant="body2" sx={{
color: '#2e7d32',
fontWeight: 'bold',
mt: 1,
textAlign: 'center'
}}>
Ausgewählt
<Box sx={{ mt: 2, textAlign: 'center' }}>
<Typography variant="body2" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
Hinzugefügt
</Typography>
</Box>
)}
<Stack direction="row" spacing={1} justifyContent="center">
<IconButton
component={Link}
to={`/Artikel/${extra.seoName}`}
size="small"
aria-label="Produktdetails anzeigen"
sx={{ mr: 1, color: 'text.secondary' }}
onClick={(event) => event.stopPropagation()}
>
<ZoomInIcon />
</IconButton>
</Stack>
</Box>
</Box>
</CardContent>
</Card>
);
}
render() {
const { extras, title, subtitle, gridSize = { xs: 12, sm: 6, md: 4 } } = this.props;
const { extras, title, subtitle, groupByCategory = true, gridSize = { xs: 12, sm: 6, md: 4 } } = this.props;
if (groupByCategory) {
// Group extras by category
const groupedExtras = extras.reduce((acc, extra) => {
if (!acc[extra.category]) {
acc[extra.category] = [];
}
acc[extra.category].push(extra);
return acc;
}, {});
if (!extras || !Array.isArray(extras)) {
return (
<Box sx={{ mb: 4 }}>
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
{title}
</Typography>
{subtitle && (
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Keine Extras verfügbar
{subtitle}
</Typography>
)}
{Object.entries(groupedExtras).map(([category, categoryExtras]) => (
<Box key={category} sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom sx={{ color: '#1976d2' }}>
{category}
</Typography>
<Grid container spacing={2}>
{categoryExtras.map(extra => (
<Grid item {...gridSize} key={extra.id}>
{this.renderExtraCard(extra)}
</Grid>
))}
</Grid>
</Box>
))}
</Box>
);
}
// Render without category grouping
return (
<Box sx={{ mb: 4 }}>
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
{title}
</Typography>
{subtitle && (

View File

@@ -6,10 +6,6 @@ import CardMedia from '@mui/material/CardMedia';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack';
import { Link } from 'react-router-dom';
import IconButton from '@mui/material/IconButton';
import ZoomInIcon from '@mui/icons-material/ZoomIn';
class ProductSelector extends Component {
formatPrice(price) {
@@ -69,19 +65,6 @@ class ProductSelector extends Component {
Ausgewählt
</Typography>
)}
<Stack direction="row" spacing={1} justifyContent="center">
<IconButton
component={Link}
to={`/Artikel/${product.seoName}`}
size="small"
aria-label="Produktdetails anzeigen"
sx={{ mr: 1, color: 'text.secondary' }}
onClick={(event) => event.stopPropagation()}
>
<ZoomInIcon />
</IconButton>
</Stack>
</Box>
</CardContent>
</Card>
@@ -164,7 +147,7 @@ class ProductSelector extends Component {
return (
<Box sx={{ mb: 4 }}>
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
{title}
</Typography>
{subtitle && (

View File

@@ -90,7 +90,7 @@ class TentShapeSelector extends Component {
onClick={() => onShapeSelect(shape.id)}
>
<CardContent sx={{ textAlign: 'center', p: 3 }}>
<Typography variant="h4" component="h4" gutterBottom sx={{ fontWeight: 'bold' }}>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 'bold' }}>
{shape.name}
</Typography>
@@ -218,7 +218,7 @@ class TentShapeSelector extends Component {
return (
<Box sx={{ mb: 4 }}>
<Typography variant="h2" component="h2" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
{title}
</Typography>
{subtitle && (

View File

@@ -1,4 +1,4 @@
import React, { Component, lazy } from 'react';
import React, { Component } from 'react';
import Box from '@mui/material/Box';
import Badge from '@mui/material/Badge';
import Drawer from '@mui/material/Drawer';
@@ -8,18 +8,9 @@ import Typography from '@mui/material/Typography';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import CloseIcon from '@mui/icons-material/Close';
import { useNavigate } from 'react-router-dom';
import CircularProgress from '@mui/material/CircularProgress';
import { Suspense } from 'react';
//import LoginComponent from '../LoginComponent.js';
const LoginComponent = lazy(() => import(/* webpackChunkName: "login" */ "../LoginComponent.js"));
import LoginComponent from '../LoginComponent.js';
import CartDropdown from '../CartDropdown.js';
import LanguageSwitcher from '../LanguageSwitcher.js';
import { isUserLoggedIn } from '../LoginComponent.js';
import { withI18n } from '../../i18n/withTranslation.js';
function getBadgeNumber() {
let count = 0;
@@ -41,8 +32,9 @@ class ButtonGroup extends Component {
componentDidMount() {
this.cart = () => {
if (!this.isUpdatingFromSocket) {
window.socketManager.emit('updateCart', window.cart);
// @note Only emit if socket exists, is connected, AND the update didn't come from socket
if (this.props.socket && this.props.socket.connected && !this.isUpdatingFromSocket) {
this.props.socket.emit('updateCart', window.cart);
}
this.setState({
@@ -59,6 +51,19 @@ class ButtonGroup extends Component {
this.addSocketListeners();
}
componentDidUpdate(prevProps) {
// Handle socket connection changes
const wasConnected = prevProps.socket && prevProps.socket.connected;
const isNowConnected = this.props.socket && this.props.socket.connected;
if (!wasConnected && isNowConnected) {
// Socket just connected, add listeners
this.addSocketListeners();
} else if (wasConnected && !isNowConnected) {
// Socket just disconnected, remove listeners
this.removeSocketListeners();
}
}
componentWillUnmount() {
window.removeEventListener('cart', this.cart);
@@ -67,17 +72,16 @@ class ButtonGroup extends Component {
}
addSocketListeners = () => {
if (this.props.socket && this.props.socket.connected) {
// Remove existing listeners first to avoid duplicates
this.removeSocketListeners();
if (window.socketManager) {
window.socketManager.on('cartUpdated', this.handleCartUpdated);
this.props.socket.on('cartUpdated', this.handleCartUpdated);
}
}
removeSocketListeners = () => {
if (window.socketManager) {
window.socketManager.off('cartUpdated', this.handleCartUpdated);
if (this.props.socket) {
this.props.socket.off('cartUpdated', this.handleCartUpdated);
}
}
@@ -112,22 +116,19 @@ class ButtonGroup extends Component {
}
render() {
const { navigate, t } = this.props;
const { socket, navigate } = this.props;
const { isCartOpen } = this.state;
const cartItems = Array.isArray(window.cart) ? window.cart : [];
return (
<Box sx={{ display: 'flex', gap: { xs: 0.5, sm: 1 } }}>
<LanguageSwitcher />
<Suspense fallback={<CircularProgress size={20} />}>
<LoginComponent/>
</Suspense>
<LoginComponent socket={socket} />
<IconButton
color="inherit"
onClick={this.toggleCart}
aria-label="Warenkorb öffnen"
sx={{ ml: 1 }}
>
<Badge badgeContent={this.state.badgeNumber} color="error">
@@ -153,7 +154,6 @@ class ButtonGroup extends Component {
<IconButton
onClick={this.toggleCart}
size="small"
aria-label="Warenkorb schließen"
sx={{
bgcolor: 'primary.main',
color: 'primary.contrastText',
@@ -164,16 +164,16 @@ class ButtonGroup extends Component {
>
<CloseIcon />
</IconButton>
<Typography variant="h6">{t ? t('cart.title') : 'Warenkorb'}</Typography>
<Typography variant="h6">Warenkorb</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<CartDropdown cartItems={cartItems} onClose={this.toggleCart} onCheckout={()=>{
<CartDropdown cartItems={cartItems} socket={socket} onClose={this.toggleCart} onCheckout={()=>{
/*open the Drawer inside <LoginComponent */
if (isUserLoggedIn().isLoggedIn) {
this.toggleCart(); // Close the cart drawer
navigate('/profile#cart');
navigate('/profile');
} else if (window.openLoginDrawer) {
window.openLoginDrawer(); // Call global function to open login drawer
this.toggleCart(); // Close the cart drawer
@@ -189,11 +189,10 @@ class ButtonGroup extends Component {
}
}
// Wrapper for ButtonGroup to provide navigate function and translations
// Wrapper for ButtonGroup to provide navigate function
const ButtonGroupWithRouter = (props) => {
const navigate = useNavigate();
const ButtonGroupWithTranslation = withI18n()(ButtonGroup);
return <ButtonGroupWithTranslation {...props} navigate={navigate} />;
return <ButtonGroup {...props} navigate={navigate} />;
};
export default ButtonGroupWithRouter;

View File

@@ -6,137 +6,316 @@ import Typography from "@mui/material/Typography";
import Collapse from "@mui/material/Collapse";
import { Link } from "react-router-dom";
import HomeIcon from "@mui/icons-material/Home";
import FiberNewIcon from '@mui/icons-material/FiberNew';
import SettingsIcon from "@mui/icons-material/Settings";
import MenuIcon from "@mui/icons-material/Menu";
import CloseIcon from "@mui/icons-material/Close";
import { withI18n } from "../../i18n/withTranslation.js";
class CategoryList extends Component {
findCategoryById = (category, targetId) => {
if (!category) return null;
if (category.seoName === targetId) {
return category;
}
if (category.children) {
for (let child of category.children) {
const found = this.findCategoryById(child, targetId);
if (found) return found;
}
}
return null;
};
getPathToCategory = (category, targetId, currentPath = []) => {
if (!category) return null;
const newPath = [...currentPath, category];
if (category.seoName === targetId) {
return newPath;
}
if (category.children) {
for (let child of category.children) {
const found = this.getPathToCategory(child, targetId, newPath);
if (found) return found;
}
}
return null;
};
constructor(props) {
super(props);
//const { i18n } = props;
const categories = window.categoryService.getSync(209);
this.state = {
categories: categories && categories.children && categories.children.length > 0 ? categories.children : [],
mobileMenuOpen: false,
activeCategoryId: null // Will be set properly after categories are loaded
// Check for cached data during SSR/initial render
let initialState = {
categoryTree: null,
level1Categories: [], // Children of category 209 (Home) - always shown
level2Categories: [], // Children of active level 1 category
level3Categories: [], // Children of active level 2 category
activePath: [], // Array of active category objects for each level
fetchedCategories: false,
mobileMenuOpen: false, // State for mobile collapsible menu
};
this.productCategoryCheckInterval = null;
// Try to get cached data for SSR
try {
// @note Check both global.window (SSR) and window (browser) for cache
const productCache = (typeof global !== "undefined" && global.window && global.window.productCache) ||
(typeof window !== "undefined" && window.productCache);
if (productCache) {
const cacheKey = "categoryTree_209";
const cachedData = productCache[cacheKey];
if (cachedData && cachedData.categoryTree) {
const { categoryTree, timestamp } = cachedData;
const cacheAge = Date.now() - timestamp;
const tenMinutes = 10 * 60 * 1000;
// Use cached data if it's fresh
if (cacheAge < tenMinutes) {
initialState.categoryTree = categoryTree;
initialState.fetchedCategories = true;
// Process category tree to set up navigation
const level1Categories =
categoryTree && categoryTree.id === 209
? categoryTree.children || []
: [];
initialState.level1Categories = level1Categories;
// Process active category path if needed
if (props.activeCategoryId) {
const activeCategory = this.findCategoryById(
categoryTree,
props.activeCategoryId
);
if (activeCategory) {
const pathToActive = this.getPathToCategory(
categoryTree,
props.activeCategoryId
);
initialState.activePath = pathToActive
? pathToActive.slice(1)
: [];
if (initialState.activePath.length >= 1) {
const level1Category = initialState.activePath[0];
initialState.level2Categories = level1Category.children || [];
}
if (initialState.activePath.length >= 2) {
const level2Category = initialState.activePath[1];
initialState.level3Categories = level2Category.children || [];
}
}
}
}
}
}
} catch (err) {
console.error("Error reading cache in constructor:", err);
}
this.state = initialState;
}
componentDidMount() {
console.log("CategoryList componentDidMount - Debug info:");
console.log(" languageContext:", this.props.languageContext);
console.log(" i18n.language:", this.props.i18n?.language);
console.log(" sessionStorage i18nextLng:", typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('i18nextLng') : 'N/A');
console.log(" localStorage i18nextLng:", typeof localStorage !== 'undefined' ? localStorage.getItem('i18nextLng') : 'N/A');
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
// ALWAYS reload categories to ensure correct language
console.log("CategoryList componentDidMount: ALWAYS RELOADING categories for language", currentLanguage);
this.setState({ categories: [] }); // Clear any cached categories
window.categoryService.get(209, currentLanguage).then((response) => {
console.log("categoryService.get response for language '" + currentLanguage + "':", response);
if (response.children && response.children.length > 0) {
console.log("Setting categories with", response.children.length, "items");
console.log("First category name:", response.children[0]?.name);
this.setState({
categories: response.children,
activeCategoryId: this.setLevel1CategoryId(this.props.activeCategoryId)
});
}
});
this.fetchCategories();
}
componentDidUpdate(prevProps) {
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
if(prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
this.setState({
categories: [],
activeCategoryId: null
},() => {
window.categoryService.get(209,this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
console.log("response", response);
if (response.children && response.children.length > 0) {
this.setState({
categories: response.children,
activeCategoryId: this.setLevel1CategoryId(this.props.activeCategoryId)
});
// Handle socket connection changes
const wasConnected = prevProps.socket && prevProps.socket.connected;
const isNowConnected = this.props.socket && this.props.socket.connected;
if (!wasConnected && isNowConnected && !this.state.fetchedCategories) {
// Socket just connected and we haven't fetched categories yet
this.setState(
{
fetchedCategories: false,
},
() => {
this.fetchCategories();
}
});
});
);
}
if (prevProps.activeCategoryId !== this.props.activeCategoryId) {
this.setLevel1CategoryId(this.props.activeCategoryId);
// If activeCategoryId changes, update subcategories
if (
prevProps.activeCategoryId !== this.props.activeCategoryId &&
this.state.categoryTree
) {
this.processCategoryTree(this.state.categoryTree);
}
}
setLevel1CategoryId = (input) => {
if(input) {
const language = this.props.languageContext?.currentLanguage || this.props.i18n.language;
const categoryTreeCache = window.categoryService.getSync(209, language);
if (categoryTreeCache && categoryTreeCache.children) {
let level1CategoryId = null;
// Check if input is already a numeric level 1 category ID
const inputAsNumber = parseInt(input);
if (!isNaN(inputAsNumber)) {
// Check if this is already a level 1 category ID
const level1Category = categoryTreeCache.children.find(cat => cat.id === inputAsNumber);
if (level1Category) {
console.log("Input is already a level 1 category ID:", inputAsNumber);
level1CategoryId = inputAsNumber;
} else {
// It's a category ID, find its level 1 parent
const findLevel1FromId = (categories, targetId) => {
for (const category of categories) {
if (category.id === targetId) {
return category.parentId === 209 ? category.id : findLevel1FromId(categoryTreeCache.children, category.parentId);
}
if (category.children && category.children.length > 0) {
const result = findLevel1FromId(category.children, targetId);
if (result) return result;
}
}
return null;
};
level1CategoryId = findLevel1FromId(categoryTreeCache.children, inputAsNumber);
}
} else {
// It's an SEO name, find the level 1 category
const findLevel1FromSeoName = (categories, targetSeoName, level1Id = null) => {
for (const category of categories) {
const currentLevel1Id = level1Id || category.id;
if (category.seoName === targetSeoName) {
return currentLevel1Id;
}
if (category.children && category.children.length > 0) {
const result = findLevel1FromSeoName(category.children, targetSeoName, currentLevel1Id);
if (result) return result;
}
}
return null;
};
level1CategoryId = findLevel1FromSeoName(categoryTreeCache.children, input);
}
this.setState({
activeCategoryId: level1CategoryId
});
fetchCategories = () => {
const { socket } = this.props;
if (!socket || !socket.connected) {
// Socket not connected yet, but don't show error immediately on first load
// The componentDidUpdate will retry when socket connects
console.log("Socket not connected yet, waiting for connection to fetch categories");
return;
}
}
this.setState({ activeCategoryId: null });
if (this.state.fetchedCategories) {
console.log('Categories already fetched, skipping');
return;
}
// Initialize global cache object if it doesn't exist
// @note Handle both SSR (global.window) and browser (window) environments
const windowObj = (typeof global !== "undefined" && global.window) ||
(typeof window !== "undefined" && window);
if (windowObj && !windowObj.productCache) {
windowObj.productCache = {};
}
// Check if we have a valid cache in the global object
try {
const cacheKey = "categoryTree_209";
const cachedData = windowObj && windowObj.productCache ? windowObj.productCache[cacheKey] : null;
if (cachedData) {
const { categoryTree, fetching } = cachedData;
//const cacheAge = Date.now() - timestamp;
//const tenMinutes = 10 * 60 * 1000; // 10 minutes in milliseconds
// If data is currently being fetched, wait for it
if (fetching) {
//console.log('CategoryList: Data is being fetched, waiting...');
const checkInterval = setInterval(() => {
const currentCache = windowObj && windowObj.productCache ? windowObj.productCache[cacheKey] : null;
if (currentCache && !currentCache.fetching) {
clearInterval(checkInterval);
if (currentCache.categoryTree) {
this.processCategoryTree(currentCache.categoryTree);
}
}
}, 100);
return;
}
// If cache is less than 10 minutes old, use it
if (/*cacheAge < tenMinutes &&*/ categoryTree) {
//console.log('Using cached category tree, age:', Math.round(cacheAge/1000), 'seconds');
// Defer processing to next tick to avoid blocking
//setTimeout(() => {
this.processCategoryTree(categoryTree);
//}, 0);
//return;
}
}
} catch (err) {
console.error("Error reading from cache:", err);
}
// Mark as being fetched to prevent concurrent calls
const cacheKey = "categoryTree_209";
if (windowObj && windowObj.productCache) {
windowObj.productCache[cacheKey] = {
fetching: true,
timestamp: Date.now(),
};
}
this.setState({ fetchedCategories: true });
//console.log('CategoryList: Fetching categories from socket');
socket.emit("categoryList", { categoryId: 209 }, (response) => {
if (response && response.categoryTree) {
// Store in global cache with timestamp
try {
const cacheKey = "categoryTree_209";
if (windowObj && windowObj.productCache) {
windowObj.productCache[cacheKey] = {
categoryTree: response.categoryTree,
timestamp: Date.now(),
fetching: false,
};
}
} catch (err) {
console.error("Error writing to cache:", err);
}
this.processCategoryTree(response.categoryTree);
} else {
try {
const cacheKey = "categoryTree_209";
if (windowObj && windowObj.productCache) {
windowObj.productCache[cacheKey] = {
categoryTree: null,
timestamp: Date.now(),
};
}
} catch (err) {
console.error("Error writing to cache:", err);
}
this.setState({
categoryTree: null,
level1Categories: [],
level2Categories: [],
level3Categories: [],
activePath: [],
});
}
});
};
processCategoryTree = (categoryTree) => {
// Level 1 categories are always the children of category 209 (Home)
const level1Categories =
categoryTree && categoryTree.id === 209
? categoryTree.children || []
: [];
// Build the navigation path and determine what to show at each level
let level2Categories = [];
let level3Categories = [];
let activePath = [];
if (this.props.activeCategoryId) {
const activeCategory = this.findCategoryById(
categoryTree,
this.props.activeCategoryId
);
if (activeCategory) {
// Build the path from root to active category
const pathToActive = this.getPathToCategory(
categoryTree,
this.props.activeCategoryId
);
activePath = pathToActive.slice(1); // Remove root (209) from path
// Determine what to show at each level based on the path depth
if (activePath.length >= 1) {
// Show children of the level 1 category
const level1Category = activePath[0];
level2Categories = level1Category.children || [];
}
if (activePath.length >= 2) {
// Show children of the level 2 category
const level2Category = activePath[1];
level3Categories = level2Category.children || [];
}
}
}
this.setState({
categoryTree,
level1Categories,
level2Categories,
level3Categories,
activePath,
fetchedCategories: true,
});
};
handleMobileMenuToggle = () => {
this.setState(prevState => ({
@@ -151,17 +330,11 @@ class CategoryList extends Component {
});
};
componentWillUnmount() {
if (this.productCategoryCheckInterval) {
clearInterval(this.productCategoryCheckInterval);
this.productCategoryCheckInterval = null;
}
}
render() {
const { categories, mobileMenuOpen, activeCategoryId } = this.state;
const { level1Categories, activePath, mobileMenuOpen } =
this.state;
const renderCategoryRow = (categories, isMobile = false) => (
const renderCategoryRow = (categories, level = 1, isMobile = false) => (
<Box
sx={{
display: "flex",
@@ -178,6 +351,7 @@ class CategoryList extends Component {
msOverflowStyle: "none",
}}
>
{level === 1 && (
<Button
component={Link}
to="/"
@@ -198,7 +372,7 @@ class CategoryList extends Component {
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(activeCategoryId === null && {
...(this.props.activeCategoryId === null && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
@@ -222,7 +396,7 @@ class CategoryList extends Component {
<HomeIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: activeCategoryId === null ? "#2e7d32" : "inherit"
color: this.props.activeCategoryId === null ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
@@ -231,93 +405,38 @@ class CategoryList extends Component {
className="bold-text"
sx={{
fontWeight: "bold",
color: activeCategoryId === null ? "#2e7d32" : "transparent",
color: this.props.activeCategoryId === null ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
Startseite
</Box>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: activeCategoryId === null ? "transparent" : "inherit",
color: this.props.activeCategoryId === null ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
Startseite
</Box>
</Box>
)}
</Button>
<Button
component={Link}
to="/Kategorie/neu"
color="inherit"
size="small"
aria-label="Neuheiten"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
mx: isMobile ? 0 : 0.5,
my: 0.25,
minWidth: isMobile ? "100%" : "auto",
borderRadius: 1,
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative"
}}
>
<FiberNewIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
{/* Bold text (always rendered to set width) */}
<Box
className="bold-text"
sx={{
fontWeight: "bold",
color: "transparent",
position: "relative",
zIndex: 2,
}}
>
{this.props.t ? this.props.t('navigation.new') : 'Neuheiten'}
</Box>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.new') : 'Neuheiten'}
</Box>
</Box>
)}
</Button>
{categories.length > 0 ? (
{this.state.fetchedCategories && categories.length > 0 ? (
<>
{categories.map((category) => {
// Determine if this category is active at this level
const isActiveAtThisLevel =
activePath[level - 1] &&
activePath[level - 1].id === category.id;
return (
<Button
@@ -340,7 +459,7 @@ class CategoryList extends Component {
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(activeCategoryId === category.id && {
...(isActiveAtThisLevel && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
@@ -364,7 +483,7 @@ class CategoryList extends Component {
className="bold-text"
sx={{
fontWeight: "bold",
color: activeCategoryId === category.id ? "#2e7d32" : "transparent",
color: isActiveAtThisLevel ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
@@ -376,7 +495,7 @@ class CategoryList extends Component {
className="thin-text"
sx={{
fontWeight: "400",
color: activeCategoryId === category.id ? "transparent" : "inherit",
color: isActiveAtThisLevel ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
@@ -390,14 +509,15 @@ class CategoryList extends Component {
);
})}
</>
) : ( !isMobile && (
) : (
level === 1 && !isMobile && (
<Typography
variant="caption"
color="inherit"
sx={{
display: "inline-flex",
alignItems: "center",
height: "33px", // Match small button height
height: "30px", // Match small button height
px: 1,
fontSize: "0.75rem",
opacity: 0.9,
@@ -407,84 +527,6 @@ class CategoryList extends Component {
</Typography>
)
)}
<Button
component={Link}
to="/Konfigurator"
color="inherit"
size="small"
aria-label="Zur Startseite"
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
textTransform: "none",
whiteSpace: "nowrap",
opacity: 0.9,
mx: isMobile ? 0 : 0.5,
my: 0.25,
minWidth: isMobile ? "100%" : "auto",
borderRadius: 1,
justifyContent: isMobile ? "flex-start" : "center",
transition: "all 0.2s ease",
textShadow: "0 1px 2px rgba(0,0,0,0.3)",
position: "relative",
...(activeCategoryId === null && {
bgcolor: "#fff",
textShadow: "none",
opacity: 1,
}),
"&:hover": {
opacity: 1,
bgcolor: "#fff",
textShadow: "none",
"& .MuiSvgIcon-root": {
color: "#2e7d32 !important",
},
"& .bold-text": {
color: "#2e7d32 !important",
},
"& .thin-text": {
color: "transparent !important",
},
},
}}
>
<SettingsIcon sx={{
fontSize: "1rem",
mr: isMobile ? 1 : 0,
color: activeCategoryId === null ? "#2e7d32" : "inherit"
}} />
{isMobile && (
<Box sx={{ position: "relative", display: "inline-block" }}>
{/* Bold text (always rendered to set width) */}
<Box
className="bold-text"
sx={{
fontWeight: "bold",
color: activeCategoryId === null ? "#2e7d32" : "transparent",
position: "relative",
zIndex: 2,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
{/* Thin text (positioned on top) */}
<Box
className="thin-text"
sx={{
fontWeight: "400",
color: activeCategoryId === null ? "transparent" : "inherit",
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
</Box>
</Box>
)}
</Button>
</Box>
);
@@ -507,7 +549,25 @@ class CategoryList extends Component {
}}
>
<Container maxWidth="lg" sx={{ px: 2 }}>
{renderCategoryRow(categories, false)}
{/* Level 1 Categories Row - Always shown */}
{renderCategoryRow(level1Categories, 1, false)}
{/* Level 2 Categories Row - Show when level 1 is selected */}
{/* DISABLED FOR NOW
{level2Categories.length > 0 && (
<Box sx={{ mt: 0.5 }}>
{renderCategoryRow(level2Categories, 2, false)}
</Box>
)}
{/* Level 3 Categories Row - Show when level 2 is selected */}
{/* DISABLED FOR NOW
{level3Categories.length > 0 && (
<Box sx={{ mt: 0.5 }}>
{renderCategoryRow(level3Categories, 3, false)}
</Box>
)}
*/}
</Container>
</Box>
@@ -535,10 +595,7 @@ class CategoryList extends Component {
onClick={this.handleMobileMenuToggle}
role="button"
tabIndex={0}
aria-label={this.props.t ?
(mobileMenuOpen ? this.props.t('navigation.categoriesClose') : this.props.t('navigation.categoriesOpen')) :
(mobileMenuOpen ? "Kategorien schließen" : "Kategorien öffnen")
}
aria-label={mobileMenuOpen ? "Kategorien schließen" : "Kategorien öffnen"}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
@@ -550,7 +607,7 @@ class CategoryList extends Component {
fontWeight: "bold",
textShadow: "0 1px 2px rgba(0,0,0,0.3)"
}}>
{this.props.t ? this.props.t('navigation.categories') : 'Kategorien'}
Kategorien
</Typography>
<Box sx={{ display: "flex", alignItems: "center" }}>
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
@@ -561,7 +618,7 @@ class CategoryList extends Component {
<Collapse in={mobileMenuOpen}>
<Box sx={{ pb: 2 }}>
{/* Level 1 Categories - Only level shown in mobile menu */}
{renderCategoryRow(categories, true)}
{renderCategoryRow(level1Categories, 1, true)}
</Box>
</Collapse>
</Container>
@@ -571,4 +628,4 @@ class CategoryList extends Component {
}
}
export default withI18n()(CategoryList);
export default CategoryList;

View File

@@ -18,9 +18,7 @@ const Logo = () => {
<img
src="/assets/images/sh.png"
alt="SH Logo"
width="108px"
height="45px"
style={{ width: "108px", height: "45px" }}
style={{ height: "45px" }}
/>
</Box>
);

View File

@@ -7,19 +7,16 @@ import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemText from "@mui/material/ListItemText";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import CircularProgress from "@mui/material/CircularProgress";
import SearchIcon from "@mui/icons-material/Search";
import KeyboardReturnIcon from "@mui/icons-material/KeyboardReturn";
import { useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { LanguageContext } from "../../i18n/withTranslation.js";
import SocketContext from "../../contexts/SocketContext.js";
const SearchBar = () => {
const navigate = useNavigate();
const location = useLocation();
const context = React.useContext(SocketContext);
const searchParams = new URLSearchParams(location.search);
const { t, i18n } = useTranslation();
const languageContext = React.useContext(LanguageContext);
// State management
const [searchQuery, setSearchQuery] = React.useState(
@@ -28,6 +25,7 @@ const SearchBar = () => {
const [suggestions, setSuggestions] = React.useState([]);
const [showSuggestions, setShowSuggestions] = React.useState(false);
const [selectedIndex, setSelectedIndex] = React.useState(-1);
const [loadingSuggestions, setLoadingSuggestions] = React.useState(false);
// Refs for debouncing and timers
const debounceTimerRef = React.useRef(null);
@@ -60,23 +58,24 @@ const SearchBar = () => {
// @note Autocomplete function using getSearchProducts Socket.io API - returns objects with name and seoName
const fetchAutocomplete = React.useCallback(
(query) => {
if (!query || query.length < 2) {
if (!context || !context.socket || !context.socket.connected || !query || query.length < 2) {
setSuggestions([]);
setShowSuggestions(false);
setLoadingSuggestions(false);
return;
}
const currentLanguage = languageContext?.currentLanguage || i18n?.language || 'de';
setLoadingSuggestions(true);
window.socketManager.emit(
context.socket.emit(
"getSearchProducts",
{
query: query.trim(),
limit: 8,
language: currentLanguage,
requestTranslation: currentLanguage === 'de' ? false : true,
},
(response) => {
setLoadingSuggestions(false);
if (response && response.products) {
// getSearchProducts returns response.products array
const suggestions = response.products.slice(0, 8); // Limit to 8 suggestions
@@ -91,7 +90,7 @@ const SearchBar = () => {
}
);
},
[languageContext, i18n]
[context]
);
const handleSearchChange = (e) => {
@@ -185,24 +184,6 @@ const SearchBar = () => {
}, 200);
};
// Get delivery days based on availability
const getDeliveryDays = (product) => {
if (product.available === 1) {
return t('delivery.times.standard2to3Days');
} else if (product.incoming === 1 || product.availableSupplier === 1) {
return t('delivery.times.supplier7to9Days');
}
};
// Handle enter icon click
const handleEnterClick = () => {
delete window.currentSearchQuery;
setShowSuggestions(false);
if (searchQuery.trim()) {
navigate(`/search?q=${encodeURIComponent(searchQuery)}`);
}
};
// Clean up timers on unmount
React.useEffect(() => {
return () => {
@@ -244,7 +225,7 @@ const SearchBar = () => {
>
<TextField
ref={inputRef}
placeholder={t('search.searchProducts')}
placeholder="Produkte suchen..."
variant="outlined"
size="small"
fullWidth
@@ -263,22 +244,9 @@ const SearchBar = () => {
<SearchIcon />
</InputAdornment>
),
endAdornment: (
endAdornment: loadingSuggestions && (
<InputAdornment position="end">
<IconButton
size="small"
onClick={handleEnterClick}
aria-label="Suche starten"
sx={{
p: 0.5,
color: "text.secondary",
"&:hover": {
color: "primary.main",
},
}}
>
<KeyboardReturnIcon fontSize="small" />
</IconButton>
<CircularProgress size={16} />
</InputAdornment>
),
sx: { borderRadius: 2, bgcolor: "background.paper" },
@@ -296,6 +264,8 @@ const SearchBar = () => {
left: 0,
right: 0,
zIndex: 1300,
maxHeight: "300px",
overflow: "auto",
mt: 0.5,
borderRadius: 2,
}}
@@ -303,19 +273,12 @@ const SearchBar = () => {
<List disablePadding>
{suggestions.map((suggestion, index) => (
<ListItem
key={`${suggestion.seoName || 'suggestion'}-${index}`}
component="button"
key={suggestion.seoName || index}
button
selected={index === selectedIndex}
onClick={() => handleSuggestionClick(suggestion)}
sx={{
cursor: "pointer",
border: "none",
background: "none",
padding: 0,
margin: 0,
width: "100%",
textAlign: "left",
px: 2, // Add horizontal padding back
"&:hover": {
backgroundColor: "action.hover",
},
@@ -330,48 +293,14 @@ const SearchBar = () => {
>
<ListItemText
primary={
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2 }}>
<Box sx={{ flexGrow: 1, minWidth: 0, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<Typography variant="body2" noWrap sx={{ mb: 0.5 }}>
<Typography variant="body2" noWrap>
{suggestion.name}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{getDeliveryDays(suggestion)}
</Typography>
</Box>
<Box sx={{ textAlign: 'right', flexShrink: 0, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<Typography variant="body1" color="primary" sx={{ fontWeight: 'bold', mb: 0.5 }}>
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(suggestion.price)}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem' }}>
{t('product.inclVat', { vat: suggestion.vat })}
</Typography>
</Box>
</Box>
}
/>
</ListItem>
))}
</List>
<Box sx={{ p: 1, borderTop: 1, borderColor: 'divider' }}>
<IconButton
fullWidth
onClick={handleEnterClick}
sx={{
justifyContent: 'center',
py: 1,
color: 'primary.main',
'&:hover': {
backgroundColor: 'action.hover',
},
}}
>
<Typography variant="body2" sx={{ mr: 1 }}>
{t('common.more')}
</Typography>
<KeyboardReturnIcon fontSize="small" />
</IconButton>
</Box>
</Paper>
)}
</Box>

View File

@@ -1,8 +1,7 @@
import React from "react";
import { Box, TextField, Typography } from "@mui/material";
import { withI18n } from "../../i18n/withTranslation.js";
const AddressForm = ({ title, address, onChange, errors, namePrefix, t }) => {
const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
// Helper function to determine if a required field should show error styling
const getRequiredFieldError = (fieldName, value) => {
const isEmpty = !value || value.trim() === "";
@@ -37,7 +36,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix, t }) => {
}}
>
<TextField
label={t ? t('checkout.addressFields.firstName') : 'Vorname'}
label="Vorname"
name="firstName"
value={address.firstName}
onChange={onChange}
@@ -50,7 +49,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix, t }) => {
}}
/>
<TextField
label={t ? t('checkout.addressFields.lastName') : 'Nachname'}
label="Nachname"
name="lastName"
value={address.lastName}
onChange={onChange}
@@ -63,7 +62,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix, t }) => {
}}
/>
<TextField
label={t ? t('checkout.addressFields.addressSupplement') : 'Adresszusatz'}
label="Adresszusatz"
name="addressAddition"
value={address.addressAddition || ""}
onChange={onChange}
@@ -71,7 +70,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix, t }) => {
InputLabelProps={{ shrink: true }}
/>
<TextField
label={t ? t('checkout.addressFields.street') : 'Straße'}
label="Straße"
name="street"
value={address.street}
onChange={onChange}
@@ -84,7 +83,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix, t }) => {
}}
/>
<TextField
label={t ? t('checkout.addressFields.houseNumber') : 'Hausnummer'}
label="Hausnummer"
name="houseNumber"
value={address.houseNumber}
onChange={onChange}
@@ -97,7 +96,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix, t }) => {
}}
/>
<TextField
label={t ? t('checkout.addressFields.postalCode') : 'PLZ'}
label="PLZ"
name="postalCode"
value={address.postalCode}
onChange={onChange}
@@ -110,7 +109,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix, t }) => {
}}
/>
<TextField
label={t ? t('checkout.addressFields.city') : 'Stadt'}
label="Stadt"
name="city"
value={address.city}
onChange={onChange}
@@ -123,7 +122,7 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix, t }) => {
}}
/>
<TextField
label={t ? t('checkout.addressFields.country') : 'Land'}
label="Land"
name="country"
value={address.country}
onChange={onChange}
@@ -136,4 +135,4 @@ const AddressForm = ({ title, address, onChange, errors, namePrefix, t }) => {
);
};
export default withI18n()(AddressForm);
export default AddressForm;

View File

@@ -5,7 +5,7 @@ import CheckoutForm from "./CheckoutForm.js";
import PaymentConfirmationDialog from "./PaymentConfirmationDialog.js";
import OrderProcessingService from "./OrderProcessingService.js";
import CheckoutValidation from "./CheckoutValidation.js";
import { withI18n } from "../../i18n/index.js";
import SocketContext from "../../contexts/SocketContext.js";
class CartTab extends Component {
constructor(props) {
@@ -51,6 +51,9 @@ class CartTab extends Component {
showStripePayment: false,
StripeComponent: null,
isLoadingStripe: false,
showMolliePayment: false,
MollieComponent: null,
isLoadingMollie: false,
showPaymentConfirmation: false,
orderCompleted: false,
originalCartItems: []
@@ -67,7 +70,8 @@ class CartTab extends Component {
// @note Add method to fetch and apply order template prefill data
fetchOrderTemplate = () => {
window.socketManager.emit('getOrderTemplate', (response) => {
if (this.context && this.context.socket && this.context.socket.connected) {
this.context.socket.emit('getOrderTemplate', (response) => {
if (response.success && response.orderTemplate) {
const template = response.orderTemplate;
@@ -115,7 +119,7 @@ class CartTab extends Component {
// Determine payment method - respect constraints
let prefillPaymentMethod = template.payment_method || "wire";
const paymentMethodMap = {
"credit_card": "mollie",/*stripe*/
"credit_card": "mollie",//stripe
"bank_transfer": "wire",
"cash_on_delivery": "onDelivery",
"cash": "cash"
@@ -145,6 +149,7 @@ class CartTab extends Component {
console.log("No order template available or failed to fetch");
}
});
}
};
componentDidMount() {
@@ -290,7 +295,7 @@ class CartTab extends Component {
};
validateAddressForm = () => {
const errors = CheckoutValidation.validateAddressForm(this.state, this.props.t);
const errors = CheckoutValidation.validateAddressForm(this.state);
this.setState({ addressFormErrors: errors });
return Object.keys(errors).length === 0;
};
@@ -317,10 +322,31 @@ class CartTab extends Component {
}
};
loadMollieComponent = async () => {
this.setState({ isLoadingMollie: true });
try {
const { default: Mollie } = await import("../Mollie.js");
this.setState({
MollieComponent: Mollie,
showMolliePayment: true,
isCompletingOrder: false,
isLoadingMollie: false,
});
} catch (error) {
console.error("Failed to load Mollie component:", error);
this.setState({
isCompletingOrder: false,
isLoadingMollie: false,
completionError: "Failed to load payment component. Please try again.",
});
}
};
handleCompleteOrder = () => {
this.setState({ completionError: null }); // Clear previous errors
const validationError = CheckoutValidation.getValidationErrorMessage(this.state, false, this.props.t);
const validationError = CheckoutValidation.getValidationErrorMessage(this.state);
if (validationError) {
this.setState({ completionError: validationError });
this.validateAddressForm(); // To show field-specific errors
@@ -361,38 +387,23 @@ class CartTab extends Component {
this.orderService.createStripeIntent(totalAmount, this.loadStripeComponent);
return;
}
// Handle molllie payment differently
// Handle Mollie payment differently
if (paymentMethod === "mollie") {
// Store the cart items used for mollie payment in sessionStorage for later reference
// 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);
console.error("Failed to store Mollie payment cart:", error);
}
// Calculate total amount for mollie
// 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);
const totalAmount = Math.round((subtotal + deliveryCost) * 100); // Convert to cents
this.orderService.createMollieIntent(totalAmount, this.loadMollieComponent);
return;
}
@@ -430,6 +441,9 @@ class CartTab extends Component {
showStripePayment,
StripeComponent,
isLoadingStripe,
showMolliePayment,
MollieComponent,
isLoadingMollie,
showPaymentConfirmation,
orderCompleted,
} = this.state;
@@ -437,7 +451,7 @@ class CartTab extends Component {
const deliveryCost = this.orderService.getDeliveryCost();
const { isPickupOnly, hasStecklinge } = CheckoutValidation.getCartItemFlags(cartItems);
const preSubmitError = CheckoutValidation.getValidationErrorMessage(this.state, false, this.props.t);
const preSubmitError = CheckoutValidation.getValidationErrorMessage(this.state);
const displayError = completionError || preSubmitError;
return (
@@ -465,7 +479,8 @@ class CartTab extends Component {
{!showPaymentConfirmation && (
<CartDropdown
cartItems={cartItems}
showDetailedSummary={showStripePayment}
socket={this.context.socket}
showDetailedSummary={showStripePayment || showMolliePayment}
deliveryMethod={deliveryMethod}
deliveryCost={deliveryCost}
/>
@@ -473,10 +488,10 @@ class CartTab extends Component {
{cartItems.length > 0 && (
<Box sx={{ mt: 3 }}>
{isLoadingStripe ? (
{isLoadingStripe || isLoadingMollie ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body1">
{this.props.t ? this.props.t('payment.loadingPaymentComponent') : 'Zahlungskomponente wird geladen...'}
Zahlungskomponente wird geladen...
</Typography>
</Box>
) : showStripePayment && StripeComponent ? (
@@ -494,11 +509,31 @@ class CartTab extends Component {
}
}}
>
{this.props.t ? this.props.t('cart.backToOrder') : '← Zurück zur Bestellung'}
Zurück zur Bestellung
</Button>
</Box>
<StripeComponent clientSecret={stripeClientSecret} />
</>
) : showMolliePayment && MollieComponent ? (
<>
<Box sx={{ mb: 2 }}>
<Button
variant="outlined"
onClick={() => this.setState({ showMolliePayment: false })}
sx={{
color: '#2e7d32',
borderColor: '#2e7d32',
'&:hover': {
backgroundColor: 'rgba(46, 125, 50, 0.04)',
borderColor: '#1b5e20'
}
}}
>
Zurück zur Bestellung
</Button>
</Box>
<MollieComponent />
</>
) : (
<CheckoutForm
paymentMethod={paymentMethod}
@@ -535,4 +570,7 @@ class CartTab extends Component {
}
}
export default withI18n()(CartTab);
// Set static contextType to access the socket
CartTab.contextType = SocketContext;
export default CartTab;

View File

@@ -4,7 +4,6 @@ import AddressForm from "./AddressForm.js";
import DeliveryMethodSelector from "./DeliveryMethodSelector.js";
import PaymentMethodSelector from "./PaymentMethodSelector.js";
import OrderSummary from "./OrderSummary.js";
import { withI18n } from "../../i18n/withTranslation.js";
class CheckoutForm extends Component {
render() {
@@ -41,7 +40,7 @@ class CheckoutForm extends Component {
{paymentMethod !== "cash" && (
<>
<AddressForm
title={this.props.t ? this.props.t('checkout.invoiceAddress') : 'Rechnungsadresse'}
title="Rechnungsadresse"
address={invoiceAddress}
onChange={onInvoiceAddressChange}
errors={addressFormErrors}
@@ -58,7 +57,7 @@ class CheckoutForm extends Component {
}
label={
<Typography variant="body2">
{this.props.t ? this.props.t('checkout.saveForFuture') : 'Für zukünftige Bestellungen speichern'}
Für zukünftige Bestellungen speichern
</Typography>
}
sx={{ mb: 2 }}
@@ -71,12 +70,13 @@ class CheckoutForm extends Component {
variant="body1"
sx={{ mb: 2, fontWeight: "bold", color: "#2e7d32" }}
>
{this.props.t ? this.props.t('checkout.pickupDate') : 'Für welchen Termin ist die Abholung der Stecklinge gewünscht?'}
Für welchen Termin ist die Abholung der Stecklinge
gewünscht?
</Typography>
)}
<TextField
label={this.props.t ? this.props.t('checkout.note') : 'Anmerkung'}
label="Anmerkung"
name="note"
value={note}
onChange={onNoteChange}
@@ -93,7 +93,6 @@ class CheckoutForm extends Component {
deliveryMethod={deliveryMethod}
onChange={onDeliveryMethodChange}
isPickupOnly={isPickupOnly || hasStecklinge}
cartItems={cartItems}
/>
{(deliveryMethod === "DHL" || deliveryMethod === "DPD") && (
@@ -108,7 +107,7 @@ class CheckoutForm extends Component {
}
label={
<Typography variant="body1">
{this.props.t ? this.props.t('checkout.sameAddress') : 'Lieferadresse ist identisch mit Rechnungsadresse'}
Lieferadresse ist identisch mit Rechnungsadresse
</Typography>
}
sx={{ mb: 2 }}
@@ -116,7 +115,7 @@ class CheckoutForm extends Component {
{!useSameAddress && (
<AddressForm
title={this.props.t ? this.props.t('checkout.deliveryAddress') : 'Lieferadresse'}
title="Lieferadresse"
address={deliveryAddress}
onChange={onDeliveryAddressChange}
errors={addressFormErrors}
@@ -151,7 +150,8 @@ class CheckoutForm extends Component {
}
label={
<Typography variant="body2">
{this.props.t ? this.props.t('checkout.termsAccept') : 'Ich habe die AGBs, die Datenschutzerklärung und die Bestimmungen zum Widerrufsrecht gelesen'}
Ich habe die AGBs, die Datenschutzerklärung und die
Bestimmungen zum Widerrufsrecht gelesen
</Typography>
}
sx={{ mb: 3, mt: 2 }}
@@ -174,12 +174,12 @@ class CheckoutForm extends Component {
disabled={isCompletingOrder || !!preSubmitError}
>
{isCompletingOrder
? (this.props.t ? this.props.t('checkout.processingOrder') : 'Bestellung wird verarbeitet...')
: (this.props.t ? this.props.t('checkout.completeOrder') : 'Bestellung abschließen')}
? "Bestellung wird verarbeitet..."
: "Bestellung abschließen"}
</Button>
</>
);
}
}
export default withI18n()(CheckoutForm);
export default CheckoutForm;

View File

@@ -1,5 +1,5 @@
class CheckoutValidation {
static validateAddressForm(state, t = null) {
static validateAddressForm(state) {
const {
invoiceAddress,
deliveryAddress,
@@ -12,15 +12,15 @@ class CheckoutValidation {
// Validate invoice address (skip if payment method is "cash")
if (paymentMethod !== "cash") {
if (!invoiceAddress.firstName)
errors.invoiceFirstName = t ? t('checkout.validationErrors.firstNameRequired') : "Vorname erforderlich";
errors.invoiceFirstName = "Vorname erforderlich";
if (!invoiceAddress.lastName)
errors.invoiceLastName = t ? t('checkout.validationErrors.lastNameRequired') : "Nachname erforderlich";
if (!invoiceAddress.street) errors.invoiceStreet = t ? t('checkout.validationErrors.streetRequired') : "Straße erforderlich";
errors.invoiceLastName = "Nachname erforderlich";
if (!invoiceAddress.street) errors.invoiceStreet = "Straße erforderlich";
if (!invoiceAddress.houseNumber)
errors.invoiceHouseNumber = t ? t('checkout.validationErrors.houseNumberRequired') : "Hausnummer erforderlich";
errors.invoiceHouseNumber = "Hausnummer erforderlich";
if (!invoiceAddress.postalCode)
errors.invoicePostalCode = t ? t('checkout.validationErrors.postalCodeRequired') : "PLZ erforderlich";
if (!invoiceAddress.city) errors.invoiceCity = t ? t('checkout.validationErrors.cityRequired') : "Stadt erforderlich";
errors.invoicePostalCode = "PLZ erforderlich";
if (!invoiceAddress.city) errors.invoiceCity = "Stadt erforderlich";
}
// Validate delivery address for shipping methods that require it
@@ -29,37 +29,37 @@ class CheckoutValidation {
(deliveryMethod === "DHL" || deliveryMethod === "DPD")
) {
if (!deliveryAddress.firstName)
errors.deliveryFirstName = t ? t('checkout.validationErrors.firstNameRequired') : "Vorname erforderlich";
errors.deliveryFirstName = "Vorname erforderlich";
if (!deliveryAddress.lastName)
errors.deliveryLastName = t ? t('checkout.validationErrors.lastNameRequired') : "Nachname erforderlich";
errors.deliveryLastName = "Nachname erforderlich";
if (!deliveryAddress.street)
errors.deliveryStreet = t ? t('checkout.validationErrors.streetRequired') : "Straße erforderlich";
errors.deliveryStreet = "Straße erforderlich";
if (!deliveryAddress.houseNumber)
errors.deliveryHouseNumber = t ? t('checkout.validationErrors.houseNumberRequired') : "Hausnummer erforderlich";
errors.deliveryHouseNumber = "Hausnummer erforderlich";
if (!deliveryAddress.postalCode)
errors.deliveryPostalCode = t ? t('checkout.validationErrors.postalCodeRequired') : "PLZ erforderlich";
if (!deliveryAddress.city) errors.deliveryCity = t ? t('checkout.validationErrors.cityRequired') : "Stadt erforderlich";
errors.deliveryPostalCode = "PLZ erforderlich";
if (!deliveryAddress.city) errors.deliveryCity = "Stadt erforderlich";
}
return errors;
}
static getValidationErrorMessage(state, isAddressOnly = false, t = null) {
static getValidationErrorMessage(state, isAddressOnly = false) {
const { termsAccepted } = state;
const addressErrors = this.validateAddressForm(state, t);
const addressErrors = this.validateAddressForm(state);
if (isAddressOnly) {
return addressErrors;
}
if (Object.keys(addressErrors).length > 0) {
return t ? t('checkout.addressValidationError') : "Bitte überprüfen Sie Ihre Eingaben in den Adressfeldern.";
return "Bitte überprüfen Sie Ihre Eingaben in den Adressfeldern.";
}
// Validate terms acceptance
if (!termsAccepted) {
return t ? t('checkout.termsValidationError') : "Bitte akzeptieren Sie die AGBs, Datenschutzerklärung und Widerrufsrecht, um fortzufahren.";
return "Bitte akzeptieren Sie die AGBs, Datenschutzerklärung und Widerrufsrecht, um fortzufahren.";
}
return null;
@@ -82,7 +82,7 @@ class CheckoutValidation {
// Prefer stripe when available and meets minimum amount
if (deliveryMethod === "DHL" || deliveryMethod === "DPD" || deliveryMethod === "Abholung") {
return "wire";/*stripe*/
return "stripe";
}
// Fall back to wire transfer
@@ -106,21 +106,11 @@ class CheckoutValidation {
newPaymentMethod = "wire";
}
// Allow mollie for DHL, DPD, and Abholung delivery methods, but check minimum amount
if (deliveryMethod !== "DHL" && deliveryMethod !== "DPD" && deliveryMethod !== "Abholung" && paymentMethod === "mollie") {
newPaymentMethod = "wire";
}
// Check minimum amount for stripe payments
if (paymentMethod === "stripe" && totalAmount < 0.50) {
newPaymentMethod = "wire";
}
// Check minimum amount for mollie payments
if (paymentMethod === "mollie" && totalAmount < 0.50) {
newPaymentMethod = "wire";
}
if (deliveryMethod !== "Abholung" && paymentMethod === "cash") {
newPaymentMethod = "wire";
}

View File

@@ -3,42 +3,34 @@ import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Radio from '@mui/material/Radio';
import Checkbox from '@mui/material/Checkbox';
import { withI18n } from '../../i18n/withTranslation.js';
const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartItems = [], t }) => {
// Calculate cart value for free shipping threshold
const cartValue = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
const isFreeShipping = cartValue >= 100;
const remainingForFreeShipping = Math.max(0, 100 - cartValue);
const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly }) => {
const deliveryOptions = [
{
id: 'DHL',
name: 'DHL',
description: isPickupOnly ? (t ? t('delivery.descriptions.notAvailable') : "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können") :
isFreeShipping ? (t ? t('delivery.descriptions.standardFree') : 'Standardversand - KOSTENLOS ab 100€ Warenwert!') : (t ? t('delivery.descriptions.standard') : 'Standardversand'),
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dhl') : '6,99 €'),
description: isPickupOnly ? "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können" : 'Standardversand',
price: '6,99 €',
disabled: isPickupOnly
},
{
id: 'DPD',
name: 'DPD',
description: isPickupOnly ? (t ? t('delivery.descriptions.notAvailable') : "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können") :
isFreeShipping ? (t ? t('delivery.descriptions.standardFree') : 'Standardversand - KOSTENLOS ab 100€ Warenwert!') : (t ? t('delivery.descriptions.standard') : 'Standardversand'),
price: isFreeShipping ? (t ? t('delivery.prices.free') : 'kostenlos') : (t ? t('delivery.prices.dpd') : '4,90 €'),
description: isPickupOnly ? "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können" : 'Standardversand',
price: '4,90 €',
disabled: isPickupOnly
},
{
id: 'Sperrgut',
name: t ? t('delivery.methods.sperrgutName') : 'Sperrgut',
description: t ? t('delivery.descriptions.bulky') : 'Für große und schwere Artikel',
price: t ? t('delivery.prices.sperrgut') : '28,99 €',
name: 'Sperrgut',
description: 'Für große und schwere Artikel',
price: '28,99 €',
disabled: true,
isCheckbox: true
},
{
id: 'Abholung',
name: t ? t('delivery.methods.pickup') : 'Abholung in der Filiale',
name: 'Abholung in der Filiale',
description: '',
price: ''
}
@@ -47,7 +39,7 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartIt
return (
<>
<Typography variant="h6" gutterBottom>
{t ? t('delivery.selector.title') : 'Versandart wählen'}
Versandart wählen
</Typography>
<Box sx={{ mb: 3 }}>
@@ -122,44 +114,9 @@ const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly, cartIt
</Typography>
</Box>
))}
{/* Free shipping information */}
{!isFreeShipping && remainingForFreeShipping > 0 && (
<Box sx={{
mt: 2,
p: 2,
backgroundColor: '#f0f8ff',
borderRadius: 1,
border: '1px solid #2196f3'
}}>
<Typography variant="body2" color="primary" sx={{ fontWeight: 'medium' }}>
{t ? t('delivery.selector.freeShippingInfo') : '💡 Versandkostenfrei ab 100€ Warenwert!'}
</Typography>
<Typography variant="body2" color="text.secondary">
{t ? t('delivery.selector.remainingForFree', { amount: remainingForFreeShipping.toFixed(2).replace('.', ',') }) : `Noch ${remainingForFreeShipping.toFixed(2).replace('.', ',')}€ für kostenlosen Versand hinzufügen.`}
</Typography>
</Box>
)}
{isFreeShipping && (
<Box sx={{
mt: 2,
p: 2,
backgroundColor: '#e8f5e8',
borderRadius: 1,
border: '1px solid #2e7d32'
}}>
<Typography variant="body2" color="success.main" sx={{ fontWeight: 'medium' }}>
{t ? t('delivery.selector.congratsFreeShipping') : '🎉 Glückwunsch! Sie erhalten kostenlosen Versand!'}
</Typography>
<Typography variant="body2" color="text.secondary">
{t ? t('delivery.selector.cartQualifiesFree', { amount: cartValue.toFixed(2).replace('.', ',') }) : `Ihr Warenkorb von ${cartValue.toFixed(2).replace('.', ',')}€ qualifiziert sich für kostenlosen Versand.`}
</Typography>
</Box>
)}
</Box>
</>
);
};
export default withI18n()(DeliveryMethodSelector);
export default DeliveryMethodSelector;

View File

@@ -15,36 +15,22 @@ import {
TableRow,
Paper
} from '@mui/material';
import { useTranslation } from 'react-i18next';
const OrderDetailsDialog = ({ open, onClose, order }) => {
const { t } = useTranslation();
if (!order) {
return null;
}
const currencyFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
// Helper function to translate payment methods
const getPaymentMethodDisplay = (paymentMethod) => {
if (!paymentMethod) return t('orders.details.notSpecified');
switch (paymentMethod.toLowerCase()) {
case 'wire':
return t('payment.methods.bankTransfer');
default:
return paymentMethod;
}
};
const handleCancelOrder = () => {
// Implement order cancellation logic here
console.log(`Cancel order: ${order.orderId}`);
onClose(); // Close the dialog after action
};
const total = order.items.reduce((acc, item) => acc + item.price * item.quantity_ordered, 0);
const subtotal = order.items.reduce((acc, item) => acc + item.price * item.quantity_ordered, 0);
const total = subtotal + order.delivery_cost;
// Calculate VAT breakdown similar to CartDropdown
const vatCalculations = order.items.reduce((acc, item) => {
@@ -66,10 +52,10 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>{t('orders.details.title', { orderId: order.orderId })}</DialogTitle>
<DialogTitle>Bestelldetails: {order.orderId}</DialogTitle>
<DialogContent>
<Box sx={{ mb: 2 }}>
<Typography variant="h6">{t('orders.details.deliveryAddress')}</Typography>
<Typography variant="h6">Lieferadresse</Typography>
<Typography>{order.shipping_address_name}</Typography>
<Typography>{order.shipping_address_street} {order.shipping_address_house_number}</Typography>
<Typography>{order.shipping_address_postal_code} {order.shipping_address_city}</Typography>
@@ -77,7 +63,7 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="h6">{t('orders.details.invoiceAddress')}</Typography>
<Typography variant="h6">Rechnungsadresse</Typography>
<Typography>{order.invoice_address_name}</Typography>
<Typography>{order.invoice_address_street} {order.invoice_address_house_number}</Typography>
<Typography>{order.invoice_address_postal_code} {order.invoice_address_city}</Typography>
@@ -86,29 +72,28 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
{/* Order Details Section */}
<Box sx={{ mb: 2 }}>
<Typography variant="h6" gutterBottom>{t('orders.details.orderDetails')}</Typography>
<Typography variant="h6" gutterBottom>Bestelldetails</Typography>
<Box sx={{ display: 'flex', gap: 4 }}>
<Box>
<Typography variant="body2" color="text.secondary">{t('orders.details.deliveryMethod')}</Typography>
<Typography variant="body1">{order.deliveryMethod || order.delivery_method || t('orders.details.notSpecified')}</Typography>
<Typography variant="body2" color="text.secondary">Lieferart:</Typography>
<Typography variant="body1">{order.deliveryMethod || order.delivery_method || 'Nicht angegeben'}</Typography>
</Box>
<Box>
<Typography variant="body2" color="text.secondary">{t('orders.details.paymentMethod')}</Typography>
<Typography variant="body1">{getPaymentMethodDisplay(order.paymentMethod || order.payment_method)}</Typography>
<Typography variant="body2" color="text.secondary">Zahlungsart:</Typography>
<Typography variant="body1">{order.paymentMethod || order.payment_method || 'Nicht angegeben'}</Typography>
</Box>
</Box>
</Box>
<Typography variant="h6" gutterBottom>{t('orders.details.orderedItems')}</Typography>
<Typography variant="h6" gutterBottom>Bestellte Artikel</Typography>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t('orders.details.item')}</TableCell>
<TableCell align="right">{t('orders.details.quantity')}</TableCell>
<TableCell align="right">{t('orders.details.price')}</TableCell>
<TableCell align="right">{t('product.vatShort')}</TableCell>
<TableCell align="right">{t('orders.details.total')}</TableCell>
<TableCell>Artikel</TableCell>
<TableCell align="right">Menge</TableCell>
<TableCell align="right">Preis</TableCell>
<TableCell align="right">Gesamt</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -117,13 +102,13 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
<TableCell>{item.name}</TableCell>
<TableCell align="right">{item.quantity_ordered}</TableCell>
<TableCell align="right">{currencyFormatter.format(item.price)}</TableCell>
<TableCell align="right">{item.vat}%</TableCell>
<TableCell align="right">{currencyFormatter.format(item.price * item.quantity_ordered)}</TableCell>
</TableRow>
))}
<TableRow>
<TableCell colSpan={4} align="right">
<Typography fontWeight="bold">{t ? t('tax.totalNet') : 'Gesamtnettopreis'}</Typography>
<TableCell colSpan={2} />
<TableCell align="right">
<Typography fontWeight="bold">Gesamtnettopreis</Typography>
</TableCell>
<TableCell align="right">
<Typography fontWeight="bold">{currencyFormatter.format(vatCalculations.totalNet)}</Typography>
@@ -131,19 +116,36 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
</TableRow>
{vatCalculations.vat7 > 0 && (
<TableRow>
<TableCell colSpan={4} align="right">{t ? t('tax.vat7') : '7% Mehrwertsteuer'}</TableCell>
<TableCell colSpan={2} />
<TableCell align="right">7% Mehrwertsteuer</TableCell>
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat7)}</TableCell>
</TableRow>
)}
{vatCalculations.vat19 > 0 && (
<TableRow>
<TableCell colSpan={4} align="right">{t ? t('tax.vat19') : '19% Mehrwertsteuer'}</TableCell>
<TableCell colSpan={2} />
<TableCell align="right">19% Mehrwertsteuer</TableCell>
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat19)}</TableCell>
</TableRow>
)}
<TableRow>
<TableCell colSpan={4} align="right">
<Typography fontWeight="bold">{t ? t('cart.summary.total') : 'Gesamtsumme'}</Typography>
<TableCell colSpan={2} />
<TableCell align="right">
<Typography fontWeight="bold">Zwischensumme</Typography>
</TableCell>
<TableCell align="right">
<Typography fontWeight="bold">{currencyFormatter.format(subtotal)}</Typography>
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={2} />
<TableCell align="right">Lieferkosten</TableCell>
<TableCell align="right">{currencyFormatter.format(order.delivery_cost)}</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={2} />
<TableCell align="right">
<Typography fontWeight="bold">Gesamtsumme</Typography>
</TableCell>
<TableCell align="right">
<Typography fontWeight="bold">{currencyFormatter.format(total)}</Typography>
@@ -157,10 +159,10 @@ const OrderDetailsDialog = ({ open, onClose, order }) => {
<DialogActions>
{order.status === 'new' && (
<Button onClick={handleCancelOrder} color="error">
{t('orders.details.cancelOrder')}
Bestellung stornieren
</Button>
)}
<Button onClick={onClose}>{t ? t('common.close') : 'Schließen'}</Button>
<Button onClick={onClose}>Schließen</Button>
</DialogActions>
</Dialog>
);

View File

@@ -49,29 +49,18 @@ class OrderProcessingService {
waitForVerifyTokenAndProcessOrder() {
// Check if window.cart is already populated (verifyToken already completed)
if (Array.isArray(window.cart) && window.cart.length > 0) {
if (this.paymentCompletionData && this.paymentCompletionData.paymentType === 'mollie') {
this.processMollieOrderWithCart(window.cart);
} else {
this.processStripeOrderWithCart(window.cart);
}
return;
}
// Listen for cart event which is dispatched after verifyToken completes
this.verifyTokenHandler = () => {
if (Array.isArray(window.cart) && window.cart.length > 0) {
const cartCopy = [...window.cart]; // Copy the cart
this.processStripeOrderWithCart([...window.cart]); // Copy the cart
// Clear window.cart after copying
window.cart = [];
window.dispatchEvent(new CustomEvent("cart"));
// Process based on payment type
if (this.paymentCompletionData && this.paymentCompletionData.paymentType === 'mollie') {
this.processMollieOrderWithCart(cartCopy);
} else {
this.processStripeOrderWithCart(cartCopy);
}
} else {
this.setState({
completionError: "Cart is empty. Please add items to your cart before placing an order."
@@ -122,21 +111,6 @@ class OrderProcessingService {
});
}
processMollieOrderWithCart(cartItems) {
// Clear timeout if it exists
if (this.verifyTokenTimeout) {
clearTimeout(this.verifyTokenTimeout);
this.verifyTokenTimeout = null;
}
// Store cart items in state and process order
this.setState({
originalCartItems: cartItems
}, () => {
this.processMollieOrder();
});
}
processStripeOrder() {
// If no original cart items, don't process
if (!this.getState().originalCartItems || this.getState().originalCartItems.length === 0) {
@@ -145,24 +119,26 @@ class OrderProcessingService {
}
// If socket is ready, process immediately
const context = this.getContext();
if (context && context.socket && context.socket.connected) {
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
if (isAuthenticated) {
this.sendStripeOrder();
return;
}
}
// Wait for socket to be ready
this.socketHandler = () => {
const context = this.getContext();
if (context && context.socket && context.socket.connected) {
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
const state = this.getState();
if (isAuthenticated && state.showPaymentConfirmation && !state.isCompletingOrder) {
this.sendStripeOrder();
}
}
// Clean up
if (this.socketHandler) {
window.removeEventListener('cart', this.socketHandler);
@@ -211,8 +187,9 @@ class OrderProcessingService {
saveAddressForFuture,
};
window.socketManager.emit("issueStripeOrder", orderData, (response) => {
// Emit stripe order to backend via socket.io
const context = this.getContext();
context.socket.emit("issueStripeOrder", orderData, (response) => {
if (response.success) {
this.setState({
isCompletingOrder: false,
@@ -228,24 +205,11 @@ class OrderProcessingService {
});
}
processMollieOrder() {
// For Mollie payments, the backend handles order creation automatically
// when payment is successful. We just need to show success state.
this.setState({
isCompletingOrder: false,
orderCompleted: true,
completionError: null,
});
// Clear the cart since order was created by backend
window.cart = [];
window.dispatchEvent(new CustomEvent("cart"));
}
// Process regular (non-Stripe) orders
processRegularOrder(orderData) {
window.socketManager.emit("issueOrder", orderData, (response) => {
const context = this.getContext();
if (context && context.socket && context.socket.connected) {
context.socket.emit("issueOrder", orderData, (response) => {
if (response.success) {
// Clear the cart
window.cart = [];
@@ -270,12 +234,20 @@ class OrderProcessingService {
});
}
});
} else {
console.error("Socket context not available");
this.setState({
isCompletingOrder: false,
completionError: "Cannot connect to server. Please try again later.",
});
}
}
// Create Stripe payment intent
createStripeIntent(totalAmount, loadStripeComponent) {
window.socketManager.emit(
const context = this.getContext();
if (context && context.socket && context.socket.connected) {
context.socket.emit(
"createStripeIntent",
{ amount: totalAmount },
(response) => {
@@ -290,38 +262,22 @@ class OrderProcessingService {
}
}
);
};
// Create Mollie payment intent
createMollieIntent(mollieOrderData) {
window.socketManager.emit(
"createMollieIntent",
mollieOrderData,
(response) => {
if (response.success) {
// Store pending payment info and redirect
localStorage.setItem('pendingPayment', JSON.stringify({
paymentId: response.paymentId,
amount: mollieOrderData.amount,
timestamp: Date.now()
}));
window.location.href = response.checkoutUrl;
} else {
console.error("Error:", response.error);
console.error("Socket context not available");
this.setState({
isCompletingOrder: false,
completionError: response.error || "Failed to create Mollie payment intent. Please try again.",
completionError: "Cannot connect to server. Please try again later.",
});
}
}
);
// Create Mollie payment intent
createMollieIntent(totalAmount, loadMollieComponent) {
loadMollieComponent();
}
// Calculate delivery cost
getDeliveryCost() {
const { deliveryMethod, paymentMethod, cartItems } = this.getState();
const { deliveryMethod, paymentMethod } = this.getState();
let cost = 0;
switch (deliveryMethod) {
@@ -341,16 +297,7 @@ class OrderProcessingService {
cost = 6.99;
}
// 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)
// Add onDelivery surcharge if selected
if (paymentMethod === "onDelivery") {
cost += 8.99;
}

View File

@@ -5,10 +5,8 @@ import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableRow from '@mui/material/TableRow';
import { useTranslation } from 'react-i18next';
const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
const { t } = useTranslation();
const currencyFormatter = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
@@ -32,9 +30,9 @@ const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
return acc;
}, { totalGross: 0, totalNet: 0, vat7: 0, vat19: 0 });
// Calculate shipping VAT (19% VAT for shipping costs) - only if there are shipping costs
const shippingNetPrice = deliveryCost > 0 ? deliveryCost / (1 + 19 / 100) : 0;
const shippingVat = deliveryCost > 0 ? deliveryCost - shippingNetPrice : 0;
// Calculate shipping VAT (19% VAT for shipping costs)
const shippingNetPrice = deliveryCost / (1 + 19 / 100);
const shippingVat = deliveryCost - shippingNetPrice;
// Combine totals - add shipping VAT to the 19% VAT total
const totalVat7 = cartVatCalculations.vat7;
@@ -44,20 +42,20 @@ const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
return (
<Box sx={{ my: 3, p: 2, bgcolor: '#f5f5f5', borderRadius: 1 }}>
<Typography variant="h6" gutterBottom>
{t ? t('cart.summary.title') : 'Bestellübersicht'}
Bestellübersicht
</Typography>
<Table size="small">
<TableBody>
<TableRow>
<TableCell>{t ? t('cart.summary.goodsNet') : 'Waren (netto):'}</TableCell>
<TableCell>Waren (netto):</TableCell>
<TableCell align="right">
{currencyFormatter.format(cartVatCalculations.totalNet)}
</TableCell>
</TableRow>
{deliveryCost > 0 && (
<TableRow>
<TableCell>{t ? t('cart.summary.shippingNet') : 'Versandkosten (netto):'}</TableCell>
<TableCell>Versandkosten (netto):</TableCell>
<TableCell align="right">
{currencyFormatter.format(shippingNetPrice)}
</TableCell>
@@ -65,7 +63,7 @@ const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
)}
{totalVat7 > 0 && (
<TableRow>
<TableCell>{t ? t('tax.vat7') : '7% Mehrwertsteuer'}:</TableCell>
<TableCell>7% Mehrwertsteuer:</TableCell>
<TableCell align="right">
{currencyFormatter.format(totalVat7)}
</TableCell>
@@ -73,37 +71,28 @@ const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
)}
{totalVat19 > 0 && (
<TableRow>
<TableCell>{t ? t('tax.vat19WithShipping') : '19% Mehrwertsteuer (inkl. Versand)'}:</TableCell>
<TableCell>19% Mehrwertsteuer (inkl. Versand):</TableCell>
<TableCell align="right">
{currencyFormatter.format(totalVat19)}
</TableCell>
</TableRow>
)}
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>{t ? t('cart.summary.totalGoods') : 'Gesamtsumme Waren:'}</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtsumme Waren:</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
{currencyFormatter.format(cartVatCalculations.totalGross)}
</TableCell>
</TableRow>
{deliveryCost > 0 && (
<TableRow>
<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 sx={{ fontWeight: 'bold' }}>Versandkosten:</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
{deliveryCost === 0 ? (
<span style={{ color: '#2e7d32' }}>{t ? t('cart.summary.free') : 'kostenlos'}</span>
) : (
currencyFormatter.format(deliveryCost)
)}
{currencyFormatter.format(deliveryCost)}
</TableCell>
</TableRow>
)}
<TableRow sx={{ borderTop: '1px solid #e0e0e0' }}>
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>{t ? t('cart.summary.total') : 'Gesamtsumme:'}</TableCell>
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>Gesamtsumme:</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
{currencyFormatter.format(totalGross)}
</TableCell>

View File

@@ -1,6 +1,5 @@
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useContext, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { withI18n } from "../../i18n/withTranslation.js";
import {
Box,
Paper,
@@ -15,27 +14,19 @@ import {
Tooltip,
CircularProgress,
Typography,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
} from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";
import CancelIcon from "@mui/icons-material/Cancel";
import SocketContext from "../../contexts/SocketContext.js";
import OrderDetailsDialog from "./OrderDetailsDialog.js";
// Constants
const getStatusTranslation = (status, t) => {
const statusMap = {
new: t ? t('orders.status.new') : "in Bearbeitung",
pending: t ? t('orders.status.pending') : "Neu",
processing: t ? t('orders.status.processing') : "in Bearbeitung",
cancelled: t ? t('orders.status.cancelled') : "Storniert",
shipped: t ? t('orders.status.shipped') : "Verschickt",
delivered: t ? t('orders.status.delivered') : "Geliefert",
};
return statusMap[status] || status;
const statusTranslations = {
new: "in Bearbeitung",
pending: "Neu",
processing: "in Bearbeitung",
cancelled: "Storniert",
shipped: "Verschickt",
delivered: "Geliefert",
};
const statusEmojis = {
@@ -70,16 +61,14 @@ const currencyFormatter = new Intl.NumberFormat("de-DE", {
});
// Orders Tab Content Component
const OrdersTab = ({ orderIdFromHash, t }) => {
const OrdersTab = ({ orderIdFromHash }) => {
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedOrder, setSelectedOrder] = useState(null);
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
const [orderToCancel, setOrderToCancel] = useState(null);
const [isCancelling, setIsCancelling] = useState(false);
const {socket} = useContext(SocketContext);
const navigate = useNavigate();
const handleViewDetails = useCallback(
@@ -88,18 +77,16 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
if (orderToView) {
setSelectedOrder(orderToView);
setIsDetailsDialogOpen(true);
// Update the hash to include the order ID
navigate(`/profile#${orderId}`, { replace: true });
}
},
[orders, navigate]
[orders]
);
const fetchOrders = useCallback(() => {
if (socket && socket.connected) {
setLoading(true);
setError(null);
window.socketManager.emit("getOrders", (response) => {
socket.emit("getOrders", (response) => {
if (response.success) {
setOrders(response.orders);
} else {
@@ -107,13 +94,25 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
}
setLoading(false);
});
}, []);
} else {
// Socket not connected yet, but don't show error immediately on first load
console.log("Socket not connected yet, waiting for connection to fetch orders");
setLoading(false); // Stop loading when socket is not connected
}
}, [socket]);
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
// Monitor socket connection changes
useEffect(() => {
if (socket && socket.connected && orders.length === 0) {
// Socket just connected and we don't have orders yet, fetch them
fetchOrders();
}
}, [socket, socket?.connected, orders.length, fetchOrders]);
useEffect(() => {
if (orderIdFromHash && orders.length > 0) {
handleViewDetails(orderIdFromHash);
@@ -121,7 +120,7 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
}, [orderIdFromHash, orders, handleViewDetails]);
const getStatusDisplay = (status) => {
return getStatusTranslation(status, t);
return statusTranslations[status] || status;
};
const getStatusEmoji = (status) => {
@@ -135,48 +134,7 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
const handleCloseDetailsDialog = () => {
setIsDetailsDialogOpen(false);
setSelectedOrder(null);
navigate("/profile#orders", { replace: true });
};
// Check if order can be cancelled
const isOrderCancelable = (order) => {
const cancelableStatuses = ['new', 'pending', 'processing'];
return cancelableStatuses.includes(order.status);
};
// Handle cancel button click
const handleCancelClick = (order) => {
setOrderToCancel(order);
setCancelConfirmOpen(true);
};
// Handle cancel confirmation
const handleConfirmCancel = () => {
if (!orderToCancel) return;
setIsCancelling(true);
window.socketManager.emit('cancelOrder', { orderId: orderToCancel.orderId }, (response) => {
setIsCancelling(false);
setCancelConfirmOpen(false);
if (response.success) {
console.log('Order cancelled:', response.orderId);
// Refresh orders list
fetchOrders();
} else {
setError(response.error || 'Failed to cancel order');
}
setOrderToCancel(null);
});
};
// Handle cancel dialog close
const handleCancelDialogClose = () => {
if (!isCancelling) {
setCancelConfirmOpen(false);
setOrderToCancel(null);
}
navigate("/profile", { replace: true });
};
if (loading) {
@@ -202,21 +160,22 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
<Table>
<TableHead>
<TableRow>
<TableCell>{t ? t('orders.table.orderNumber') : 'Bestellnummer'}</TableCell>
<TableCell>{t ? t('orders.table.date') : 'Datum'}</TableCell>
<TableCell>{t ? t('orders.table.status') : 'Status'}</TableCell>
<TableCell>{t ? t('orders.table.items') : 'Artikel'}</TableCell>
<TableCell align="right">{t ? t('orders.table.total') : 'Summe'}</TableCell>
<TableCell align="center">{t ? t('orders.table.actions') : 'Aktionen'}</TableCell>
<TableCell>Bestellnummer</TableCell>
<TableCell>Datum</TableCell>
<TableCell>Status</TableCell>
<TableCell>Artikel</TableCell>
<TableCell align="right">Summe</TableCell>
<TableCell align="center">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{orders.map((order) => {
const displayStatus = getStatusDisplay(order.status);
const total = order.items.reduce(
const subtotal = order.items.reduce(
(acc, item) => acc + item.price * item.quantity_ordered,
0
);
const total = subtotal + order.delivery_cost;
return (
<TableRow key={order.orderId} hover>
<TableCell>{order.orderId}</TableCell>
@@ -245,16 +204,7 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
</Box>
</TableCell>
<TableCell>
{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(
{order.items.reduce(
(acc, item) => acc + item.quantity_ordered,
0
)}
@@ -263,30 +213,15 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
{currencyFormatter.format(total)}
</TableCell>
<TableCell align="center">
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center' }}>
<Tooltip title={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}>
<Tooltip title="Details anzeigen">
<IconButton
size="small"
color="primary"
onClick={() => handleViewDetails(order.orderId)}
aria-label={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}
>
<SearchIcon />
</IconButton>
</Tooltip>
{isOrderCancelable(order) && (
<Tooltip title={t ? t('orders.tooltips.cancelOrder') : 'Bestellung stornieren'}>
<IconButton
size="small"
color="error"
onClick={() => handleCancelClick(order)}
aria-label={t ? t('orders.tooltips.cancelOrder') : 'Bestellung stornieren'}
>
<CancelIcon />
</IconButton>
</Tooltip>
)}
</Box>
</TableCell>
</TableRow>
);
@@ -296,7 +231,7 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
</TableContainer>
) : (
<Alert severity="info">
{t ? t('orders.noOrders') : 'Sie haben noch keine Bestellungen aufgegeben.'}
Sie haben noch keine Bestellungen aufgegeben.
</Alert>
)}
<OrderDetailsDialog
@@ -304,49 +239,8 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
onClose={handleCloseDetailsDialog}
order={selectedOrder}
/>
{/* Cancel Confirmation Dialog */}
<Dialog
open={cancelConfirmOpen}
onClose={handleCancelDialogClose}
maxWidth="sm"
fullWidth
>
<DialogTitle>
{t ? t('orders.cancelConfirm.title') : 'Bestellung stornieren'}
</DialogTitle>
<DialogContent>
<Typography>
{t ? t('orders.cancelConfirm.message') : 'Sind Sie sicher, dass Sie diese Bestellung stornieren möchten?'}
</Typography>
{orderToCancel && (
<Typography variant="body2" sx={{ mt: 1, fontWeight: 'bold' }}>
{t ? t('orders.table.orderNumber') : 'Bestellnummer'}: {orderToCancel.orderId}
</Typography>
)}
</DialogContent>
<DialogActions>
<Button
onClick={handleCancelDialogClose}
disabled={isCancelling}
>
{t ? t('common.cancel') : 'Abbrechen'}
</Button>
<Button
onClick={handleConfirmCancel}
color="error"
variant="contained"
disabled={isCancelling}
>
{isCancelling
? (t ? t('orders.cancelConfirm.cancelling') : 'Wird storniert...')
: (t ? t('orders.cancelConfirm.confirm') : 'Stornieren')
}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default withI18n()(OrdersTab);
export default OrdersTab;

View File

@@ -1,6 +1,5 @@
import React, { Component } from "react";
import { Box, Typography, Button } from "@mui/material";
import { withI18n } from "../../i18n/withTranslation.js";
class PaymentConfirmationDialog extends Component {
render() {
@@ -29,32 +28,30 @@ class PaymentConfirmationDialog extends Component {
color: paymentCompletionData.isSuccessful ? '#2e7d32' : '#d32f2f',
fontWeight: 'bold'
}}>
{paymentCompletionData.isSuccessful ?
(this.props.t ? this.props.t('payment.successful') : 'Zahlung erfolgreich!') :
(this.props.t ? this.props.t('payment.failed') : 'Zahlung fehlgeschlagen')}
{paymentCompletionData.isSuccessful ? 'Zahlung erfolgreich!' : 'Zahlung fehlgeschlagen'}
</Typography>
{paymentCompletionData.isSuccessful ? (
<>
{orderCompleted ? (
<Typography variant="body1" sx={{ mt: 2, color: '#2e7d32' }}>
{this.props.t ? this.props.t('payment.orderCompleted') : '🎉 Ihre Bestellung wurde erfolgreich abgeschlossen! Sie können jetzt Ihre Bestellungen einsehen.'}
🎉 Ihre Bestellung wurde erfolgreich abgeschlossen! Sie können jetzt Ihre Bestellungen einsehen.
</Typography>
) : (
<Typography variant="body1" sx={{ mt: 2, color: '#2e7d32' }}>
{this.props.t ? this.props.t('payment.orderProcessing') : 'Ihre Zahlung wurde erfolgreich verarbeitet. Die Bestellung wird automatisch abgeschlossen.'}
Ihre Zahlung wurde erfolgreich verarbeitet. Die Bestellung wird automatisch abgeschlossen.
</Typography>
)}
</>
) : (
<Typography variant="body1" sx={{ mt: 2, color: '#d32f2f' }}>
{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.'}
Ihre Zahlung konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut oder wählen Sie eine andere Zahlungsmethode.
</Typography>
)}
{isCompletingOrder && (
<Typography variant="body2" sx={{ mt: 2, color: '#2e7d32', p: 2, bgcolor: '#e8f5e8', borderRadius: 1 }}>
{this.props.t ? this.props.t('orders.processing') : 'Bestellung wird abgeschlossen...'}
Bestellung wird abgeschlossen...
</Typography>
)}
@@ -78,7 +75,7 @@ class PaymentConfirmationDialog extends Component {
}
}}
>
{this.props.t ? this.props.t('cart.continueShopping') : 'Weiter einkaufen'}
Weiter einkaufen
</Button>
<Button
onClick={onViewOrders}
@@ -88,7 +85,7 @@ class PaymentConfirmationDialog extends Component {
'&:hover': { bgcolor: '#1b5e20' }
}}
>
{this.props.t ? this.props.t('payment.viewOrders') : 'Zu meinen Bestellungen'}
Zu meinen Bestellungen
</Button>
</Box>
)}
@@ -97,4 +94,4 @@ class PaymentConfirmationDialog extends Component {
}
}
export default withI18n()(PaymentConfirmationDialog);
export default PaymentConfirmationDialog;

View File

@@ -1,8 +1,7 @@
import React, { useEffect, useCallback } from "react";
import { Box, Typography, Radio } from "@mui/material";
import { withI18n } from "../../i18n/withTranslation.js";
const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeliveryMethodChange, cartItems = [], deliveryCost = 0, t }) => {
const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeliveryMethodChange, cartItems = [], deliveryCost = 0 }) => {
// Calculate total amount
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
@@ -25,7 +24,7 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
// Handle delivery method changes - auto-switch to stripe when DHL/DPD is selected
useEffect(() => {
if ((deliveryMethod === "DHL" || deliveryMethod === "DPD") && paymentMethod === "cash") {
handlePaymentMethodChange({ target: { value: "wire" /*stripe*/ } });
handlePaymentMethodChange({ target: { value: "mollie" } });
}
}, [deliveryMethod, paymentMethod, handlePaymentMethodChange]);
@@ -39,13 +38,13 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
const paymentOptions = [
{
id: "wire",
name: t ? t('payment.methods.bankTransfer') : "Überweisung",
description: t ? t('payment.methods.bankTransferDescription') : "Bezahlen Sie per Banküberweisung",
name: "Überweisung",
description: "Bezahlen Sie per Banküberweisung",
disabled: totalAmount === 0,
},
/*{
id: "stripe",
name: "Karte oder Sofortüberweisung",
name: "Karte oder Sofortüberweisung (Stripe)",
description: totalAmount < 0.50 && totalAmount > 0
? "Bezahlen Sie per Karte oder Sofortüberweisung (Mindestbetrag: 0,50 €)"
: "Bezahlen Sie per Karte oder Sofortüberweisung",
@@ -57,12 +56,12 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
"/assets/images/visa_electron.png",
],
},*/
/*{
{
id: "mollie",
name: t ? t('payment.methods.cardPayment') : "Karte, Sofortüberweisung, Apple Pay, Google Pay, PayPal",
name: "Karte oder Sofortüberweisung",
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"),
? "Bezahlen Sie per Karte oder Sofortüberweisung (Mindestbetrag: 0,50 €)"
: "Bezahlen Sie per Karte oder Sofortüberweisung",
disabled: totalAmount < 0.50 || (deliveryMethod !== "DHL" && deliveryMethod !== "DPD" && deliveryMethod !== "Abholung"),
icons: [
"/assets/images/giropay.png",
@@ -70,18 +69,18 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
"/assets/images/mastercard.png",
"/assets/images/visa_electron.png",
],
},*/
},
{
id: "onDelivery",
name: t ? t('payment.methods.cashOnDelivery') : "Nachnahme",
description: t ? t('payment.methods.cashOnDeliveryDescription') : "Bezahlen Sie bei Lieferung (8,99 € Aufschlag)",
name: "Nachnahme",
description: "Bezahlen Sie bei Lieferung (8,99 € Aufschlag)",
disabled: totalAmount === 0 || deliveryMethod !== "DHL",
icons: ["/assets/images/cash.png"],
},
{
id: "cash",
name: t ? t('payment.methods.cashInStore') : "Zahlung in der Filiale",
description: t ? t('payment.methods.cashInStoreDescription') : "Bei Abholung bezahlen",
name: "Zahlung in der Filiale",
description: "Bei Abholung bezahlen",
disabled: false, // Always enabled
icons: ["/assets/images/cash.png"],
},
@@ -90,7 +89,7 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
return (
<>
<Typography variant="h6" gutterBottom>
{t ? t('payment.methods.selectPaymentMethod') : 'Zahlungsart wählen'}
Zahlungsart wählen
</Typography>
<Box sx={{ mb: 3 }}>
@@ -190,4 +189,4 @@ const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeli
);
};
export default withI18n()(PaymentMethodSelector);
export default PaymentMethodSelector;

View File

@@ -11,8 +11,7 @@ import {
IconButton,
Snackbar
} from '@mui/material';
import ContentCopy from '@mui/icons-material/ContentCopy';
import { withI18n } from '../../i18n/withTranslation.js';
import { ContentCopy } from '@mui/icons-material';
class SettingsTab extends Component {
constructor(props) {
@@ -48,7 +47,7 @@ class SettingsTab extends Component {
this.setState({ newEmail: user.email || '' });
// Check if user has an API key
window.socketManager.emit('isApiKey', (response) => {
this.props.socket.emit('isApiKey', (response) => {
if (response.success && response.hasApiKey) {
this.setState({
hasApiKey: true,
@@ -73,38 +72,38 @@ class SettingsTab extends Component {
// Validation
if (!this.state.currentPassword || !this.state.newPassword || !this.state.confirmPassword) {
this.setState({ passwordError: this.props.t ? this.props.t('settings.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
this.setState({ passwordError: 'Bitte füllen Sie alle Felder aus' });
return;
}
if (this.state.newPassword !== this.state.confirmPassword) {
this.setState({ passwordError: this.props.t ? this.props.t('settings.errors.passwordsNotMatch') : 'Die neuen Passwörter stimmen nicht überein' });
this.setState({ passwordError: 'Die neuen Passwörter stimmen nicht überein' });
return;
}
if (this.state.newPassword.length < 8) {
this.setState({ passwordError: this.props.t ? this.props.t('settings.errors.passwordTooShort') : 'Das neue Passwort muss mindestens 8 Zeichen lang sein' });
this.setState({ passwordError: 'Das neue Passwort muss mindestens 8 Zeichen lang sein' });
return;
}
this.setState({ loading: true });
// Call socket.io endpoint to update password
window.socketManager.emit('updatePassword',
this.props.socket.emit('updatePassword',
{ oldPassword: this.state.currentPassword, newPassword: this.state.newPassword },
(response) => {
this.setState({ loading: false });
if (response.success) {
this.setState({
passwordSuccess: this.props.t ? this.props.t('settings.success.passwordUpdated') : 'Passwort erfolgreich aktualisiert',
passwordSuccess: 'Passwort erfolgreich aktualisiert',
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
} else {
this.setState({
passwordError: response.message || (this.props.t ? this.props.t('settings.errors.passwordUpdateError') : 'Fehler beim Aktualisieren des Passworts')
passwordError: response.message || 'Fehler beim Aktualisieren des Passworts'
});
}
}
@@ -122,26 +121,26 @@ class SettingsTab extends Component {
// Validation
if (!this.state.password || !this.state.newEmail) {
this.setState({ emailError: this.props.t ? this.props.t('settings.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
this.setState({ emailError: 'Bitte füllen Sie alle Felder aus' });
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.state.newEmail)) {
this.setState({ emailError: this.props.t ? this.props.t('settings.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
this.setState({ emailError: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
return;
}
this.setState({ loading: true });
// Call socket.io endpoint to update email
window.socketManager.emit('updateEmail',
this.props.socket.emit('updateEmail',
{ password: this.state.password, email: this.state.newEmail },
(response) => {
this.setState({ loading: false });
if (response.success) {
this.setState({
emailSuccess: this.props.t ? this.props.t('settings.success.emailUpdated') : 'E-Mail-Adresse erfolgreich aktualisiert',
emailSuccess: 'E-Mail-Adresse erfolgreich aktualisiert',
password: ''
});
@@ -158,7 +157,7 @@ class SettingsTab extends Component {
}
} else {
this.setState({
emailError: response.message || (this.props.t ? this.props.t('settings.errors.emailUpdateError') : 'Fehler beim Aktualisieren der E-Mail-Adresse')
emailError: response.message || 'Fehler beim Aktualisieren der E-Mail-Adresse'
});
}
}
@@ -184,7 +183,7 @@ class SettingsTab extends Component {
try {
const user = JSON.parse(storedUser);
window.socketManager.emit('createApiKey', user.id, (response) => {
this.props.socket.emit('createApiKey', user.id, (response) => {
this.setState({ loadingApiKey: false });
if (response.success) {
@@ -239,7 +238,7 @@ class SettingsTab extends Component {
<Box sx={{ p: { xs: 1, sm: 3 } }}>
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
{this.props.t ? this.props.t('settings.changePassword') : 'Passwort ändern'}
Passwort ändern
</Typography>
{this.state.passwordError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.passwordError}</Alert>}
@@ -248,7 +247,7 @@ class SettingsTab extends Component {
<Box component="form" onSubmit={this.handleUpdatePassword}>
<TextField
margin="normal"
label={this.props.t ? this.props.t('settings.currentPassword') : 'Aktuelles Passwort'}
label="Aktuelles Passwort"
type="password"
fullWidth
value={this.state.currentPassword}
@@ -258,7 +257,7 @@ class SettingsTab extends Component {
<TextField
margin="normal"
label={this.props.t ? this.props.t('settings.newPassword') : 'Neues Passwort'}
label="Neues Passwort"
type="password"
fullWidth
value={this.state.newPassword}
@@ -268,7 +267,7 @@ class SettingsTab extends Component {
<TextField
margin="normal"
label={this.props.t ? this.props.t('settings.confirmNewPassword') : 'Neues Passwort bestätigen'}
label="Neues Passwort bestätigen"
type="password"
fullWidth
value={this.state.confirmPassword}
@@ -283,7 +282,7 @@ class SettingsTab extends Component {
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
disabled={this.state.loading}
>
{this.state.loading ? <CircularProgress size={24} /> : (this.props.t ? this.props.t('settings.updatePassword') : 'Passwort aktualisieren')}
{this.state.loading ? <CircularProgress size={24} /> : 'Passwort aktualisieren'}
</Button>
</Box>
</Paper>
@@ -292,7 +291,7 @@ class SettingsTab extends Component {
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
{this.props.t ? this.props.t('settings.changeEmail') : 'E-Mail-Adresse ändern'}
E-Mail-Adresse ändern
</Typography>
{this.state.emailError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.emailError}</Alert>}
@@ -301,7 +300,7 @@ class SettingsTab extends Component {
<Box component="form" onSubmit={this.handleUpdateEmail}>
<TextField
margin="normal"
label={this.props.t ? this.props.t('settings.password') : 'Passwort'}
label="Passwort"
type="password"
fullWidth
value={this.state.password}
@@ -311,7 +310,7 @@ class SettingsTab extends Component {
<TextField
margin="normal"
label={this.props.t ? this.props.t('settings.newEmail') : 'Neue E-Mail-Adresse'}
label="Neue E-Mail-Adresse"
type="email"
fullWidth
value={this.state.newEmail}
@@ -326,7 +325,7 @@ class SettingsTab extends Component {
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
disabled={this.state.loading}
>
{this.state.loading ? <CircularProgress size={24} /> : (this.props.t ? this.props.t('settings.updateEmail') : 'E-Mail aktualisieren')}
{this.state.loading ? <CircularProgress size={24} /> : 'E-Mail aktualisieren'}
</Button>
</Box>
</Paper>
@@ -335,11 +334,11 @@ class SettingsTab extends Component {
<Paper sx={{ p: { xs: 2, sm: 3 } }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
{this.props.t ? this.props.t('settings.apiKey') : 'API-Schlüssel'}
API-Schlüssel
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
{this.props.t ? this.props.t('settings.apiKeyDescription') : 'Verwenden Sie Ihren API-Schlüssel für die Integration mit externen Anwendungen.'}
Verwenden Sie Ihren API-Schlüssel für die Integration mit externen Anwendungen.
</Typography>
{this.state.apiKeyError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.apiKeyError}</Alert>}
@@ -348,14 +347,14 @@ class SettingsTab extends Component {
{this.state.apiKeySuccess}
{this.state.apiKey && this.state.apiKeyDisplay !== '************' && (
<Typography variant="body2" sx={{ mt: 1 }}>
{this.props.t ? this.props.t('settings.success.apiKeyWarning') : 'Speichern Sie diesen Schlüssel sicher. Er wird aus Sicherheitsgründen in 10 Sekunden ausgeblendet.'}
Speichern Sie diesen Schlüssel sicher. Er wird aus Sicherheitsgründen in 10 Sekunden ausgeblendet.
</Typography>
)}
</Alert>
)}
<Typography variant="body2" sx={{ mb: 2 }}>
{this.props.t ? this.props.t('settings.apiDocumentation') : 'API-Dokumentation:'} {' '}
API-Dokumentation: {' '}
<a
href={`${window.location.protocol}//${window.location.host}/api/`}
target="_blank"
@@ -368,7 +367,7 @@ class SettingsTab extends Component {
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mt: 2 }}>
<TextField
label={this.props.t ? this.props.t('settings.apiKey') : 'API-Schlüssel'}
label="API-Schlüssel"
value={this.state.apiKeyDisplay}
disabled
fullWidth
@@ -382,12 +381,11 @@ class SettingsTab extends Component {
{this.state.apiKeyDisplay !== '************' && this.state.apiKey && (
<IconButton
onClick={this.handleCopyToClipboard}
aria-label={this.props.t ? this.props.t('settings.copyToClipboard') : 'In Zwischenablage kopieren'}
sx={{
color: '#2e7d32',
'&:hover': { bgcolor: 'rgba(46, 125, 50, 0.1)' }
}}
title={this.props.t ? this.props.t('settings.copyToClipboard') : 'In Zwischenablage kopieren'}
title="In Zwischenablage kopieren"
>
<ContentCopy />
</IconButton>
@@ -407,7 +405,7 @@ class SettingsTab extends Component {
{this.state.loadingApiKey ? (
<CircularProgress size={24} />
) : (
this.state.hasApiKey ? (this.props.t ? this.props.t('settings.regenerate') : 'Regenerieren') : (this.props.t ? this.props.t('settings.generate') : 'Generieren')
this.state.hasApiKey ? 'Regenerieren' : 'Generieren'
)}
</Button>
</Box>
@@ -417,7 +415,7 @@ class SettingsTab extends Component {
open={this.state.copySnackbarOpen}
autoHideDuration={3000}
onClose={this.handleCloseSnackbar}
message={this.props.t ? this.props.t('settings.apiKeyCopied') : 'API-Schlüssel in Zwischenablage kopiert'}
message="API-Schlüssel in Zwischenablage kopiert"
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
/>
</Box>
@@ -425,4 +423,4 @@ class SettingsTab extends Component {
}
}
export default withI18n()(SettingsTab);
export default SettingsTab;

View File

@@ -3,200 +3,23 @@ const config = {
apiBaseUrl: "",
googleClientId: "928121624463-jbgfdlgem22scs1k9c87ucg4ffvaik6o.apps.googleusercontent.com",
stripePublishableKey: "pk_test_51R7lltRtpe3h1vwJzIrDb5bcEigTLBHrtqj9SiPX7FOEATSuD6oJmKc8xpNp49ShpGJZb2GShHIUqj4zlSIz4olj00ipOuOAnu",
mollieProfileKey: "pfl_AtcRTimCff",
// SEO and Business Information
siteName: "Growheads.de",
brandName: "GrowHeads",
currency: "EUR",
language: "de-DE", // Will be updated dynamically based on i18n
language: "de-DE",
country: "DE",
// Multilingual configurations
languages: {
de: {
code: "de-DE",
name: "Deutsch",
shortName: "DE"
},
en: {
code: "en-US",
name: "English",
shortName: "EN"
},
es: {
code: "es-ES",
name: "Español",
shortName: "ES"
},
fr: {
code: "fr-FR",
name: "Français",
shortName: "FR"
},
it: {
code: "it-IT",
name: "Italiano",
shortName: "IT"
},
pl: {
code: "pl-PL",
name: "Polski",
shortName: "PL"
},
hu: {
code: "hu-HU",
name: "Magyar",
shortName: "HU"
},
sr: {
code: "sr-RS",
name: "Српски",
shortName: "SR"
},
bg: {
code: "bg-BG",
name: "Български",
shortName: "BG"
},
ru: {
code: "ru-RU",
name: "Русский",
shortName: "RU"
},
uk: {
code: "uk-UA",
name: "Українська",
shortName: "UK"
},
sk: {
code: "sk-SK",
name: "Slovenčina",
shortName: "SK"
},
sl: {
code: "sl-SI",
name: "Slovenščina",
shortName: "SI"
},
cs: {
code: "cs-CZ",
name: "Čeština",
shortName: "CS"
},
ro: {
code: "ro-RO",
name: "Română",
shortName: "RO"
},
hr: {
code: "hr-HR",
name: "Hrvatski",
shortName: "HR"
},
sv: {
code: "sv-SE",
name: "Svenska",
shortName: "SE"
},
tr: {
code: "tr-TR",
name: "Türkçe",
shortName: "TR"
},
el: {
code: "el-GR",
name: "Ελληνικά",
shortName: "GR"
},
ar: {
code: "ar-EG",
name: "العربية",
shortName: "EG"
},
zh: {
code: "zh-CN",
name: "中文",
shortName: "CN"
}
},
// Shop Descriptions - Multilingual
// Shop Descriptions
descriptions: {
de: {
short: "GrowHeads - Online-Shop für Cannabis-Samen, Stecklinge und Gartenbedarf",
short: "GrowHeads - Online-Shop für Cannanis-Samen, Stecklinge und Gartenbedarf",
long: "GrowHeads - Ihr Online-Shop für hochwertige Samen, Pflanzen und Gartenbedarf zur Cannabis Kultivierung. Entdecken Sie unser großes Sortiment an Saatgut, Pflanzen und Gartenzubehör für Ihren grünen Daumen."
},
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"
},
// Keywords
keywords: "Seeds, Steckling, Cannabis, Biobizz, Growheads",
// Shipping
shipping: {

View File

@@ -1,225 +0,0 @@
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;

View File

@@ -0,0 +1,7 @@
import React from 'react';
// Create a new context for Socket.IO
const SocketContext = React.createContext(null);
export const SocketConsumer = SocketContext.Consumer;
export default SocketContext;

View File

@@ -41,3 +41,229 @@ export const tentShapes = [
visualDepth: 60
}
];
export const tentSizes = [
// 60x60 tents
{
id: 'tent_60x60x140',
name: 'Basic 140cm',
description: 'Einsteigermodell',
price: 89.99,
image: '/assets/images/nopicture.jpg',
dimensions: '60x60x140cm',
coverage: '1-2 Pflanzen',
shapeId: '60x60',
height: 140
},
{
id: 'tent_60x60x160',
name: 'Premium 160cm',
description: 'Mehr Höhe für größere Pflanzen',
price: 109.99,
image: '/assets/images/nopicture.jpg',
dimensions: '60x60x160cm',
coverage: '1-2 Pflanzen',
shapeId: '60x60',
height: 160
},
// 80x80 tents
{
id: 'tent_80x80x160',
name: 'Standard 160cm',
description: 'Beliebtes Mittelklasse-Modell',
price: 129.99,
image: '/assets/images/nopicture.jpg',
dimensions: '80x80x160cm',
coverage: '2-4 Pflanzen',
shapeId: '80x80',
height: 160
},
{
id: 'tent_80x80x180',
name: 'Pro 180cm',
description: 'Extra Höhe für optimales Wachstum',
price: 149.99,
image: '/assets/images/nopicture.jpg',
dimensions: '80x80x180cm',
coverage: '2-4 Pflanzen',
shapeId: '80x80',
height: 180
},
// 100x100 tents
{
id: 'tent_100x100x180',
name: 'Professional 180cm',
description: 'Für anspruchsvolle Projekte',
price: 189.99,
image: '/assets/images/nopicture.jpg',
dimensions: '100x100x180cm',
coverage: '4-6 Pflanzen',
shapeId: '100x100',
height: 180
},
{
id: 'tent_100x100x200',
name: 'Expert 200cm',
description: 'Maximum an Wuchshöhe',
price: 219.99,
image: '/assets/images/nopicture.jpg',
dimensions: '100x100x200cm',
coverage: '4-6 Pflanzen',
shapeId: '100x100',
height: 200
},
// 120x60 tents
{
id: 'tent_120x60x160',
name: 'Rectangular 160cm',
description: 'Platzsparend und effizient',
price: 139.99,
image: '/assets/images/nopicture.jpg',
dimensions: '120x60x160cm',
coverage: '3-6 Pflanzen',
shapeId: '120x60',
height: 160
},
{
id: 'tent_120x60x180',
name: 'Rectangular Pro 180cm',
description: 'Optimale Raumausnutzung',
price: 169.99,
image: '/assets/images/nopicture.jpg',
dimensions: '120x60x180cm',
coverage: '3-6 Pflanzen',
shapeId: '120x60',
height: 180
}
];
export const lightTypes = [
{
id: 'led_quantum_board',
name: 'LED Quantum Board',
description: 'Energieeffizient, geringe Wärmeentwicklung',
price: 159.99,
image: '/assets/images/nopicture.jpg',
wattage: '240W',
coverage: 'Bis 100x100cm',
spectrum: 'Vollspektrum',
efficiency: 'Sehr hoch'
},
{
id: 'led_cob',
name: 'LED COB',
description: 'Hochintensive COB-LEDs',
price: 199.99,
image: '/assets/images/nopicture.jpg',
wattage: '300W',
coverage: 'Bis 120x120cm',
spectrum: 'Vollspektrum',
efficiency: 'Hoch'
},
{
id: 'hps_400w',
name: 'HPS 400W',
description: 'Bewährte Natriumdampflampe',
price: 89.99,
image: '/assets/images/nopicture.jpg',
wattage: '400W',
coverage: 'Bis 80x80cm',
spectrum: 'Blüte-optimiert',
efficiency: 'Mittel'
},
{
id: 'cmh_315w',
name: 'CMH 315W',
description: 'Keramik-Metallhalogenid',
price: 129.99,
image: '/assets/images/nopicture.jpg',
wattage: '315W',
coverage: 'Bis 90x90cm',
spectrum: 'Natürlich',
efficiency: 'Hoch'
}
];
export const ventilationTypes = [
{
id: 'basic_exhaust',
name: 'Basic Abluft-Set',
description: 'Lüfter + Aktivkohlefilter',
price: 79.99,
image: '/assets/images/nopicture.jpg',
airflow: '187 m³/h',
noiseLevel: '35 dB',
includes: ['Rohrventilator', 'Aktivkohlefilter', 'Aluflexrohr']
},
{
id: 'premium_ventilation',
name: 'Premium Klima-Set',
description: 'Komplette Klimakontrolle',
price: 159.99,
image: '/assets/images/nopicture.jpg',
airflow: '280 m³/h',
noiseLevel: '28 dB',
includes: ['EC-Lüfter', 'Aktivkohlefilter', 'Thermostat', 'Feuchtigkeitsmesser']
},
{
id: 'pro_climate',
name: 'Profi Klima-System',
description: 'Automatisierte Klimasteuerung',
price: 299.99,
image: '/assets/images/nopicture.jpg',
airflow: '420 m³/h',
noiseLevel: '25 dB',
includes: ['Digitaler Controller', 'EC-Lüfter', 'Aktivkohlefilter', 'Zu-/Abluft']
}
];
export const extras = [
{
id: 'ph_tester',
name: 'pH-Messgerät',
description: 'Digitales pH-Meter',
price: 29.99,
image: '/assets/images/nopicture.jpg',
category: 'Messung'
},
{
id: 'nutrients_starter',
name: 'Dünger Starter-Set',
description: 'Komplettes Nährstoff-Set',
price: 39.99,
image: '/assets/images/nopicture.jpg',
category: 'Nährstoffe'
},
{
id: 'grow_pots',
name: 'Grow-Töpfe Set (5x)',
description: '5x Stofftöpfe 11L',
price: 24.99,
image: '/assets/images/nopicture.jpg',
category: 'Töpfe'
},
{
id: 'timer_socket',
name: 'Zeitschaltuhr',
description: 'Digitale Zeitschaltuhr',
price: 19.99,
image: '/assets/images/nopicture.jpg',
category: 'Steuerung'
},
{
id: 'thermometer',
name: 'Thermo-Hygrometer',
description: 'Min/Max Temperatur & Luftfeuchtigkeit',
price: 14.99,
image: '/assets/images/nopicture.jpg',
category: 'Messung'
},
{
id: 'pruning_shears',
name: 'Gartenschere',
description: 'Präzisions-Gartenschere',
price: 16.99,
image: '/assets/images/nopicture.jpg',
category: 'Werkzeug'
}
];

View File

@@ -1,260 +0,0 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
// Note: LanguageDetector not used - we have custom detector
// Only import German translations by default
import translationDE from './locales/de/index.js';
import legalAgbDeliveryDE from './locales/de/legal-agb-delivery.js';
import legalAgbPaymentDE from './locales/de/legal-agb-payment.js';
import legalAgbConsumerDE from './locales/de/legal-agb-consumer.js';
import legalDatenschutzBasicDE from './locales/de/legal-datenschutz-basic.js';
import legalDatenschutzCustomerDE from './locales/de/legal-datenschutz-customer.js';
import legalDatenschutzGoogleOrdersDE from './locales/de/legal-datenschutz-google-orders.js';
import legalDatenschutzNewsletterDE from './locales/de/legal-datenschutz-newsletter.js';
import legalDatenschutzChatbotDE from './locales/de/legal-datenschutz-chatbot.js';
import legalDatenschutzCookiesPaymentDE from './locales/de/legal-datenschutz-cookies-payment.js';
import legalDatenschutzRightsDE from './locales/de/legal-datenschutz-rights.js';
import legalImpressumDE from './locales/de/legal-impressum.js';
import legalWiderrufDE from './locales/de/legal-widerruf.js';
import legalBatterieDE from './locales/de/legal-batterie.js';
// Language loading cache to prevent duplicate loads
const languageCache = new Set(['de']);
const loadingPromises = new Map();
// Lazy loading function for languages
const loadLanguage = async (language) => {
if (languageCache.has(language)) {
return; // Already loaded
}
if (loadingPromises.has(language)) {
return loadingPromises.get(language); // Already loading
}
const loadingPromise = (async () => {
try {
console.log(`🌍 Lazy loading language: ${language}`);
// Dynamic imports for lazy loading
const [
translation,
legalAgbDelivery,
legalAgbPayment,
legalAgbConsumer,
legalDatenschutzBasic,
legalDatenschutzCustomer,
legalDatenschutzGoogleOrders,
legalDatenschutzNewsletter,
legalDatenschutzChatbot,
legalDatenschutzCookiesPayment,
legalDatenschutzRights,
legalImpressum,
legalWiderruf,
legalBatterie
] = await Promise.all([
import(`./locales/${language}/index.js`),
import(`./locales/${language}/legal-agb-delivery.js`),
import(`./locales/${language}/legal-agb-payment.js`),
import(`./locales/${language}/legal-agb-consumer.js`),
import(`./locales/${language}/legal-datenschutz-basic.js`),
import(`./locales/${language}/legal-datenschutz-customer.js`),
import(`./locales/${language}/legal-datenschutz-google-orders.js`),
import(`./locales/${language}/legal-datenschutz-newsletter.js`),
import(`./locales/${language}/legal-datenschutz-chatbot.js`),
import(`./locales/${language}/legal-datenschutz-cookies-payment.js`),
import(`./locales/${language}/legal-datenschutz-rights.js`),
import(`./locales/${language}/legal-impressum.js`),
import(`./locales/${language}/legal-widerruf.js`),
import(`./locales/${language}/legal-batterie.js`)
]);
// Add the loaded resources to i18n
i18n.addResourceBundle(language, 'translation', translation.default);
i18n.addResourceBundle(language, 'legal-agb-delivery', legalAgbDelivery.default);
i18n.addResourceBundle(language, 'legal-agb-payment', legalAgbPayment.default);
i18n.addResourceBundle(language, 'legal-agb-consumer', legalAgbConsumer.default);
i18n.addResourceBundle(language, 'legal-datenschutz-basic', legalDatenschutzBasic.default);
i18n.addResourceBundle(language, 'legal-datenschutz-customer', legalDatenschutzCustomer.default);
i18n.addResourceBundle(language, 'legal-datenschutz-google-orders', legalDatenschutzGoogleOrders.default);
i18n.addResourceBundle(language, 'legal-datenschutz-newsletter', legalDatenschutzNewsletter.default);
i18n.addResourceBundle(language, 'legal-datenschutz-chatbot', legalDatenschutzChatbot.default);
i18n.addResourceBundle(language, 'legal-datenschutz-cookies-payment', legalDatenschutzCookiesPayment.default);
i18n.addResourceBundle(language, 'legal-datenschutz-rights', legalDatenschutzRights.default);
i18n.addResourceBundle(language, 'legal-impressum', legalImpressum.default);
i18n.addResourceBundle(language, 'legal-widerruf', legalWiderruf.default);
i18n.addResourceBundle(language, 'legal-batterie', legalBatterie.default);
languageCache.add(language);
console.log(`✅ Language ${language} loaded successfully`);
} catch (error) {
console.error(`❌ Failed to load language ${language}:`, error);
throw error;
} finally {
loadingPromises.delete(language);
}
})();
loadingPromises.set(language, loadingPromise);
return loadingPromise;
};
// Custom language detector that prioritizes session storage and defaults to German
const customDetector = {
name: 'customDetector',
lookup() {
// Only try storage in browser environment
if (typeof window === 'undefined') {
return 'de';
}
// 1. Check session storage first
try {
if (typeof sessionStorage !== 'undefined') {
const sessionLang = sessionStorage.getItem('i18nextLng');
if (sessionLang && sessionLang !== 'de') {
return sessionLang;
}
}
} catch {
// Session storage not available
}
// 2. Check localStorage
try {
if (typeof localStorage !== 'undefined') {
const localLang = localStorage.getItem('i18nextLng');
if (localLang && localLang !== 'de') {
return localLang;
}
}
} catch {
// LocalStorage not available
}
// 3. Always default to German (don't detect browser language)
return 'de';
},
cacheUserLanguage(lng) {
// Only cache in browser environment
if (typeof window === 'undefined') {
return;
}
try {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.setItem('i18nextLng', lng);
}
if (typeof localStorage !== 'undefined') {
localStorage.setItem('i18nextLng', lng);
}
} catch {
// Storage not available
}
}
};
// Initialize i18n with only German resources
const resources = {
de: {
translation: translationDE,
'legal-agb-delivery': legalAgbDeliveryDE,
'legal-agb-payment': legalAgbPaymentDE,
'legal-agb-consumer': legalAgbConsumerDE,
'legal-datenschutz-basic': legalDatenschutzBasicDE,
'legal-datenschutz-customer': legalDatenschutzCustomerDE,
'legal-datenschutz-google-orders': legalDatenschutzGoogleOrdersDE,
'legal-datenschutz-newsletter': legalDatenschutzNewsletterDE,
'legal-datenschutz-chatbot': legalDatenschutzChatbotDE,
'legal-datenschutz-cookies-payment': legalDatenschutzCookiesPaymentDE,
'legal-datenschutz-rights': legalDatenschutzRightsDE,
'legal-impressum': legalImpressumDE,
'legal-widerruf': legalWiderrufDE,
'legal-batterie': legalBatterieDE
}
};
i18n
.use({
type: 'languageDetector',
async: false,
detect: customDetector.lookup,
init() {},
cacheUserLanguage: customDetector.cacheUserLanguage
})
.use(initReactI18next)
.init({
resources,
fallbackLng: 'de',
debug: process.env.NODE_ENV === 'development',
// Disable automatic language detection from browser
detection: {
order: ['customDetector'],
caches: ['localStorage', 'sessionStorage']
},
interpolation: {
escapeValue: false // React already escapes values
},
// Namespace configuration
defaultNS: 'translation',
// React-specific options
react: {
useSuspense: false // Disable suspense for class components compatibility
},
// Load missing keys as fallback
saveMissing: process.env.NODE_ENV === 'development'
});
// Override changeLanguage to load languages on demand
const originalChangeLanguage = i18n.changeLanguage.bind(i18n);
i18n.changeLanguage = async (language) => {
if (language !== 'de' && !languageCache.has(language)) {
try {
await loadLanguage(language);
} catch {
console.error(`Failed to load language ${language}, falling back to German`);
language = 'de';
}
}
return originalChangeLanguage(language);
};
// Check session storage on initialization and load language if needed
const initializeLanguage = async () => {
// Only run in browser environment
if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
return;
}
try {
const sessionLang = sessionStorage.getItem('i18nextLng');
if (sessionLang && sessionLang !== 'de' && !languageCache.has(sessionLang)) {
console.log(`🔄 Restoring session language: ${sessionLang}`);
await loadLanguage(sessionLang);
await i18n.changeLanguage(sessionLang);
}
} catch {
console.warn('Failed to restore session language');
}
};
// Initialize language on DOM ready (browser only)
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeLanguage);
} else {
initializeLanguage();
}
}
export default i18n;
export { loadLanguage };
// Re-export withI18n and other utilities for compatibility
export { withI18n, withTranslation, withLanguage, LanguageContext, LanguageProvider } from './withTranslation.js';

View File

@@ -1,25 +0,0 @@
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": "مستخدمو المسؤول"
}
};

View File

@@ -1,39 +0,0 @@
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": "العربة المحفوظة في ملفك الشخصي"
}
};

View File

@@ -1,3 +0,0 @@
export default {
"privacyRead": "تم القراءة والموافقة",
};

View File

@@ -1,34 +0,0 @@
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": "المدينة مطلوبة"
}
};

View File

@@ -1,20 +0,0 @@
export default {
"loading": "جارٍ التحميل...",
"error": "خطأ",
"close": "إغلاق",
"save": "حفظ",
"cancel": "إلغاء",
"ok": "موافق",
"yes": "نعم",
"no": "لا",
"next": "التالي",
"back": "رجوع",
"edit": "تعديل",
"delete": "حذف",
"add": "إضافة",
"remove": "إزالة",
"products": "منتجات",
"product": "منتج",
"days": "أيام",
"more": "المزيد"
};

View File

@@ -1,35 +0,0 @@
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}}€ مؤهلة للشحن المجاني."
}
};

View File

@@ -1,7 +0,0 @@
export default {
"sorting": "الترتيب",
"perPage": "لكل صفحة",
"availability": "التوفر",
"manufacturer": "الشركة المصنعة",
"all": "الكل"
};

View File

@@ -1,15 +0,0 @@
export default {
"hours": "السبت 11 صباحًا - 7 مساءً",
"address": "شارع تراشنبرجر 14 - دريسدن",
"location": "بين محطة بيسشن وميدان تراشنبرجر",
"allPricesIncl": "* جميع الأسعار تشمل ضريبة القيمة المضافة القانونية، بالإضافة إلى الشحن",
"copyright": "© {{year}} GrowHeads.de",
"legal": {
"datenschutz": "سياسة الخصوصية",
"agb": "الشروط والأحكام",
"sitemap": "خريطة الموقع",
"impressum": "الإشعار القانوني",
"batteriegesetzhinweise": "معلومات قانون البطاريات",
"widerrufsrecht": "حق الانسحاب"
}
};

View File

@@ -1,63 +0,0 @@
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';
import legalDatenschutzBasic from './legal-datenschutz-basic.js';
import legalDatenschutzCustomer from './legal-datenschutz-customer.js';
import legalDatenschutzGoogleOrders from './legal-datenschutz-google-orders.js';
import legalDatenschutzNewsletter from './legal-datenschutz-newsletter.js';
import legalDatenschutzChatbot from './legal-datenschutz-chatbot.js';
import legalDatenschutzCookiesPayment from './legal-datenschutz-cookies-payment.js';
import legalDatenschutzRights from './legal-datenschutz-rights.js';
import legalAgbDelivery from './legal-agb-delivery.js';
import legalAgbPayment from './legal-agb-payment.js';
import legalAgbConsumer from './legal-agb-consumer.js';
export default {
"locale": locale,
"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,
"legalDatenschutzBasic": legalDatenschutzBasic,
"legalDatenschutzCustomer": legalDatenschutzCustomer,
"legalDatenschutzGoogleOrders": legalDatenschutzGoogleOrders,
"legalDatenschutzNewsletter": legalDatenschutzNewsletter,
"legalDatenschutzChatbot": legalDatenschutzChatbot,
"legalDatenschutzCookiesPayment": legalDatenschutzCookiesPayment,
"legalDatenschutzRights": legalDatenschutzRights,
"legalAgbDelivery": legalAgbDelivery,
"legalAgbPayment": legalAgbPayment,
"legalAgbConsumer": legalAgbConsumer
};

View File

@@ -1,37 +0,0 @@
export default {
"distanceSelling": {
"title": "معلومات وفقًا لقانون البيع عن بُعد",
"intro": "تنطبق المعلومات التالية فقط على العقود المبرمة بين Growheads والمستهلكين عن طريق طلب الكتالوج، طلب الإنترنت، أو وسائل الاتصال عن بُعد الأخرى. وهي محدودة للمستهلكين داخل الاتحاد الأوروبي.",
"sections": {
"1": {
"title": "الخصائص الأساسية للسلع",
"content": "يرجى الرجوع إلى الشروحات في الكتالوج أو على موقعنا الإلكتروني لمعرفة الخصائص الأساسية للسلع. العروض في كتالوجنا وعلى موقعنا الإلكتروني غير ملزمة. الطلبات المقدمة إلينا تُعتبر عروضًا ملزمة. يمكن لـ Growheads قبول هذه الطلبات خلال فترة 14 يومًا من استلام الطلب عن طريق إرسال تأكيد الطلب أو عن طريق شحن البضاعة."
},
"2": {
"title": "التحفظ",
"content": "إذا لم تكن جميع الأصناف المطلوبة متاحة للتسليم، نحتفظ بالحق في إجراء تسليمات جزئية، بشرط أن يكون ذلك معقولًا للعميل. قد تختلف بعض الأصناف عن الصور والوصف في الكتالوج وعلى الموقع الإلكتروني. هذا ينطبق بشكل خاص على السلع المصنوعة يدويًا. لذلك نحتفظ بالحق، إذا لزم الأمر، في تسليم سلع ذات جودة وسعر مكافئين."
},
"3": {
"title": "الأسعار والضرائب",
"content": "يمكنك العثور على أسعار الأصناف الفردية شاملة ضريبة القيمة المضافة في الكتالوج أو على موقعنا الإلكتروني. تفقد الأسعار صلاحيتها عند صدور كتالوج جديد."
},
"4": "جميع الأسعار عرضة للأخطاء أو تقلبات الأسعار. إذا حدث تغيير في السعر، يحق للمشتري ممارسة حقه في الإرجاع.",
"5": {
"title": "فترة الضمان",
"content": "تطبق فترة الضمان القانونية لمدة 24 (أربعة وعشرين) شهرًا. في بعض الحالات الفردية، قد تنطبق فترات أطول إذا منحها المصنع."
},
"6": {
"title": "حق الإرجاع / حق الانسحاب",
"content": "يتمتع العميل بحق إرجاع لمدة 14 يومًا.\nتبدأ الفترة عند استلام العميل للبضاعة ويتم احترامها بإرسال الانسحاب في الوقت المناسب إلى Growheads. تستثنى من ذلك المواد الغذائية وغيرها من السلع القابلة للتلف، وكذلك المنتجات المصممة خصيصًا أو السلع التي تم طلبها خصيصًا بناءً على طلب العميل. يجب أن يتم الإرجاع عن طريق إعادة إرسال البضاعة خلال الفترة المحددة. إذا لم يكن بالإمكان شحن البضاعة، يجب إرسال طلب الإرجاع إلينا خلال الفترة عن طريق رسالة، بطاقة بريدية، بريد إلكتروني، أو أي وسيلة دائمة أخرى. يكفي الإرسال في الوقت المناسب إلى عنوان الشركة المذكور في البند 7) للحفاظ على الموعد النهائي. لا يتطلب الانسحاب سببًا. سيتم رد ثمن الشراء وأي تكاليف توصيل وشحن بعد استلامنا للبضاعة. القيمة الحاسمة هي قيمة البضاعة المعادة وقت الشراء، وليس قيمة الطلب الكامل. عادةً ما يمكن لـ Growheads ترتيب استلام البضاعة منك."
},
"7": {
"title": "اسم وعنوان الشركة، الشكاوى، الاستدعاءات",
"content": "Growheads\nTrachenberger Straße 14\n01129 Dresden"
},
"8": {
"title": "مكان التنفيذ والاختصاص القضائي",
"content": "مكان التنفيذ والاختصاص القضائي لجميع المطالبات هو دريسدن، ما لم تنص أحكام قانونية إلزامية على خلاف ذلك."
}
}
}
};

View File

@@ -1,20 +0,0 @@
export default {
"title": "الشروط والأحكام العامة",
"deliveryShippingConditions": "شروط التسليم والشحن",
"deliveryTerms": {
"1": "يستغرق الشحن من 1 إلى 7 أيام.",
"2": "تظل البضاعة ملكًا لشركة Growheads حتى يتم استلام الدفعة كاملة.",
"3": "إذا كان هناك اشتباه في أن البضاعة قد تضررت أثناء النقل أو أن هناك عناصر مفقودة، يجب الاحتفاظ بتغليف الشحن لفحصه من قبل خبير. يجب أن يؤكد الناقل أي ضرر في التغليف على سند التسليم، مع تحديد نوع الضرر ومداه. يجب الإبلاغ عن أضرار الشحن إلى Growheads فورًا كتابيًا عبر الفاكس أو البريد الإلكتروني أو البريد. لهذا الغرض، يجب التقاط صور للبضاعة التالفة وكذلك لصندوق الشحن التالف مع ملصق العنوان. يجب أيضًا الاحتفاظ بصندوق الشحن التالف. هذه الوثائق مطلوبة للمطالبة بالتعويض من شركة النقل.",
"4": "عند إعادة البضائع المعيبة، يجب على العميل التأكد من أن البضائع معبأة بشكل صحيح.",
"5": "يجب تسجيل جميع عمليات الإرجاع مسبقًا لدى Growheads.",
"6": "يتحمل العميل مخاطر إرسال العناصر إلينا، ما لم يكن الأمر يتعلق بإرجاع بضائع معيبة.",
"7": "يحق لـ Growheads أن تطلب استلام البضاعة من خلال Deutsche Post/GLS أو شركة شحن تختارها.",
"8": "يتم حساب تكاليف البريد بناءً على الوزن. تحتفظ Growheads بحق تمرير أي زيادات في الأسعار من شركات النقل (رسوم المرور، رسوم الوقود).",
"9": "عادةً ما يتم شحن طرودنا عبر: GLS، DHL و Deutsche Post AG.",
"10": "بالنسبة للعناصر الثقيلة أو الضخمة بشكل خاص، نحتفظ بالحق في فرض رسوم إضافية على تكاليف التسليم. عادةً ما تكون هذه الرسوم مذكورة في قائمة الأسعار.",
"11": "يمكن الدفع مقدمًا عن طريق التحويل البنكي إلى الحساب المصرفي المحدد.",
"12": "إذا حدث تأخير في التسليم نتحمل مسؤوليته، فإن فترة السماح التي يحق للمشتري تحديدها محدودة بأسبوعين. تبدأ الفترة من استلام Growheads لإشعار فترة السماح.",
"13": "يجب الإبلاغ كتابيًا عن العيوب الظاهرة في البضاعة فور التسليم. إذا لم يلتزم العميل بهذا الالتزام، تُستبعد مطالبات الضمان المتعلقة بالعيوب الظاهرة.",
"14": "إذا اشتكى العميل من عيب، يجب عليه إعادة البضاعة المعيبة إلينا مع وصف دقيق للعيب قدر الإمكان. يجب إرفاق نسخة من فاتورتنا مع الشحنة. يجب إعادة البضاعة في التغليف الأصلي أو في تغليف يحمي البضاعة بنفس طريقة التغليف الأصلي، لتجنب التلف أثناء الإرجاع."
}
};

View File

@@ -1,16 +0,0 @@
export default {
"consultationLiability": {
"title": "الاستشارة والمسؤولية",
"1": "نقدم نصائح فنية تطبيقية حسب أفضل معرفتنا بناءً على مستوى خبرتنا ومعرفتنا الحالي.",
"2": "المشتري مسؤول عن الالتزام باللوائح القانونية المتعلقة بالتخزين، والنقل الإضافي، واستخدام بضائعنا.",
},
"paymentConditions": {
"title": "شروط الدفع",
"1": "تظل البضائع ملكًا لشركة Growheads حتى يتم استلام الدفعة كاملة.",
"2": "يتم دفع الفواتير مقدمًا عن طريق التحويل البنكي إلى حسابنا البنكي. إذا دفعت مقدمًا، سيتم شحن البضائع بمجرد تسجيل المبلغ في حسابنا.",
},
"retentionOfTitle": {
"title": "الاحتفاظ بالملكية",
"content": "تظل البضائع المسلمة ملكًا لشركة Growheads حتى يقوم المشتري بتسوية جميع المطالبات الموجهة ضده. إذا قام البائع بإعادة بيع البضائع، فإنه بموجب هذا يعهد إلينا بالمطالبات الناشئة عن البيع. إذا تأخر المشتري في السداد، يحق لنا في أي وقت طلب إعادة البضائع دون الانسحاب من العقد.",
}
};

View File

@@ -1,8 +0,0 @@
export default {
"title": "معلومات قانون البطاريات",
"intro": "فيما يتعلق ببيع البطاريات أو تسليم الأجهزة التي تحتوي على بطاريات، نحن ملزمون بإبلاغكم بما يلي:",
"returnObligation": "بصفتك مستخدم نهائي، أنت ملزم قانونيًا بإرجاع البطاريات المستخدمة. يمكنك إرجاع البطاريات القديمة التي نمتلكها أو التي كانت ضمن مجموعتنا كبطاريات جديدة مجانًا إلى مستودع الشحن الخاص بنا (عنوان الشحن).",
"symbolsInfo": "الرموز المعروضة على البطاريات تعني ما يلي:",
"wasteSymbol": "رمز سلة المهملات المعلمة بعلامة إلغاء يعني أنه لا يجوز التخلص من البطارية مع النفايات المنزلية.",
"chemicalSymbols": "Pb = البطارية تحتوي على أكثر من 0.004 بالمئة رصاص بالوزن\nCd = البطارية تحتوي على أكثر من 0.002 بالمئة كادميوم بالوزن\nHg = البطارية تحتوي على أكثر من 0.0005 بالمئة زئبق بالوزن."
};

View File

@@ -1,18 +0,0 @@
export default {
"title": "سياسة الخصوصية",
"responsibleParty": {
"title": "الجهة المسؤولة بموجب قانون حماية البيانات:",
"company": "Growheads\nTrachenberger Straße 14\n01129 Dresden"
},
"generalInfo": "ما لم يُذكر خلاف ذلك أدناه، فإن تقديم بياناتك الشخصية ليس مطلوبًا قانونيًا أو تعاقديًا، ولا هو ضروري لإبرام عقد. أنت غير ملزم بتقديم البيانات. عدم تقديمها لن يترتب عليه أي عواقب. هذا ينطبق فقط طالما لم يتم الإشارة إلى خلاف ذلك في عمليات المعالجة التالية. \"البيانات الشخصية\" تعني جميع المعلومات المتعلقة بشخص طبيعي محدد أو يمكن تحديده.",
"sections": {
"informationDeletion": {
"title": "المعلومات، الحذف، الحظر",
"content": "يمكنك في أي وقت طلب معلومات عن بياناتك الشخصية، مصدرها والمستلمين لها، وهدف معالجة البيانات، كما يمكنك طلب تصحيح أو حظر أو حذف هذه البيانات مجانًا. يرجى استخدام خيارات الاتصال الموجودة في تذييل الصفحة أو في الإشعار القانوني (Impressum) لهذا الغرض. نحن متاحون أيضًا في أي وقت لأي أسئلة إضافية حول الموضوع. يرجى ملاحظة أننا غير مخولين ولن نقوم بحذف بيانات الفواتير، البيانات البنكية، والبيانات التي تم إرسالها إلى مزود خدمة الشحن. البيانات التي يمكن حذفها تشمل: حسابات العملاء على خادم الويب، وكذلك في نظام إدارة البضائع، والبريد الإلكتروني الذي لا يرتبط مباشرة بطلب.",
},
"serverLogfiles": {
"title": "ملفات سجل الخادم",
"content": "يمكنك زيارة مواقعنا الإلكترونية دون تقديم أي معلومات عن نفسك. في كل مرة تصل فيها إلى موقعنا، يتم إرسال بيانات الاستخدام من متصفح الإنترنت الخاص بك وتخزينها في ملفات السجل (ملفات سجل الخادم). تشمل هذه البيانات المخزنة، على سبيل المثال، اسم الصفحة التي تم الوصول إليها، تاريخ ووقت الوصول، كمية البيانات المنقولة، ومزود الخدمة الذي طلب الوصول. تُستخدم هذه البيانات فقط لضمان التشغيل السلس لموقعنا وتحسين عرضنا. هذه البيانات ليست بيانات شخصية. لا يتم دمج هذه البيانات مع مصادر بيانات أخرى. إذا علمنا بوجود مؤشرات محددة على استخدام غير قانوني، نحتفظ بالحق في فحص هذه البيانات لاحقًا."
}
}
};

View File

@@ -1,12 +0,0 @@
export default {
"sections": {
"chatbot": {
"title": "استخدام روبوت دردشة ذكي (OpenAI API)",
"content": "نحن نستخدم روبوت دردشة مدعوم بالذكاء الاصطناعي على موقعنا الإلكتروني، والذي يتم تشغيله عبر واجهة برمجة التطبيقات (API) لمزود الخدمة OpenAI. يهدف روبوت الدردشة إلى الرد على استفسارات الزوار بكفاءة وبشكل تلقائي، وبالتالي توفير وظيفة دعم. عند استخدامك لروبوت الدردشة، يتم معالجة مدخلاتك بواسطة النظام لتوليد ردود مناسبة. تتم المعالجة بشكل مجهول لا يتم جمع أو تخزين عناوين IP أو أي بيانات شخصية أخرى (مثل الاسم أو بيانات الاتصال).",
"legalBasis": "الأساس القانوني لاستخدام روبوت الدردشة هو مصلحتنا المشروعة وفقًا للمادة 6 الفقرة 1 الحرف f من DSGVO. تكمن هذه المصلحة في توفير دعم فعال للزوار وكذلك تحسين تجربة المستخدم على موقعنا الإلكتروني.",
"dataRecipient": "المستلم لبيانات الدردشة هو OpenAI (OpenAI OpCo, LLC) كمزود خدمة فني. تقوم OpenAI بمعالجة محتوى الدردشة المرسل على خوادمها حصريًا لغرض توليد الردود. تعمل OpenAI كمعالج بيانات وفقًا للمادة 28 من DSGVO ولا تستخدم البيانات لأغراضها الخاصة. لقد أبرمنا عقد معالجة بيانات مع OpenAI، والذي يتضمن بنود العقد النموذجية للاتحاد الأوروبي كضمانات مناسبة لحماية البيانات. يقع المقر الرئيسي لـ OpenAI في الولايات المتحدة الأمريكية؛ ومن خلال الموافقة على بنود العقد النموذجية، يتم ضمان مستوى حماية بيانات يعادل مستوى الاتحاد الأوروبي عند نقل بياناتك.",
"dataRetention": "نحتفظ باستفسارات الدردشة الخاصة بك فقط طالما كان ذلك ضروريًا للمعالجة والرد. بمجرد الانتهاء من طلبك، يتم حذف سجلات الدردشة أو إخفاء هويتها على الفور. وفقًا لتصريحاتها الخاصة، تحتفظ OpenAI ببيانات الدردشة المعالجة مؤقتًا فقط وتحذفها تلقائيًا بعد مدة أقصاها 30 يومًا.",
"voluntaryUse": "استخدام روبوت الدردشة اختياري. إذا لم تستخدم روبوت الدردشة، فلن يتم نقل أي بيانات إلى OpenAI. يرجى عدم إدخال أي بيانات شخصية حساسة في الدردشة."
}
}
};

View File

@@ -1,17 +0,0 @@
export default {
"sections": {
"cookies": {
"title": "الكوكيز",
"intro": "موقعنا بيستخدم الكوكيز في الحالات التالية:",
"payment": "1. عملية الدفع: عند الدفع ببطاقات الائتمان أو التحويلات الفورية (مثلاً Klarna Sofort)، بيتم استخدام كوكيز تقنية ضرورية. الكوكيز دي بتحتوي على سلسلة مميزة من الأحرف بتسمح بالتعرف الفريد على المتصفح. الكوكيز دي بيتم تعيينها من قبل مزود خدمة الدفع Stripe وضرورية تمامًا لمعالجة المدفوعات بأمان وسلاسة. من غير الكوكيز دي، مش ممكن تقديم طلب باستخدام طرق الدفع دي. المعالجة بتتم بناءً على المادة 6 (1) بند ب من DSGVO لتنفيذ العقد.",
"googleSSO": "2. تسجيل الدخول الموحد من جوجل (SSO): عند استخدام تسجيل الدخول عبر جوجل، بيتم تعيين كوكيز من جوجل ضرورية لعملية تسجيل الدخول والمصادقة. الكوكيز دي بتسمحلك تسجل دخولك بسهولة بحساب جوجل بتاعك من غير ما تحتاج تسجل دخول كل مرة. المعالجة بتتم بناءً على المادة 6 (1) بند ب من DSGVO (تنفيذ العقد) والمادة 6 (1) بند ف من DSGVO (مصلحة مشروعة في تسجيل دخول سهل الاستخدام).",
"otherPayments": "بالنسبة لطرق الدفع الأخرى الخصم المباشر، الاستلام، أو الدفع عند الاستلام مفيش كوكيز إضافية مستخدمة، إلا لو استخدمت تسجيل الدخول عبر جوجل."
},
"mollie": {
"title": "Mollie (معالجة الدفع)",
"content": "احنا بنستخدم مزود خدمة الدفع Mollie على موقعنا لمعالجة المدفوعات. مزود الخدمة هو Mollie B.V., Keizersgracht 126, 1015 CW Amsterdam, Netherlands. في السياق ده، بيتم نقل البيانات الشخصية المطلوبة لمعالجة الدفع لـ Mollie خصوصًا اسمك، بريدك الإلكتروني، عنوان الفاتورة، معلومات الدفع (مثلاً بيانات بطاقة الائتمان)، وعنوان الـ IP. معالجة البيانات بتتم لغرض معالجة الدفع؛ الأساس القانوني هو المادة 6 الفقرة 1 بند ب من DSGVO، لأنها بتخدم تنفيذ عقد معاك.",
"responsibility": "Mollie كمان بتعالج بيانات معينة كمسؤول مستقل، مثلاً لتنفيذ الالتزامات القانونية (زي مكافحة غسيل الأموال) ولمنع الاحتيال. بالإضافة لكده، احنا موقّعين عقد معالجة بيانات مع Mollie وفقًا للمادة 28 من DSGVO؛ وبموجب العقد ده، Mollie بتتصرف فقط بتعليماتنا عند معالجة المدفوعات.",
"dataTransfer": "لو Mollie بتعالج بيانات شخصية خارج الاتحاد الأوروبي، خصوصًا في الولايات المتحدة الأمريكية، ده بيتم مع الالتزام بضمانات مناسبة. Mollie بتستخدم بنود العقد النموذجية للاتحاد الأوروبي حسب المادة 46 من DSGVO لضمان مستوى مناسب من حماية البيانات. مع ذلك، بنحب نوضح إن الولايات المتحدة الأمريكية بتعتبر دولة ثالثة بموجب قانون حماية البيانات مع احتمال وجود مستوى حماية بيانات غير كافي. ممكن تلاقي معلومات أكتر في سياسة الخصوصية الخاصة بـ Mollie على https://www.mollie.com/de/privacy."
}
}
};

View File

@@ -1,8 +0,0 @@
export default {
"sections": {
"customerAccount": {
"title": "حساب العميل",
"content": "عند فتح حساب عميل، نجمع بياناتك الشخصية بالقدر المحدد هناك. معالجة البيانات تهدف إلى تحسين تجربة التسوق الخاصة بك وتسهيل معالجة الطلبات. تتم المعالجة بناءً على المادة 6 (1) حرف a من DSGVO بموافقتك. يمكنك سحب موافقتك في أي وقت بإبلاغنا، دون التأثير على قانونية المعالجة التي تمت بناءً على الموافقة حتى سحبها. سيتم بعد ذلك حذف حساب العميل الخاص بك."
}
}
};

Some files were not shown because too many files have changed in this diff Show More