Compare commits
1 Commits
2ced182570
...
mollie
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccceb8fe78 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
alwaysApply: false
|
||||
---
|
||||
never run your own dev sever, it can be restarted with ```pm2 restart dev_seedheads_fron```
|
||||
get logoutput lioke this ```pm2 log dev_seedheads_fron --lines 20 --nostream```
|
||||
60
.gitignore
vendored
@@ -1,3 +1,61 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.cursor/
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
/logs
|
||||
/public/index.prerender.html
|
||||
/public/assets/images/prod*.jpg
|
||||
/public/assets/images/cat*.jpg
|
||||
/public/prerender.css
|
||||
/public/Artikel/*
|
||||
/public/Kategorie/*
|
||||
/public/agb
|
||||
/public/batteriegesetzhinweise
|
||||
/public/datenschutz
|
||||
/public/impressum
|
||||
/public/sitemap
|
||||
/public/widerrufsrecht
|
||||
/public/robots.txt
|
||||
/public/sitemap.xml
|
||||
/public/index.prerender.html
|
||||
/public/Konfigurator
|
||||
/public/profile
|
||||
/public/404
|
||||
|
||||
/public/products.xml
|
||||
/public/llms*
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
.hintrc
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Local configuration
|
||||
src/config.local.js
|
||||
|
||||
# Local development notes
|
||||
dev-notes.md
|
||||
dev-notes.local.md
|
||||
66
.vscode/launch.json
vendored
@@ -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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
40
README.md
@@ -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
|
||||
|
||||
282
docs/i18n.md
@@ -1,282 +0,0 @@
|
||||
# Internationalization (i18n)
|
||||
|
||||
This project uses [i18next](https://www.i18next.com/) with [react-i18next](https://react.i18next.com/). The setup works with both function components and class components (HOCs).
|
||||
|
||||
## Overview
|
||||
|
||||
- **Default language:** German (`de`), bundled at startup.
|
||||
- **Other languages:** Loaded on demand when the user switches (see `loadLanguage` in `src/i18n/index.js`).
|
||||
- **Persistence:** Language choice is stored in `sessionStorage` / `localStorage` (`i18nextLng`) via a custom detector in `src/i18n/index.js`.
|
||||
- **HTML `lang`:** Updated when the language changes (see `LanguageProvider` in `src/i18n/withTranslation.js`).
|
||||
- **Fallback:** `fallbackLng` is `de`.
|
||||
|
||||
### Product-facing behavior
|
||||
|
||||
- **Language switcher** — `src/components/LanguageSwitcher.js`: shows current language with flag, dropdown to change language, persists selection.
|
||||
- **Translated areas** — Navigation, auth, cart, checkout, product UI, footer, profile, and more; legal pages use dedicated **`legal-*`** namespaces (long-form text).
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `src/i18n/index.js` | Initializes i18n, registers namespaces, lazy-loads non-German locales |
|
||||
| `src/i18n/withTranslation.js` | `LanguageProvider`, `withTranslation`, `withI18n`, `withLanguage` |
|
||||
| `src/i18n/locales/<lang>/` | Per-language modules (`de`, `en`, …) |
|
||||
|
||||
Each language folder contains:
|
||||
|
||||
- **`index.js`** — Merges feature modules into the main **`translation`** namespace (shop UI: `navigation`, `cart`, `product`, …). Some legal content is also re-exported here under camelCase keys (e.g. `legalAgbDelivery`) for convenience; runtime legal pages use separate namespaces (see below).
|
||||
- **One file per topic** — e.g. `cart.js`, `navigation.js`, `product.js` (not a single `translation.json`).
|
||||
- **Legal bundles** — e.g. `legal-agb-delivery.js`, `legal-datenschutz-basic.js`, `legal-impressum.js`, registered as their **own i18next namespaces** in `src/i18n/index.js`.
|
||||
|
||||
## Namespaces
|
||||
|
||||
- **`translation`** (default) — Everything merged in `locales/<lang>/index.js`. Keys are dot paths such as `cart.addToCart` or `product.title`.
|
||||
- **`legal-*`** — Long-form legal copy. Loaded the same way, but you select the namespace in `useTranslation('legal-impressum')` (or equivalent). Keys are relative to that namespace, e.g. `sections.operator.title`, not `legal-impressum.sections.operator.title`.
|
||||
|
||||
Default namespace is `translation`; omit the argument for shop UI:
|
||||
|
||||
```javascript
|
||||
const { t } = useTranslation();
|
||||
// same as useTranslation('translation')
|
||||
```
|
||||
|
||||
Legal page example:
|
||||
|
||||
```javascript
|
||||
const { t } = useTranslation('legal-impressum');
|
||||
return t('title');
|
||||
```
|
||||
|
||||
Multiple namespaces in one component:
|
||||
|
||||
```javascript
|
||||
const { t: tDelivery } = useTranslation('legal-agb-delivery');
|
||||
const { t: tPayment } = useTranslation('legal-agb-payment');
|
||||
```
|
||||
|
||||
## Usage in components
|
||||
|
||||
### Function components
|
||||
|
||||
```javascript
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const MyComponent = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <Typography>{t('navigation.home')}</Typography>;
|
||||
};
|
||||
```
|
||||
|
||||
### Class components — `withI18n` (translation + language context)
|
||||
|
||||
`withI18n` combines translation and language context. Use it when you need `t` and optionally `languageContext`:
|
||||
|
||||
```javascript
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
|
||||
class MyComponent extends Component {
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
|
||||
return <Typography>{t('navigation.home')}</Typography>;
|
||||
}
|
||||
}
|
||||
|
||||
export default withI18n()(MyComponent);
|
||||
```
|
||||
|
||||
### Class components — `withTranslation` + `withLanguage`
|
||||
|
||||
Some components only need `t` and `languageContext` from separate HOCs:
|
||||
|
||||
```javascript
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import { withLanguage } from '../i18n/withTranslation.js';
|
||||
|
||||
export default withTranslation()(withLanguage(MyComponent));
|
||||
```
|
||||
|
||||
Destructuring in `render`:
|
||||
|
||||
```javascript
|
||||
const { t, title } = this.props;
|
||||
t('product.new');
|
||||
```
|
||||
|
||||
`this.props.t('…')` resolves against the default `translation` namespace.
|
||||
|
||||
### Language context (`changeLanguage`)
|
||||
|
||||
```javascript
|
||||
import { withLanguage } from '../i18n/withTranslation.js';
|
||||
|
||||
class MyComponent extends Component {
|
||||
render() {
|
||||
const { languageContext } = this.props;
|
||||
|
||||
return (
|
||||
<Button onClick={() => languageContext.changeLanguage('en')}>
|
||||
English
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withLanguage(MyComponent);
|
||||
```
|
||||
|
||||
Available language codes are defined on `LanguageProvider` (`allLanguages` in `withTranslation.js`); the switcher only offers languages that are loaded or loading.
|
||||
|
||||
## Key naming
|
||||
|
||||
- Use **dot notation** for nested objects in the JS exports: `product.inclVat`, `delivery.times.standard2to3Days`.
|
||||
- File names in `locales/de/` map to **top-level segments** inside `index.js` (e.g. `cart.js` → `cart.*`). Keep new strings in the appropriate module and **export them from `index.js`** if you add a new file.
|
||||
|
||||
## Interpolation
|
||||
|
||||
i18next interpolation uses `{{name}}` in strings and an options object:
|
||||
|
||||
```javascript
|
||||
t('product.weight', { weight: '1,2' });
|
||||
```
|
||||
|
||||
```javascript
|
||||
// locale string example: "Gewicht: {{weight}} kg"
|
||||
```
|
||||
|
||||
## Language switching
|
||||
|
||||
`LanguageProvider` coordinates `i18n.changeLanguage` and lazy loading. Non-default languages are loaded via `loadLanguage` in `src/i18n/index.js`, which dynamic-imports the same file layout under `locales/<lang>/`.
|
||||
|
||||
## Adding or changing copy
|
||||
|
||||
1. Edit the right module under `src/i18n/locales/de/` (source of truth for structure).
|
||||
2. Mirror changes in other languages under `src/i18n/locales/<lang>/` as needed, or use the project’s translation tooling (`npm run translate`, etc. — see `translate-i18n.js` and `package.json` scripts).
|
||||
3. For a **new top-level section**, add the module import and export in `locales/<lang>/index.js` for every language you ship.
|
||||
|
||||
### Example (new keys in an existing file)
|
||||
|
||||
`src/i18n/locales/de/cart.js`:
|
||||
|
||||
```javascript
|
||||
export default {
|
||||
// ...
|
||||
newFeature: {
|
||||
title: 'Neuer Titel',
|
||||
description: 'Neue Beschreibung',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Use in code: `t('cart.newFeature.title')` (assuming the `cart` object is merged under `cart` in `index.js`).
|
||||
|
||||
## Adding a new language
|
||||
|
||||
1. Add a folder `src/i18n/locales/<code>/` with the same **file set** as German (at minimum `index.js` and every module `index.js` imports, plus all `legal-*.js` files registered in `src/i18n/index.js`).
|
||||
2. Ensure `loadLanguage` in `src/i18n/index.js` can import that folder (pattern already uses `import(\`./locales/${language}/...\`)`).
|
||||
3. Include the code in `allLanguages` (and any UI lists) in `withTranslation.js` / `LanguageSwitcher.js` as needed.
|
||||
4. Clear the language cache logic if you add special cases (`languageCache` in `index.js`).
|
||||
|
||||
## Configuration notes
|
||||
|
||||
- **Detection:** Custom detector in `src/i18n/index.js` prefers session/localStorage, then defaults to `de` (browser language is not used for the initial default).
|
||||
- **Debug:** i18next `debug` follows `NODE_ENV` (on in development).
|
||||
- **Missing keys:** `saveMissing` can run in development (see `src/i18n/index.js`).
|
||||
|
||||
## SEO
|
||||
|
||||
- Document `lang` updates with language changes.
|
||||
- You can extend the app with hreflang, localized routes, or metadata per language as needed.
|
||||
|
||||
## Best practices
|
||||
|
||||
1. **Keep structure parallel** across `locales/<lang>/` files.
|
||||
2. **Prefer German (`de`) as the structural source**; keep `fallbackLng` aligned with your primary market.
|
||||
3. **Use interpolation** for variable fragments: `t('key', { name: value })`.
|
||||
4. **Test** critical flows after adding strings or languages.
|
||||
|
||||
## Translation coverage (high level)
|
||||
|
||||
Coverage varies by screen; shop flows, auth, cart, products, and legal pages have substantial strings. Remaining gaps are normal for a growing app—search for hard-coded German/English in components when polishing.
|
||||
|
||||
## Performance
|
||||
|
||||
- **German** is in the initial bundle.
|
||||
- **Other languages** load on first switch (dynamic import), then stay cached in the i18n layer for the session.
|
||||
|
||||
## Browser support
|
||||
|
||||
Modern browsers with ES modules, `localStorage` / `sessionStorage`, and your supported React version.
|
||||
|
||||
---
|
||||
|
||||
## Key usage check (`scripts/check-i18n-keys.mjs`)
|
||||
|
||||
The check answers: *which keys defined for German (`src/i18n/locales/de`) appear to be unused in `src/`?*
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run i18n:check-keys
|
||||
# or
|
||||
node scripts/check-i18n-keys.mjs
|
||||
```
|
||||
|
||||
Machine-readable output:
|
||||
|
||||
```bash
|
||||
node scripts/check-i18n-keys.mjs --json
|
||||
```
|
||||
|
||||
### What the script detects
|
||||
|
||||
The script only understands **static** key strings:
|
||||
|
||||
- `t('cart.addToCart')`, `t("…")`
|
||||
- `this.props.t('…')`, `props.t('…')`
|
||||
- Aliases from `useTranslation('namespace')`, e.g. `const { t: tDelivery } = useTranslation('legal-agb-delivery');` then `tDelivery('deliveryTerms.1')`
|
||||
- Destructuring `const { t } = this.props` / `props` for HOC-wrapped components
|
||||
- Fully static template literals without interpolation: `` t(`some.key`) ``
|
||||
|
||||
It flattens locale objects the same way i18next does (leaf keys only).
|
||||
|
||||
### Embedded legal copy vs separate namespaces
|
||||
|
||||
Legal strings exist in two shapes:
|
||||
|
||||
1. **Separate namespace** (what pages use): `legal-agb-delivery` + key `deliveryTerms.1`.
|
||||
2. **Embedded in `translation`**: `legalAgbDelivery.deliveryTerms.1` inside `locales/de/index.js`.
|
||||
|
||||
The script treats a key as used in the **embedded** `translation` tree when the matching **separate-namespace** key is used, so you do not see false “unused” duplicates for the same text.
|
||||
|
||||
### Dynamic keys (`t(\`…${x}…\`)`)
|
||||
|
||||
If the key is built at runtime, the static scan will not see it. The script includes **hard-coded expansions** for known patterns on:
|
||||
|
||||
- `src/pages/AGB.js`
|
||||
- `src/pages/Datenschutz.js` (basic Datenschutz sections)
|
||||
- `src/pages/Impressum.js`
|
||||
|
||||
If you add **new** dynamic patterns elsewhere, either:
|
||||
|
||||
- Prefer **static** keys where reasonable (`t('feature.section.title')` per section), or
|
||||
- Extend `addKnownDynamicLegalKeys` (or equivalent) in `scripts/check-i18n-keys.mjs` so the checker knows which key paths can occur.
|
||||
|
||||
Other files may still be listed under “template literals in `t(...)`” when the script cannot prove all keys.
|
||||
|
||||
### Making new keys “count” for the checker
|
||||
|
||||
1. Call `t` with a **string literal** (or a template literal **without** `${…}`) as the first argument.
|
||||
2. Use **`useTranslation('namespace')`** with a **string literal** namespace so aliases like `tFoo` map correctly.
|
||||
3. For class components, keep **`const { t } = this.props`** (or `props`) so the script ties `t` to `translation`.
|
||||
|
||||
Avoid only referencing a key from non-`src` code (build scripts, server-only files): the checker only scans `src/` and skips `src/i18n/locales/**`.
|
||||
|
||||
### Interpreting results
|
||||
|
||||
- **Unused** — No static (and no expanded dynamic) reference found; may still be dead copy, or only used via variables/dynamic templates.
|
||||
- **Used but not in locale files** — A reference in code does not match any defined German key (typo or missing `de` entry).
|
||||
|
||||
The report is **best-effort**; use it to find obvious dead strings and drift, not as a formal guarantee.
|
||||
154
docs/nginx.conf
@@ -1,154 +0,0 @@
|
||||
server {
|
||||
client_max_body_size 64M;
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name example.de;
|
||||
ssl_certificate /etc/letsencrypt/live/example.de/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/example.de/privkey.pem;
|
||||
|
||||
gzip on;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_types
|
||||
text/css
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/x-javascript
|
||||
application/json
|
||||
application/xml
|
||||
image/svg+xml;
|
||||
|
||||
index index.html;
|
||||
root /example/dist;
|
||||
|
||||
error_log logs/error.log info;
|
||||
access_log logs/access.log combined;
|
||||
|
||||
location /socket.io/ {
|
||||
proxy_pass http://localhost:9303/socket.io/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_connect_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_read_timeout 3600s;
|
||||
send_timeout 3600s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
keepalive_timeout 65;
|
||||
keepalive_requests 100;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:9303/api/;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_set_header User-Agent $http_user_agent;
|
||||
proxy_set_header Content-Type $content_type;
|
||||
proxy_set_header Content-Length $content_length;
|
||||
|
||||
proxy_set_header X-API-Key $http_x_api_key;
|
||||
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_buffering off;
|
||||
|
||||
client_max_body_size 10M;
|
||||
}
|
||||
|
||||
location ^~ /Kategorie/ {
|
||||
types {}
|
||||
default_type text/html;
|
||||
}
|
||||
|
||||
location ^~ /Artikel/ {
|
||||
types {}
|
||||
default_type text/html;
|
||||
}
|
||||
|
||||
location = /sitemap.xml {
|
||||
types {}
|
||||
default_type application/xml;
|
||||
}
|
||||
|
||||
location ~ ^/(datenschutz|impressum|batteriegesetzhinweise|widerrufsrecht|sitemap|agb|Kategorien|Konfigurator|404|profile|resetPassword|thc-test|linkTelegram|filiale|aktionen|presseverleih|payment/success)(/|$) {
|
||||
types {}
|
||||
default_type text/html;
|
||||
}
|
||||
|
||||
location = /404 {
|
||||
error_page 404 =404 /404-big.html;
|
||||
return 404;
|
||||
}
|
||||
|
||||
location = /404-big.html {
|
||||
internal;
|
||||
alias /home/seb/src/growheads_de/dist/404;
|
||||
default_type text/html;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
|
||||
location = /404.html {
|
||||
internal;
|
||||
default_type text/html;
|
||||
return 404 '<!doctype html><html><body>
|
||||
<script>
|
||||
if (!navigator.userAgent.includes("bot")) { location.href="/404"; }
|
||||
</script>
|
||||
</body></html>';
|
||||
}
|
||||
|
||||
location ~* \.(js|css)\?.*$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Vary Accept-Encoding;
|
||||
}
|
||||
|
||||
location ~* \.(js|css)$ {
|
||||
if ($uri ~ "\.[a-f0-9]{7,}\.(js|css)$") {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
break;
|
||||
}
|
||||
expires 1d;
|
||||
add_header Cache-Control "public";
|
||||
add_header Vary Accept-Encoding;
|
||||
}
|
||||
|
||||
location ~* \.(ttf|otf|woff|woff2|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
}
|
||||
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
add_header Vary Accept-Encoding;
|
||||
}
|
||||
|
||||
location = /prerender.css {
|
||||
expires 1w;
|
||||
add_header Cache-Control "public";
|
||||
add_header Vary Accept-Encoding;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
add_header Vary Accept-Encoding;
|
||||
}
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
// Configuration
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
const DIST_DIR = './dist';
|
||||
const OUTPUT_CSV = './category-descriptions.csv';
|
||||
|
||||
// Model configuration
|
||||
const MODEL = 'gpt-5.4';
|
||||
|
||||
// Initialize OpenAI client
|
||||
const openai = new OpenAI({
|
||||
apiKey: OPENAI_API_KEY,
|
||||
});
|
||||
|
||||
// System prompt for generating SEO descriptions
|
||||
const SEO_DESCRIPTION_PROMPT = `You are given a list of products from a specific category. Create a SEO-friendly description for that category that would be suitable for a product catalog page.
|
||||
|
||||
Requirements:
|
||||
- Write in German
|
||||
- Make it SEO-optimized with relevant keywords
|
||||
|
||||
The product list format is:
|
||||
First line: categoryName,categoryId
|
||||
Subsequent lines: articleNumber,price,productName,shortDescription
|
||||
|
||||
Generate a compelling category description based on this product data.`;
|
||||
|
||||
// Function to find all *-list.txt files in dist directory
|
||||
function findListFiles() {
|
||||
try {
|
||||
const files = fs.readdirSync(DIST_DIR);
|
||||
return files.filter(file => file.endsWith('-list.txt'));
|
||||
} catch (error) {
|
||||
console.error('Error reading dist directory:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Function to read a list file and extract category info
|
||||
function readListFile(filePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
if (lines.length < 1) {
|
||||
throw new Error('File is empty');
|
||||
}
|
||||
|
||||
// Parse first line: categoryName,categoryId,[subcategoryIds]
|
||||
const firstLine = lines[0];
|
||||
const parts = firstLine.split(',');
|
||||
|
||||
if (parts.length < 2) {
|
||||
throw new Error('Invalid first line format');
|
||||
}
|
||||
|
||||
const categoryName = parts[0].replace(/^"|"$/g, '');
|
||||
const categoryId = parts[1].replace(/^"|"$/g, '');
|
||||
|
||||
// Parse subcategory IDs from array notation [id1,id2,...]
|
||||
let subcategoryIds = [];
|
||||
if (parts.length >= 3) {
|
||||
const subcatString = parts.slice(2).join(','); // Handle case where array spans multiple comma-separated values
|
||||
const match = subcatString.match(/\[(.*?)\]/);
|
||||
if (match && match[1]) {
|
||||
subcategoryIds = match[1].split(',').map(id => id.trim()).filter(id => id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!categoryName || !categoryId) {
|
||||
throw new Error('Invalid first line format');
|
||||
}
|
||||
|
||||
return {
|
||||
categoryName: categoryName,
|
||||
categoryId: categoryId,
|
||||
subcategoryIds: subcategoryIds,
|
||||
content: content
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error reading ${filePath}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to build processing order based on dependencies
|
||||
function buildProcessingOrder(categories) {
|
||||
const categoryMap = new Map();
|
||||
const processed = new Set();
|
||||
const processingOrder = [];
|
||||
|
||||
// Create a map of categoryId -> category data
|
||||
categories.forEach(cat => {
|
||||
categoryMap.set(cat.categoryId, cat);
|
||||
});
|
||||
|
||||
// Function to check if all subcategories are processed
|
||||
function canProcess(category) {
|
||||
return category.subcategoryIds.every(subId => processed.has(subId));
|
||||
}
|
||||
|
||||
// Keep processing until all categories are done
|
||||
while (processingOrder.length < categories.length) {
|
||||
const beforeLength = processingOrder.length;
|
||||
|
||||
// Find categories that can be processed now
|
||||
for (const category of categories) {
|
||||
if (!processed.has(category.categoryId) && canProcess(category)) {
|
||||
processingOrder.push(category);
|
||||
processed.add(category.categoryId);
|
||||
}
|
||||
}
|
||||
|
||||
// If no progress was made, there might be a circular dependency or missing category
|
||||
if (processingOrder.length === beforeLength) {
|
||||
console.error('⚠️ Unable to resolve all category dependencies');
|
||||
// Add remaining categories anyway
|
||||
for (const category of categories) {
|
||||
if (!processed.has(category.categoryId)) {
|
||||
console.warn(` Adding ${category.categoryName} (${category.categoryId}) despite unresolved dependencies`);
|
||||
processingOrder.push(category);
|
||||
processed.add(category.categoryId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return processingOrder;
|
||||
}
|
||||
|
||||
// Function to generate SEO description using OpenAI
|
||||
async function generateSEODescription(productListContent, categoryName, categoryId, subcategoryDescriptions = []) {
|
||||
try {
|
||||
console.log(`🔄 Generating SEO description for category: ${categoryName} (ID: ${categoryId})`);
|
||||
|
||||
// Prepend subcategory information if present
|
||||
let fullContent = productListContent;
|
||||
if (subcategoryDescriptions.length > 0) {
|
||||
const subcatInfo = 'This category has the following subcategories:\n' +
|
||||
subcategoryDescriptions.map(sub => `- "${sub.name}": ${sub.description}`).join('\n') +
|
||||
'\n\n';
|
||||
fullContent = subcatInfo + productListContent;
|
||||
}
|
||||
|
||||
const response = await openai.responses.create({
|
||||
model: "gpt-5.4",
|
||||
input: [
|
||||
{
|
||||
"role": "developer",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": SEO_DESCRIPTION_PROMPT
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": fullContent
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
text: {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "descriptions",
|
||||
"strict": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"seo_description": {
|
||||
"type": "string",
|
||||
"description": "A concise description intended for SEO purposes. 155 characters"
|
||||
},
|
||||
"long_description": {
|
||||
"type": "string",
|
||||
"description": "A comprehensive description, 2-5 Sentences"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"seo_description",
|
||||
"long_description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"verbosity": "medium"
|
||||
},
|
||||
reasoning: {
|
||||
"effort": "none"
|
||||
}
|
||||
});
|
||||
|
||||
const description = response.output_text;
|
||||
console.log(`✅ Generated description for ${categoryName}`);
|
||||
return description;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error generating description for ${categoryName}:`, error.message);
|
||||
return `Error generating description: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to write CSV file
|
||||
function writeCSV(results) {
|
||||
try {
|
||||
const csvHeader = 'categoryId,listFileName,seoDescription\n';
|
||||
const csvRows = results.map(result =>
|
||||
`"${result.categoryId}","${result.listFileName}","${result.description.replace(/"/g, '""')}"`
|
||||
).join('\n');
|
||||
|
||||
const csvContent = csvHeader + csvRows;
|
||||
fs.writeFileSync(OUTPUT_CSV, csvContent, 'utf8');
|
||||
console.log(`✅ CSV file written: ${OUTPUT_CSV}`);
|
||||
console.log(`📊 Processed ${results.length} categories`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error writing CSV file:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution function
|
||||
async function main() {
|
||||
console.log('🚀 Starting category description generation...');
|
||||
|
||||
// Check if OpenAI API key is set
|
||||
if (!OPENAI_API_KEY) {
|
||||
console.error('❌ OPENAI_API_KEY environment variable is not set');
|
||||
console.log('Please set your OpenAI API key: export OPENAI_API_KEY="your-api-key-here"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if dist directory exists
|
||||
if (!fs.existsSync(DIST_DIR)) {
|
||||
console.error(`❌ Dist directory not found: ${DIST_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find all list files
|
||||
const listFiles = findListFiles();
|
||||
if (listFiles.length === 0) {
|
||||
console.log('⚠️ No *-list.txt files found in dist directory');
|
||||
console.log('💡 Make sure to run the prerender script first to generate the list files');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`📂 Found ${listFiles.length} list files to process`);
|
||||
|
||||
// Step 1: Read all list files and extract category information
|
||||
console.log('📖 Reading all category files...');
|
||||
const categories = [];
|
||||
const fileDataMap = new Map(); // Map categoryId -> fileData
|
||||
|
||||
for (const listFile of listFiles) {
|
||||
const filePath = path.join(DIST_DIR, listFile);
|
||||
const fileData = readListFile(filePath);
|
||||
|
||||
if (!fileData) {
|
||||
console.log(`⚠️ Skipping ${listFile} due to read error`);
|
||||
continue;
|
||||
}
|
||||
|
||||
categories.push({
|
||||
categoryId: fileData.categoryId,
|
||||
categoryName: fileData.categoryName,
|
||||
subcategoryIds: fileData.subcategoryIds,
|
||||
listFileName: listFile
|
||||
});
|
||||
|
||||
fileDataMap.set(fileData.categoryId, {
|
||||
...fileData,
|
||||
listFileName: listFile
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✅ Read ${categories.length} categories`);
|
||||
|
||||
// Step 2: Build processing order based on dependencies
|
||||
console.log('🔨 Building processing order based on category hierarchy...');
|
||||
const processingOrder = buildProcessingOrder(categories);
|
||||
|
||||
const leafCategories = processingOrder.filter(cat => cat.subcategoryIds.length === 0);
|
||||
const parentCategories = processingOrder.filter(cat => cat.subcategoryIds.length > 0);
|
||||
|
||||
console.log(` 📄 ${leafCategories.length} leaf categories (no subcategories)`);
|
||||
console.log(` 📁 ${parentCategories.length} parent categories (with subcategories)`);
|
||||
|
||||
// Step 3: Process categories in order
|
||||
const results = [];
|
||||
const generatedDescriptions = new Map(); // Map categoryId -> {seo_description, long_description}
|
||||
|
||||
for (const category of processingOrder) {
|
||||
const fileData = fileDataMap.get(category.categoryId);
|
||||
|
||||
if (!fileData) {
|
||||
console.log(`⚠️ Skipping ${category.categoryName} - no file data found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gather subcategory descriptions
|
||||
const subcategoryDescriptions = [];
|
||||
for (const subId of category.subcategoryIds) {
|
||||
const subDesc = generatedDescriptions.get(subId);
|
||||
const subCategory = categories.find(cat => cat.categoryId === subId);
|
||||
|
||||
if (subDesc && subCategory) {
|
||||
subcategoryDescriptions.push({
|
||||
name: subCategory.categoryName,
|
||||
description: subDesc.long_description || subDesc.seo_description
|
||||
});
|
||||
} else if (subCategory) {
|
||||
console.warn(` ⚠️ Subcategory ${subCategory.categoryName} (${subId}) not yet processed`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate SEO description
|
||||
const descriptionJSON = await generateSEODescription(
|
||||
fileData.content,
|
||||
fileData.categoryName,
|
||||
fileData.categoryId,
|
||||
subcategoryDescriptions
|
||||
);
|
||||
|
||||
// Parse the JSON response
|
||||
let parsedDescription;
|
||||
try {
|
||||
parsedDescription = JSON.parse(descriptionJSON);
|
||||
generatedDescriptions.set(category.categoryId, parsedDescription);
|
||||
} catch (error) {
|
||||
console.error(` ❌ Failed to parse JSON for ${category.categoryName}:`, error.message);
|
||||
parsedDescription = { seo_description: descriptionJSON, long_description: descriptionJSON };
|
||||
generatedDescriptions.set(category.categoryId, parsedDescription);
|
||||
}
|
||||
|
||||
// Store result
|
||||
results.push({
|
||||
categoryId: category.categoryId,
|
||||
listFileName: fileData.listFileName,
|
||||
description: parsedDescription.seo_description || descriptionJSON
|
||||
});
|
||||
|
||||
// Add delay to avoid rate limiting
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// Write CSV output
|
||||
if (results.length > 0) {
|
||||
writeCSV(results);
|
||||
console.log('🎉 Category description generation completed successfully!');
|
||||
} else {
|
||||
console.error('❌ No results to write - all files failed processing');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch(error => {
|
||||
console.error('❌ Script failed:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
findListFiles,
|
||||
readListFile,
|
||||
buildProcessingOrder,
|
||||
generateSEODescription,
|
||||
writeCSV
|
||||
};
|
||||
2074
package-lock.json
generated
25
package.json
@@ -7,21 +7,13 @@
|
||||
"start": "cross-env NODE_OPTIONS=\"--no-deprecation\" webpack serve --progress --mode development --no-open",
|
||||
"start:seedheads": "cross-env PROXY_TARGET=https://seedheads.de NODE_OPTIONS=\"--no-deprecation\" webpack serve --progress --mode development --no-open",
|
||||
"prod": "webpack serve --progress --mode production --no-client-overlay --no-client --no-web-socket-server --no-open --no-live-reload --no-hot --compress --no-devtool",
|
||||
"build:client": "node scripts/convert-images-to-avif.cjs && cross-env NODE_ENV=production webpack --progress --mode production && shx cp dist/index.html dist/index_template.html",
|
||||
"build:client": "cross-env NODE_ENV=production webpack --progress --mode production && shx cp dist/index.html dist/index_template.html",
|
||||
"build": "npm run build:client",
|
||||
"analyze": "cross-env ANALYZE=true NODE_ENV=production webpack --progress --mode production",
|
||||
"lint": "eslint src/**/*.{js,jsx}",
|
||||
"prerender": "node prerender.cjs",
|
||||
"prerender:prod": "cross-env NODE_ENV=production node prerender.cjs",
|
||||
"prerender:product": "node prerender-single-product.cjs",
|
||||
"prerender:product:prod": "cross-env NODE_ENV=production node prerender-single-product.cjs",
|
||||
"build:prerender": "npm run build:client && npm run prerender:prod",
|
||||
"translate": "node translate-i18n.js",
|
||||
"translate:english": "node translate-i18n.js --only-english",
|
||||
"translate:skip-english": "node translate-i18n.js --skip-english",
|
||||
"translate:others": "node translate-i18n.js --skip-english",
|
||||
"validate:products": "node scripts/validate-products-xml.cjs",
|
||||
"i18n:check-keys": "node scripts/check-i18n-keys.mjs"
|
||||
"build:prerender": "npm run build:client && npm run prerender:prod"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -34,21 +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",
|
||||
"qrcode": "^1.5.4",
|
||||
"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",
|
||||
"sepa-payment-qr-code": "^2.0.2",
|
||||
"sharp": "^0.34.2",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
@@ -81,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,203 +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, config.socketIoClientOptions);
|
||||
|
||||
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);
|
||||
});
|
||||
271
prerender.cjs
@@ -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,
|
||||
@@ -136,7 +51,6 @@ const {
|
||||
generateLlmsTxt,
|
||||
generateCategoryLlmsTxt,
|
||||
generateAllCategoryLlmsPages,
|
||||
generateCategoryProductList,
|
||||
} = require("./prerender/seo.cjs");
|
||||
const {
|
||||
fetchCategoryProducts,
|
||||
@@ -159,14 +73,18 @@ const Batteriegesetzhinweise =
|
||||
const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default;
|
||||
const Sitemap = require("./src/pages/Sitemap.js").default;
|
||||
const PrerenderSitemap = require("./src/PrerenderSitemap.js").default;
|
||||
const PrerenderCategoriesPage = require("./src/PrerenderCategoriesPage.js").default;
|
||||
const AGB = require("./src/pages/AGB.js").default;
|
||||
const NotFound404 = require("./src/pages/NotFound404.js").default;
|
||||
|
||||
// Worker function for parallel product rendering
|
||||
const renderProductWorker = async (productSeoNames, workerId, progressCallback, categoryMap = {}) => {
|
||||
const socketUrl = "http://127.0.0.1:9303";
|
||||
const workerSocket = io(socketUrl, config.socketIoClientOptions);
|
||||
const workerSocket = io(socketUrl, {
|
||||
path: "/socket.io/",
|
||||
transports: ["polling", "websocket"],
|
||||
reconnection: false,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let processedCount = 0;
|
||||
@@ -185,11 +103,10 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
||||
|
||||
try {
|
||||
const productDetails = await fetchProductDetails(workerSocket, productSeoName);
|
||||
|
||||
|
||||
const actualSeoName = productDetails.product.seoName || productSeoName;
|
||||
const productComponent = React.createElement(PrerenderProduct, {
|
||||
productData: productDetails,
|
||||
t: global.i18n.t.bind(global.i18n),
|
||||
});
|
||||
|
||||
const filename = `Artikel/${actualSeoName}`;
|
||||
@@ -201,7 +118,7 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
||||
}, shopConfig.baseUrl, shopConfig);
|
||||
// Get category info from categoryMap if available
|
||||
const categoryInfo = productDetails.product.categoryId ? categoryMap[productDetails.product.categoryId] : null;
|
||||
|
||||
|
||||
const jsonLdScript = generateProductJsonLd({
|
||||
...productDetails.product,
|
||||
seoName: actualSeoName,
|
||||
@@ -216,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) {
|
||||
@@ -230,9 +146,9 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
||||
success,
|
||||
workerId
|
||||
};
|
||||
|
||||
|
||||
results.push(result);
|
||||
|
||||
|
||||
// Call progress callback if provided
|
||||
if (progressCallback) {
|
||||
progressCallback(result);
|
||||
@@ -248,14 +164,14 @@ const renderProductWorker = async (productSeoNames, workerId, progressCallback,
|
||||
error: error.message,
|
||||
workerId
|
||||
};
|
||||
|
||||
|
||||
results.push(result);
|
||||
|
||||
|
||||
// Call progress callback if provided
|
||||
if (progressCallback) {
|
||||
progressCallback(result);
|
||||
}
|
||||
|
||||
|
||||
setTimeout(processNextProduct, 25);
|
||||
}
|
||||
};
|
||||
@@ -287,16 +203,16 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
|
||||
const barLength = 30;
|
||||
const filledLength = Math.round((barLength * current) / total);
|
||||
const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength);
|
||||
|
||||
|
||||
// @note Single line progress update to prevent flickering
|
||||
const truncatedName = productName ? ` - ${productName.substring(0, 25)}${productName.length > 25 ? '...' : ''}` : '';
|
||||
|
||||
|
||||
// Build worker stats on one line
|
||||
let workerStats = '';
|
||||
for (let i = 0; i < Math.min(maxWorkers, 8); i++) { // Limit to 8 workers to fit on screen
|
||||
workerStats += `W${i + 1}:${workerCounts[i]}/${workerSuccess[i]} `;
|
||||
}
|
||||
|
||||
|
||||
// Single line update without complex cursor movements
|
||||
process.stdout.write(`\r [${bar}] ${percentage}% (${current}/${total})${truncatedName}\n ${workerStats}${current < total ? '\x1b[1A' : '\n'}`);
|
||||
};
|
||||
@@ -304,26 +220,26 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
|
||||
// Split products among workers
|
||||
const productsPerWorker = Math.ceil(allProductsArray.length / maxWorkers);
|
||||
const workerPromises = [];
|
||||
|
||||
|
||||
// Initial progress bar
|
||||
updateProgressBar(0, totalProducts);
|
||||
|
||||
|
||||
for (let i = 0; i < maxWorkers; i++) {
|
||||
const start = i * productsPerWorker;
|
||||
const end = Math.min(start + productsPerWorker, allProductsArray.length);
|
||||
const productsForWorker = allProductsArray.slice(start, end);
|
||||
|
||||
|
||||
if (productsForWorker.length > 0) {
|
||||
const promise = renderProductWorker(productsForWorker, i + 1, (result) => {
|
||||
// Progress callback - called each time a product is completed
|
||||
completedProducts++;
|
||||
progressResults.push(result);
|
||||
lastProductName = result.productName;
|
||||
|
||||
|
||||
// Update per-worker counters
|
||||
const workerIndex = result.workerId - 1; // Convert to 0-based index
|
||||
workerCounts[workerIndex]++;
|
||||
|
||||
|
||||
if (result.success) {
|
||||
totalSuccessCount++;
|
||||
workerSuccess[workerIndex]++;
|
||||
@@ -331,11 +247,11 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
|
||||
// Don't log errors immediately to avoid interfering with progress bar
|
||||
// Errors will be shown after completion
|
||||
}
|
||||
|
||||
|
||||
// Update progress bar with worker stats
|
||||
updateProgressBar(completedProducts, totalProducts, lastProductName);
|
||||
}, categoryMap);
|
||||
|
||||
|
||||
workerPromises.push(promise);
|
||||
}
|
||||
}
|
||||
@@ -343,10 +259,10 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
|
||||
try {
|
||||
// Wait for all workers to complete
|
||||
await Promise.all(workerPromises);
|
||||
|
||||
|
||||
// Ensure final progress update
|
||||
updateProgressBar(totalProducts, totalProducts, lastProductName);
|
||||
|
||||
|
||||
// Show any errors that occurred
|
||||
const errorResults = progressResults.filter(r => !r.success && r.error);
|
||||
if (errorResults.length > 0) {
|
||||
@@ -355,7 +271,7 @@ const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProdu
|
||||
console.log(` - ${result.productSeoName}: ${result.error}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return totalSuccessCount;
|
||||
} catch (error) {
|
||||
console.error('Error in parallel rendering:', error);
|
||||
@@ -365,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
|
||||
@@ -418,19 +334,6 @@ const renderApp = async (categoryData, socket) => {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Copy index.html to resetPassword (no file extension) for SPA routing
|
||||
if (config.isProduction) {
|
||||
const indexPath = path.resolve(__dirname, config.outputDir, "index.html");
|
||||
const resetPasswordPath = path.resolve(__dirname, config.outputDir, "resetPassword");
|
||||
fs.copyFileSync(indexPath, resetPasswordPath);
|
||||
console.log(`✅ Copied index.html to ${resetPasswordPath}`);
|
||||
|
||||
// Copy index.html to linkTelegram (no file extension) for SPA routing
|
||||
const linkTelegramPath = path.resolve(__dirname, config.outputDir, "linkTelegram");
|
||||
fs.copyFileSync(indexPath, linkTelegramPath);
|
||||
console.log(`✅ Copied index.html to ${linkTelegramPath}`);
|
||||
}
|
||||
|
||||
// Render static pages
|
||||
console.log("\n📄 Rendering static pages...");
|
||||
|
||||
@@ -466,13 +369,6 @@ const renderApp = async (categoryData, socket) => {
|
||||
description: "Sitemap page",
|
||||
needsCategoryData: true,
|
||||
},
|
||||
{
|
||||
component: PrerenderCategoriesPage,
|
||||
path: "/Kategorien",
|
||||
filename: "Kategorien",
|
||||
description: "Categories page",
|
||||
needsCategoryData: true,
|
||||
},
|
||||
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
|
||||
{ component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" },
|
||||
{
|
||||
@@ -542,23 +438,7 @@ const renderApp = async (categoryData, socket) => {
|
||||
let categoryPagesRendered = 0;
|
||||
let categoriesWithProducts = 0;
|
||||
|
||||
const allCategoriesWithVirtual = [
|
||||
...allCategories,
|
||||
{
|
||||
id: "neu",
|
||||
name: "Neuheiten",
|
||||
seoName: "neu",
|
||||
parentId: 209,
|
||||
},
|
||||
{
|
||||
id: "bald",
|
||||
name: "Demnächst",
|
||||
seoName: "bald",
|
||||
parentId: 209,
|
||||
},
|
||||
];
|
||||
|
||||
for (const category of allCategoriesWithVirtual) {
|
||||
for (const category of allCategories) {
|
||||
// Skip categories without seoName
|
||||
if (!category.seoName) {
|
||||
console.log(
|
||||
@@ -576,7 +456,8 @@ const renderApp = async (categoryData, socket) => {
|
||||
try {
|
||||
productData = await fetchCategoryProducts(socket, category.id);
|
||||
console.log(
|
||||
` ✅ Found ${productData.products ? productData.products.length : 0
|
||||
` ✅ Found ${
|
||||
productData.products ? productData.products.length : 0
|
||||
} products`
|
||||
);
|
||||
|
||||
@@ -660,7 +541,7 @@ const renderApp = async (categoryData, socket) => {
|
||||
const totalProducts = allProducts.size;
|
||||
const numCPUs = os.cpus().length;
|
||||
const maxWorkers = Math.min(numCPUs, totalProducts, 8); // Cap at 8 workers to avoid overwhelming the server
|
||||
|
||||
|
||||
// Create category map for breadcrumbs
|
||||
const categoryMap = {};
|
||||
allCategories.forEach(category => {
|
||||
@@ -669,11 +550,11 @@ const renderApp = async (categoryData, socket) => {
|
||||
seoName: category.seoName
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
console.log(
|
||||
`\n📦 Rendering ${totalProducts} individual product pages using ${maxWorkers} parallel workers...`
|
||||
);
|
||||
|
||||
|
||||
const productPagesRendered = await renderProductsInParallel(
|
||||
Array.from(allProducts),
|
||||
maxWorkers,
|
||||
@@ -691,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...");
|
||||
@@ -725,21 +607,21 @@ const renderApp = async (categoryData, socket) => {
|
||||
// Generate products.xml (Google Shopping feed) in parallel to sitemap.xml
|
||||
if (allProductsData.length > 0) {
|
||||
console.log("\n🛒 Generating products.xml (Google Shopping feed)...");
|
||||
|
||||
|
||||
try {
|
||||
const productsXml = generateProductsXml(allProductsData, shopConfig.baseUrl, shopConfig);
|
||||
|
||||
|
||||
const productsXmlPath = path.resolve(__dirname, config.outputDir, "products.xml");
|
||||
|
||||
|
||||
// Write with explicit UTF-8 encoding
|
||||
fs.writeFileSync(productsXmlPath, productsXml, { encoding: 'utf8' });
|
||||
|
||||
|
||||
console.log(`✅ products.xml generated: ${productsXmlPath}`);
|
||||
console.log(` - Products included: ${allProductsData.length}`);
|
||||
console.log(` - Format: Google Shopping RSS 2.0 feed`);
|
||||
console.log(` - Encoding: UTF-8`);
|
||||
console.log(` - Includes: title, description, price, availability, images`);
|
||||
|
||||
|
||||
// Verify the file is valid UTF-8
|
||||
try {
|
||||
const verification = fs.readFileSync(productsXmlPath, 'utf8');
|
||||
@@ -747,27 +629,7 @@ const renderApp = async (categoryData, socket) => {
|
||||
} catch (verifyError) {
|
||||
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");
|
||||
@@ -778,18 +640,18 @@ const renderApp = async (categoryData, socket) => {
|
||||
|
||||
// Generate llms.txt (LLM-friendly markdown sitemap) and category-specific files
|
||||
console.log("\n🤖 Generating LLM sitemap files...");
|
||||
|
||||
|
||||
try {
|
||||
// Generate main llms.txt overview file
|
||||
const llmsTxt = generateLlmsTxt(allCategories, allProductsData, shopConfig.baseUrl, shopConfig);
|
||||
const llmsTxtPath = path.resolve(__dirname, config.outputDir, "llms.txt");
|
||||
fs.writeFileSync(llmsTxtPath, llmsTxt, { encoding: 'utf8' });
|
||||
|
||||
|
||||
console.log(`✅ Main llms.txt generated: ${llmsTxtPath}`);
|
||||
console.log(` - Static pages: 8 pages`);
|
||||
console.log(` - Categories: ${allCategories.length} with links to detailed files`);
|
||||
console.log(` - File size: ${Math.round(llmsTxt.length / 1024)}KB`);
|
||||
|
||||
|
||||
// Group products by category for category-specific files
|
||||
const productsByCategory = {};
|
||||
allProductsData.forEach((product) => {
|
||||
@@ -799,53 +661,47 @@ const renderApp = async (categoryData, socket) => {
|
||||
}
|
||||
productsByCategory[categoryId].push(product);
|
||||
});
|
||||
|
||||
|
||||
// Generate category-specific LLM files with pagination
|
||||
let categoryFilesGenerated = 0;
|
||||
let totalCategoryProducts = 0;
|
||||
let totalPaginatedFiles = 0;
|
||||
|
||||
|
||||
for (const category of allCategories) {
|
||||
if (category.seoName) {
|
||||
const categoryProducts = productsByCategory[category.id] || [];
|
||||
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
|
||||
|
||||
// Generate all paginated files for this category
|
||||
const categoryPages = generateAllCategoryLlmsPages(category, categoryProducts, shopConfig.baseUrl, shopConfig);
|
||||
|
||||
|
||||
// Write each paginated file
|
||||
for (const page of categoryPages) {
|
||||
const pagePath = path.resolve(__dirname, config.outputDir, page.fileName);
|
||||
fs.writeFileSync(pagePath, page.content, { encoding: 'utf8' });
|
||||
totalPaginatedFiles++;
|
||||
}
|
||||
|
||||
// Generate and write the product list file for this category
|
||||
const productList = generateCategoryProductList(category, categoryProducts);
|
||||
const listPath = path.resolve(__dirname, config.outputDir, productList.fileName);
|
||||
fs.writeFileSync(listPath, productList.content, { encoding: 'utf8' });
|
||||
|
||||
|
||||
const pageCount = categoryPages.length;
|
||||
const totalSize = categoryPages.reduce((sum, page) => sum + page.content.length, 0);
|
||||
|
||||
|
||||
console.log(` ✅ llms-${categorySlug}-page-*.txt - ${categoryProducts.length} products across ${pageCount} pages (${Math.round(totalSize / 1024)}KB total)`);
|
||||
console.log(` 📋 ${productList.fileName} - ${productList.productCount} products (${Math.round(productList.content.length / 1024)}KB)`);
|
||||
|
||||
|
||||
categoryFilesGenerated++;
|
||||
totalCategoryProducts += categoryProducts.length;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.log(` 📄 Total paginated files generated: ${totalPaginatedFiles}`);
|
||||
console.log(` 📦 Total products across all categories: ${totalCategoryProducts}`);
|
||||
|
||||
|
||||
try {
|
||||
const verification = fs.readFileSync(llmsTxtPath, 'utf8');
|
||||
console.log(` - File verification: ✅ All files valid UTF-8`);
|
||||
} catch (verifyError) {
|
||||
console.log(` - File verification: ⚠️ ${verifyError.message}`);
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error generating LLM sitemap files: ${error.message}`);
|
||||
console.log("\n⚠️ Skipping LLM sitemap generation due to errors");
|
||||
@@ -863,7 +719,12 @@ const fetchCategoryDataAndRender = () => {
|
||||
process.exit(1);
|
||||
}, 15000);
|
||||
|
||||
const socket = io(socketUrl, config.socketIoClientOptions);
|
||||
const socket = io(socketUrl, {
|
||||
path: "/socket.io/",
|
||||
transports: ["polling", "websocket"], // Using polling first is more robust
|
||||
reconnection: false,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
socket.on("connect", () => {
|
||||
console.log('Socket connected. Emitting "categoryList"...');
|
||||
|
||||
@@ -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();
|
||||
@@ -69,21 +61,11 @@ const globalCssCollection = new Set();
|
||||
// Get webpack entrypoints
|
||||
const webpackEntrypoints = getWebpackEntrypoints();
|
||||
|
||||
/** Socket.IO client options for prerender scripts: skip backend connection counters (balanced on disconnect). */
|
||||
const socketIoClientOptions = {
|
||||
path: "/socket.io/",
|
||||
transports: ["websocket"],
|
||||
reconnection: false,
|
||||
timeout: 10000,
|
||||
auth: { prerender: true },
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
isProduction,
|
||||
outputDir,
|
||||
getWebpackEntrypoints,
|
||||
globalCss,
|
||||
globalCssCollection,
|
||||
webpackEntrypoints,
|
||||
socketIoClientOptions,
|
||||
webpackEntrypoints
|
||||
};
|
||||
@@ -37,19 +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,
|
||||
nocount: true,
|
||||
categoryId:
|
||||
categoryId === "neu" || categoryId === "bald"
|
||||
? categoryId
|
||||
: parseInt(categoryId),
|
||||
language: 'de',
|
||||
requestTranslation: false
|
||||
},
|
||||
{ full:true, categoryId: parseInt(categoryId) },
|
||||
(response) => {
|
||||
clearTimeout(timeout);
|
||||
if (response && response.products !== undefined) {
|
||||
@@ -78,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;
|
||||
@@ -156,7 +140,7 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
||||
"public",
|
||||
"assets",
|
||||
"images",
|
||||
"sh.avif"
|
||||
"sh.png"
|
||||
);
|
||||
|
||||
// Ensure assets/images directory exists
|
||||
@@ -187,98 +171,80 @@ const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
||||
.filter((id) => id);
|
||||
|
||||
if (imageIds.length > 0) {
|
||||
// Process first image for each product — store AVIF + JPEG (e.g. for Twitter / social)
|
||||
// Process first image for each product
|
||||
const bildId = parseInt(imageIds[0]);
|
||||
const avifFilename = `prod${bildId}.avif`;
|
||||
const jpegFilename = `prod${bildId}.jpg`;
|
||||
const avifPath = path.join(assetsPath, avifFilename);
|
||||
const jpegPath = path.join(assetsPath, jpegFilename);
|
||||
const estimatedFilename = `prod${bildId}.jpg`; // We'll generate a filename based on the ID
|
||||
|
||||
if (fs.existsSync(avifPath) && fs.existsSync(jpegPath)) {
|
||||
const imagePath = path.join(assetsPath, estimatedFilename);
|
||||
|
||||
// Skip if image already exists
|
||||
if (fs.existsSync(imagePath)) {
|
||||
imagesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const writeAvifAndJpegFromBuffer = async (buf) => {
|
||||
if (!fs.existsSync(avifPath)) {
|
||||
await sharp(buf).avif().toFile(avifPath);
|
||||
}
|
||||
if (!fs.existsSync(jpegPath)) {
|
||||
await sharp(buf)
|
||||
.jpeg({ quality: 85, mozjpeg: true })
|
||||
.toFile(jpegPath);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (fs.existsSync(avifPath) && !fs.existsSync(jpegPath)) {
|
||||
await sharp(avifPath)
|
||||
.jpeg({ quality: 85, mozjpeg: true })
|
||||
.toFile(jpegPath);
|
||||
} else if (!fs.existsSync(avifPath) && fs.existsSync(jpegPath)) {
|
||||
await sharp(jpegPath).avif().toFile(avifPath);
|
||||
} else {
|
||||
const imageBuffer = await fetchProductImage(socket, bildId);
|
||||
const buf = Buffer.from(imageBuffer);
|
||||
const imageBuffer = await fetchProductImage(socket, bildId);
|
||||
|
||||
// If overlay exists, apply it to the image
|
||||
if (false && fs.existsSync(overlayPath)) {
|
||||
try {
|
||||
const baseImage = sharp(buf);
|
||||
const baseMetadata = await baseImage.metadata();
|
||||
// If overlay exists, apply it to the image
|
||||
if (fs.existsSync(overlayPath)) {
|
||||
try {
|
||||
// Get image dimensions to center the overlay
|
||||
const baseImage = sharp(Buffer.from(imageBuffer));
|
||||
const baseMetadata = await baseImage.metadata();
|
||||
|
||||
const overlaySize = Math.min(baseMetadata.width, baseMetadata.height) * 0.4;
|
||||
|
||||
// Resize overlay to 20% of base image size and get its buffer
|
||||
const resizedOverlayBuffer = await sharp(overlayPath)
|
||||
.resize({
|
||||
width: Math.round(overlaySize),
|
||||
height: Math.round(overlaySize),
|
||||
fit: 'contain', // Keep full overlay visible
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 } // Transparent background instead of black bars
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
const overlaySize =
|
||||
Math.min(baseMetadata.width, baseMetadata.height) * 0.4;
|
||||
// Calculate center position for the resized overlay
|
||||
const centerX = Math.floor((baseMetadata.width - overlaySize) / 2);
|
||||
const centerY = Math.floor((baseMetadata.height - overlaySize) / 2);
|
||||
|
||||
const resizedOverlayBuffer = await sharp(overlayPath)
|
||||
.resize({
|
||||
width: Math.round(overlaySize),
|
||||
height: Math.round(overlaySize),
|
||||
fit: "contain",
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
})
|
||||
.toBuffer();
|
||||
const processedImageBuffer = await baseImage
|
||||
.composite([
|
||||
{
|
||||
input: resizedOverlayBuffer,
|
||||
top: centerY,
|
||||
left: centerX,
|
||||
blend: "multiply", // Darkens the image, visible on all backgrounds
|
||||
opacity: 0.3,
|
||||
},
|
||||
])
|
||||
.jpeg() // Ensure output is JPEG
|
||||
.toBuffer();
|
||||
|
||||
const centerX = Math.floor(
|
||||
(baseMetadata.width - overlaySize) / 2
|
||||
);
|
||||
const centerY = Math.floor(
|
||||
(baseMetadata.height - overlaySize) / 2
|
||||
);
|
||||
|
||||
const processedImageBuffer = await baseImage
|
||||
.composite([
|
||||
{
|
||||
input: resizedOverlayBuffer,
|
||||
top: centerY,
|
||||
left: centerX,
|
||||
blend: "multiply",
|
||||
opacity: 0.3,
|
||||
},
|
||||
])
|
||||
.toBuffer();
|
||||
|
||||
await writeAvifAndJpegFromBuffer(processedImageBuffer);
|
||||
console.log(
|
||||
` ✅ Applied overlay → ${avifFilename} + ${jpegFilename}`
|
||||
);
|
||||
} catch (overlayError) {
|
||||
console.log(
|
||||
` ⚠️ Failed to apply overlay to prod${bildId}: ${overlayError.message}`
|
||||
);
|
||||
await writeAvifAndJpegFromBuffer(buf);
|
||||
}
|
||||
} else {
|
||||
await writeAvifAndJpegFromBuffer(buf);
|
||||
fs.writeFileSync(imagePath, processedImageBuffer);
|
||||
console.log(
|
||||
` ✅ Applied centered inverted sh.png overlay to ${estimatedFilename}`
|
||||
);
|
||||
} catch (overlayError) {
|
||||
console.log(
|
||||
` ⚠️ Failed to apply overlay to ${estimatedFilename}: ${overlayError.message}`
|
||||
);
|
||||
// Fallback: save without overlay
|
||||
fs.writeFileSync(imagePath, Buffer.from(imageBuffer));
|
||||
}
|
||||
} else {
|
||||
// Save without overlay if overlay file doesn't exist
|
||||
fs.writeFileSync(imagePath, Buffer.from(imageBuffer));
|
||||
}
|
||||
|
||||
imagesSaved++;
|
||||
|
||||
// Small delay to avoid overwhelming server
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
} catch (error) {
|
||||
console.log(
|
||||
` ⚠️ Failed to fetch/save prod${bildId} (${avifFilename} / ${jpegFilename}): ${error.message}`
|
||||
` ⚠️ Failed to fetch image ${estimatedFilename} (ID: ${bildId}): ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -303,7 +269,7 @@ const saveCategoryImages = async (socket, categories, outputDir) => {
|
||||
// Debug: Log categories that will be processed
|
||||
console.log(" 🔍 Categories to process:");
|
||||
categories.forEach((cat, index) => {
|
||||
console.log(` ${index + 1}. "${cat.name}" (ID: ${cat.id}) -> cat${cat.id}.avif`);
|
||||
console.log(` ${index + 1}. "${cat.name}" (ID: ${cat.id}) -> cat${cat.id}.jpg`);
|
||||
});
|
||||
|
||||
const assetsPath = path.resolve(
|
||||
@@ -330,7 +296,7 @@ const saveCategoryImages = async (socket, categories, outputDir) => {
|
||||
for (const category of categories) {
|
||||
categoriesProcessed++;
|
||||
|
||||
const estimatedFilename = `cat${category.id}.avif`; // Use 'cat' prefix with category ID
|
||||
const estimatedFilename = `cat${category.id}.jpg`; // Use 'cat' prefix with category ID
|
||||
const imagePath = path.join(assetsPath, estimatedFilename);
|
||||
|
||||
// Skip if image already exists
|
||||
|
||||
@@ -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
|
||||
@@ -158,52 +177,28 @@ const renderPage = (
|
||||
const prerenderFallbackScript = `
|
||||
<script>
|
||||
// Save prerendered content to window object for SocketProvider fallback
|
||||
window.__PRERENDER_FALLBACK__ = {
|
||||
window.__PRERENDER_FALLBACK__ = {
|
||||
path: '${location}',
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -247,9 +240,10 @@ const renderPage = (
|
||||
if (!suppressLogs) {
|
||||
console.log(`✅ ${description} prerendered to ${outputPath}`);
|
||||
console.log(` - Markup length: ${renderedMarkup.length} characters`);
|
||||
if (productDetailCacheScript) {
|
||||
console.log(` - Product detail cache populated for SPA hydration`);
|
||||
}
|
||||
console.log(` - CSS rules: ${Object.keys(cache.inserted).length}`);
|
||||
console.log(` - Total inlined CSS: ${Math.round(combinedCss.length / 1024)}KB`);
|
||||
console.log(` - Render-blocking CSS eliminated: ${inlinedCss ? 'YES' : 'NO'}`);
|
||||
console.log(` - Fallback content saved to window.__PRERENDER_FALLBACK__`);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
// Category IDs to skip (seeds, plants, headshop items)
|
||||
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258];
|
||||
|
||||
// Check if category ID is in skip list
|
||||
if (category.id && skipCategoryIds.includes(parseInt(category.id))) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const categoryUrl = `${baseUrl}/Kategorie/${category.seoName}`;
|
||||
|
||||
// Calculate price valid date (current date + 3 months)
|
||||
const priceValidDate = new Date();
|
||||
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
|
||||
const priceValidUntil = priceValidDate.toISOString().split("T")[0];
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org/",
|
||||
"@type": "CollectionPage",
|
||||
@@ -55,7 +42,7 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
product.pictureList && product.pictureList.trim()
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||
.split(",")[0]
|
||||
.trim()}.avif`
|
||||
.trim()}.jpg`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`,
|
||||
description: product.description
|
||||
? product.description.replace(/<[^>]*>/g, "").substring(0, 200)
|
||||
@@ -70,7 +57,6 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
url: `${baseUrl}/Artikel/${product.seoName}`,
|
||||
price: product.price && !isNaN(product.price) ? product.price.toString() : "0.00",
|
||||
priceCurrency: config.currency,
|
||||
priceValidUntil: priceValidUntil,
|
||||
availability: product.available
|
||||
? "https://schema.org/InStock"
|
||||
: "https://schema.org/OutOfStock",
|
||||
@@ -79,41 +65,6 @@ const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
name: config.brandName,
|
||||
},
|
||||
itemCondition: "https://schema.org/NewCondition",
|
||||
hasMerchantReturnPolicy: {
|
||||
"@type": "MerchantReturnPolicy",
|
||||
applicableCountry: "DE",
|
||||
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
|
||||
merchantReturnDays: 14,
|
||||
returnMethod: "https://schema.org/ReturnByMail",
|
||||
returnFees: "https://schema.org/FreeReturn",
|
||||
},
|
||||
shippingDetails: {
|
||||
"@type": "OfferShippingDetails",
|
||||
shippingRate: {
|
||||
"@type": "MonetaryAmount",
|
||||
value: 5.90,
|
||||
currency: "EUR",
|
||||
},
|
||||
shippingDestination: {
|
||||
"@type": "DefinedRegion",
|
||||
addressCountry: "DE",
|
||||
},
|
||||
deliveryTime: {
|
||||
"@type": "ShippingDeliveryTime",
|
||||
handlingTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
transitTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 2,
|
||||
maxValue: 3,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -11,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,147 +23,134 @@ 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
|
||||
915: "2802", // Grow-Sets > Set-Zubehör – 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
|
||||
921: "4082", // Headshop > Pfeifen – Rauchzubehör
|
||||
924: "4082", // Headshop > Dabbing – Rauchzubehör
|
||||
896: "3151", // Headshop > Vaporizer – Vaporizer
|
||||
923: "4082", // Headshop > Papes & Blunts – Rauchzubehör
|
||||
710: "5109", // Headshop > Grinder – Gewürzmühlen (Küchenhelfer)
|
||||
922: "4082", // Headshop > Aktivkohlefilter & Tips – Rauchzubehör
|
||||
916: "4082", // Headshop > Rollen & Bauen – Rauchzubehör
|
||||
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
|
||||
920: "581", // Headshop > Räucherstäbchen – Raumdüfte (Home Fragrances)
|
||||
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"?>
|
||||
<rss xmlns:g="http://base.google.com/ns/1.0" version="2.0">
|
||||
<channel>
|
||||
<title>${config.descriptions.de.short}</title>
|
||||
<title>${config.descriptions.short}</title>
|
||||
<link>${baseUrl}</link>
|
||||
<description>${config.descriptions.de.short}</description>
|
||||
<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) => {
|
||||
@@ -306,43 +197,15 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
let processedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
// Track skip reasons with counts and product lists
|
||||
const skipReasons = {
|
||||
noProductOrSeoName: { count: 0, products: [] },
|
||||
excludedCategory: { count: 0, products: [] },
|
||||
excludedTermsTitle: { count: 0, products: [] },
|
||||
excludedTermsDescription: { count: 0, products: [] },
|
||||
missingGTIN: { count: 0, products: [] },
|
||||
invalidGTINChecksum: { count: 0, products: [] },
|
||||
missingPicture: { count: 0, products: [] },
|
||||
missingWeight: { count: 0, products: [] },
|
||||
insufficientDescription: { count: 0, products: [] },
|
||||
nameTooShort: { count: 0, products: [] },
|
||||
outOfStock: { count: 0, products: [] },
|
||||
zeroPriceOrInvalid: { count: 0, products: [] },
|
||||
processingError: { count: 0, products: [] }
|
||||
};
|
||||
|
||||
// Legacy arrays for backward compatibility
|
||||
const productsNeedingWeight = [];
|
||||
const productsNeedingDescription = [];
|
||||
|
||||
// Category IDs to skip (seeds, plants, headshop items)
|
||||
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710, 924, 923, 922, 921, 916, 278, 259, 258];
|
||||
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710];
|
||||
|
||||
// Add each product as an item
|
||||
allProductsData.forEach((product, index) => {
|
||||
|
||||
try {
|
||||
// Skip products without essential data
|
||||
if (!product || !product.seoName) {
|
||||
skippedCount++;
|
||||
skipReasons.noProductOrSeoName.count++;
|
||||
skipReasons.noProductOrSeoName.products.push({
|
||||
id: product?.articleNumber || 'N/A',
|
||||
name: product?.name || 'N/A',
|
||||
url: product?.seoName ? `/Artikel/${product.seoName}` : 'N/A'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -350,168 +213,27 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
const productCategoryId = product.categoryId || product.category_id || product.category || null;
|
||||
if (productCategoryId && skipCategoryIds.includes(parseInt(productCategoryId))) {
|
||||
skippedCount++;
|
||||
skipReasons.excludedCategory.count++;
|
||||
skipReasons.excludedCategory.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
categoryId: productCategoryId,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip products with excluded terms in title or description
|
||||
const productTitle = (product.name || "").toLowerCase();
|
||||
|
||||
// Get description early so we can check it for excluded terms
|
||||
const productDescription = product.kurzBeschreibung || product.description || '';
|
||||
|
||||
const excludedTerms = {
|
||||
title: ['canna', 'hash', 'marijuana', 'marihuana'],
|
||||
description: ['cannabis']
|
||||
};
|
||||
|
||||
// Check title for excluded terms
|
||||
const excludedTitleTerm = excludedTerms.title.find(term => productTitle.includes(term));
|
||||
if (excludedTitleTerm) {
|
||||
skippedCount++;
|
||||
skipReasons.excludedTermsTitle.count++;
|
||||
skipReasons.excludedTermsTitle.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
term: excludedTitleTerm,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check description for excluded terms
|
||||
const excludedDescTerm = excludedTerms.description.find(term => productDescription.toLowerCase().includes(term));
|
||||
if (excludedDescTerm) {
|
||||
skippedCount++;
|
||||
skipReasons.excludedTermsDescription.count++;
|
||||
skipReasons.excludedTermsDescription.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
term: excludedDescTerm,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip products without GTIN or with invalid GTIN
|
||||
// Skip products without GTIN
|
||||
if (!product.gtin || !product.gtin.toString().trim()) {
|
||||
skippedCount++;
|
||||
skipReasons.missingGTIN.count++;
|
||||
skipReasons.missingGTIN.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate GTIN format and checksum
|
||||
const gtinString = product.gtin.toString().trim();
|
||||
|
||||
// Helper function to validate GTIN with proper checksum validation
|
||||
const isValidGTIN = (gtin) => {
|
||||
if (!/^\d{8}$|^\d{12,14}$/.test(gtin)) return false; // Only 8, 12, 13, 14 digits allowed
|
||||
|
||||
const digits = gtin.split('').map(Number);
|
||||
const length = digits.length;
|
||||
let sum = 0;
|
||||
|
||||
if (length === 8) {
|
||||
// EAN-8: positions 0-6, check digit at 7
|
||||
// Multipliers: 3,1,3,1,3,1,3 for positions 0-6
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const multiplier = (i % 2 === 0) ? 3 : 1;
|
||||
sum += digits[i] * multiplier;
|
||||
}
|
||||
} else if (length === 12) {
|
||||
// UPC-A: positions 0-10, check digit at 11
|
||||
// Multipliers: 3,1,3,1,3,1,3,1,3,1,3 for positions 0-10
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const multiplier = (i % 2 === 0) ? 3 : 1;
|
||||
sum += digits[i] * multiplier;
|
||||
}
|
||||
} else if (length === 13) {
|
||||
// EAN-13: positions 0-11, check digit at 12
|
||||
// Multipliers: 1,3,1,3,1,3,1,3,1,3,1,3 for positions 0-11
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const multiplier = (i % 2 === 0) ? 1 : 3;
|
||||
sum += digits[i] * multiplier;
|
||||
}
|
||||
} else if (length === 14) {
|
||||
// EAN-14: similar to EAN-13 but 14 digits
|
||||
for (let i = 0; i < 13; i++) {
|
||||
const multiplier = (i % 2 === 0) ? 1 : 3;
|
||||
sum += digits[i] * multiplier;
|
||||
}
|
||||
}
|
||||
const checkDigit = (10 - (sum % 10)) % 10;
|
||||
return checkDigit === digits[length - 1];
|
||||
};
|
||||
|
||||
if (!isValidGTIN(gtinString)) {
|
||||
skippedCount++;
|
||||
skipReasons.invalidGTINChecksum.count++;
|
||||
skipReasons.invalidGTINChecksum.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
gtin: gtinString,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip products without pictures
|
||||
if (!product.pictureList || !product.pictureList.trim()) {
|
||||
skippedCount++;
|
||||
skipReasons.missingPicture.count++;
|
||||
skipReasons.missingPicture.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if product has weight data - validate BEFORE building XML
|
||||
if (!product.weight || isNaN(product.weight)) {
|
||||
// Track products without weight
|
||||
const productInfo = {
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'Unnamed',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
};
|
||||
productsNeedingWeight.push(productInfo);
|
||||
skipReasons.missingWeight.count++;
|
||||
skipReasons.missingWeight.products.push(productInfo);
|
||||
skippedCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if description is missing or too short (less than 20 characters) - skip if insufficient
|
||||
const originalDescription = productDescription ? cleanTextContent(productDescription) : '';
|
||||
if (!originalDescription || originalDescription.length < 20) {
|
||||
const productInfo = {
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'Unnamed',
|
||||
currentDescription: originalDescription || 'NONE',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
};
|
||||
productsNeedingDescription.push(productInfo);
|
||||
skipReasons.insufficientDescription.count++;
|
||||
skipReasons.insufficientDescription.products.push(productInfo);
|
||||
skippedCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean description for feed (remove HTML tags and limit length)
|
||||
const feedDescription = cleanTextContent(productDescription).substring(0, 500);
|
||||
const cleanDescription = escapeXml(feedDescription) || "Produktbeschreibung nicht verfügbar";
|
||||
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
|
||||
const rawName = product.name || "Unnamed Product";
|
||||
@@ -520,13 +242,6 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
// Validate essential fields
|
||||
if (!cleanName || cleanName.length < 2) {
|
||||
skippedCount++;
|
||||
skipReasons.nameTooShort.count++;
|
||||
skipReasons.nameTooShort.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: rawName,
|
||||
cleanedName: cleanName,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -535,7 +250,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
|
||||
// Generate image URL
|
||||
const imageUrl = product.pictureList && product.pictureList.trim()
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.avif`
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.jpg`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
|
||||
// Generate brand (manufacturer)
|
||||
@@ -548,18 +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++;
|
||||
skipReasons.outOfStock.count++;
|
||||
skipReasons.outOfStock.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate price (ensure it's a valid number)
|
||||
const price = product.price && !isNaN(product.price)
|
||||
? `${parseFloat(product.price).toFixed(2)} ${config.currency}`
|
||||
@@ -568,18 +271,11 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
// Skip products with price == 0
|
||||
if (!product.price || parseFloat(product.price) === 0) {
|
||||
skippedCount++;
|
||||
skipReasons.zeroPriceOrInvalid.count++;
|
||||
skipReasons.zeroPriceOrInvalid.products.push({
|
||||
id: product.articleNumber || product.seoName,
|
||||
name: product.name || 'N/A',
|
||||
price: product.price,
|
||||
url: `/Artikel/${product.seoName}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate GTIN/EAN if available (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)}`;
|
||||
@@ -590,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>
|
||||
@@ -616,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;
|
||||
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) {
|
||||
// Add weight if available
|
||||
if (product.weight && !isNaN(product.weight)) {
|
||||
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 += `
|
||||
@@ -641,13 +326,6 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
} catch (itemError) {
|
||||
console.log(` ⚠️ Skipped product ${index + 1}: ${itemError.message}`);
|
||||
skippedCount++;
|
||||
skipReasons.processingError.count++;
|
||||
skipReasons.processingError.products.push({
|
||||
id: product?.articleNumber || product?.seoName || 'N/A',
|
||||
name: product?.name || 'N/A',
|
||||
error: itemError.message,
|
||||
url: product?.seoName ? `/Artikel/${product.seoName}` : 'N/A'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -655,133 +333,7 @@ const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
console.log(`\n 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
|
||||
|
||||
// Display skip reason totals
|
||||
console.log(`\n 📋 Skip Reasons Breakdown:`);
|
||||
console.log(` ────────────────────────────────────────────────────────────`);
|
||||
|
||||
const skipReasonLabels = {
|
||||
noProductOrSeoName: 'No Product or SEO Name',
|
||||
excludedCategory: 'Excluded Category',
|
||||
excludedTermsTitle: 'Excluded Terms in Title',
|
||||
excludedTermsDescription: 'Excluded Terms in Description',
|
||||
missingGTIN: 'Missing GTIN',
|
||||
invalidGTINChecksum: 'Invalid GTIN Checksum',
|
||||
missingPicture: 'Missing Picture',
|
||||
missingWeight: 'Missing Weight',
|
||||
insufficientDescription: 'Insufficient Description',
|
||||
nameTooShort: 'Name Too Short',
|
||||
outOfStock: 'Out of Stock',
|
||||
zeroPriceOrInvalid: 'Zero or Invalid Price',
|
||||
processingError: 'Processing Error'
|
||||
};
|
||||
|
||||
let hasAnySkips = false;
|
||||
Object.entries(skipReasons).forEach(([key, data]) => {
|
||||
if (data.count > 0) {
|
||||
hasAnySkips = true;
|
||||
const label = skipReasonLabels[key] || key;
|
||||
console.log(` • ${label}: ${data.count}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasAnySkips) {
|
||||
console.log(` ✅ No products were skipped`);
|
||||
}
|
||||
|
||||
console.log(` ────────────────────────────────────────────────────────────`);
|
||||
console.log(` Total: ${skippedCount} products skipped\n`);
|
||||
|
||||
// Write log files for products needing attention
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const logsDir = path.join(process.cwd(), 'logs');
|
||||
|
||||
// Ensure logs directory exists
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write comprehensive skip reasons log
|
||||
const skipLogPath = path.join(logsDir, `skip-reasons-${timestamp}.log`);
|
||||
let skipLogContent = `# Product Skip Reasons Report
|
||||
# Generated: ${new Date().toISOString()}
|
||||
# Total products processed: ${processedCount}
|
||||
# Total products skipped: ${skippedCount}
|
||||
# Base URL: ${baseUrl}
|
||||
|
||||
`;
|
||||
|
||||
Object.entries(skipReasons).forEach(([key, data]) => {
|
||||
if (data.count > 0) {
|
||||
const label = skipReasonLabels[key] || key;
|
||||
skipLogContent += `\n## ${label} (${data.count} products)\n`;
|
||||
skipLogContent += `${'='.repeat(80)}\n`;
|
||||
|
||||
data.products.forEach(product => {
|
||||
skipLogContent += `ID: ${product.id}\n`;
|
||||
skipLogContent += `Name: ${product.name}\n`;
|
||||
if (product.categoryId !== undefined) {
|
||||
skipLogContent += `Category ID: ${product.categoryId}\n`;
|
||||
}
|
||||
if (product.term !== undefined) {
|
||||
skipLogContent += `Excluded Term: ${product.term}\n`;
|
||||
}
|
||||
if (product.gtin !== undefined) {
|
||||
skipLogContent += `GTIN: ${product.gtin}\n`;
|
||||
}
|
||||
if (product.currentDescription !== undefined) {
|
||||
skipLogContent += `Current Description: "${product.currentDescription}"\n`;
|
||||
}
|
||||
if (product.cleanedName !== undefined) {
|
||||
skipLogContent += `Cleaned Name: "${product.cleanedName}"\n`;
|
||||
}
|
||||
if (product.price !== undefined) {
|
||||
skipLogContent += `Price: ${product.price}\n`;
|
||||
}
|
||||
if (product.error !== undefined) {
|
||||
skipLogContent += `Error: ${product.error}\n`;
|
||||
}
|
||||
skipLogContent += `URL: ${baseUrl}${product.url}\n`;
|
||||
skipLogContent += `${'-'.repeat(80)}\n`;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(skipLogPath, skipLogContent, 'utf8');
|
||||
console.log(` 📄 Detailed skip reasons report saved to: ${skipLogPath}`);
|
||||
|
||||
// Write missing weight log (for backward compatibility)
|
||||
if (productsNeedingWeight.length > 0) {
|
||||
const weightLogContent = `# Products Missing Weight Data
|
||||
# Generated: ${new Date().toISOString()}
|
||||
# Total products missing weight: ${productsNeedingWeight.length}
|
||||
|
||||
${productsNeedingWeight.map(product => `${product.id}\t${product.name}\t${baseUrl}${product.url}`).join('\n')}
|
||||
`;
|
||||
|
||||
const weightLogPath = path.join(logsDir, `missing-weight-${timestamp}.log`);
|
||||
fs.writeFileSync(weightLogPath, weightLogContent, 'utf8');
|
||||
console.log(` ⚠️ Products missing weight (${productsNeedingWeight.length}) - saved to: ${weightLogPath}`);
|
||||
}
|
||||
|
||||
// Write missing description log (for backward compatibility)
|
||||
if (productsNeedingDescription.length > 0) {
|
||||
const descLogContent = `# Products With Insufficient Description Data
|
||||
# Generated: ${new Date().toISOString()}
|
||||
# Total products needing description: ${productsNeedingDescription.length}
|
||||
|
||||
${productsNeedingDescription.map(product => `${product.id}\t${product.name}\t"${product.currentDescription}"\t${baseUrl}${product.url}`).join('\n')}
|
||||
`;
|
||||
|
||||
const descLogPath = path.join(logsDir, `missing-description-${timestamp}.log`);
|
||||
fs.writeFileSync(descLogPath, descLogContent, 'utf8');
|
||||
console.log(` ⚠️ Products with insufficient description (${productsNeedingDescription.length}) - saved to: ${descLogPath}`);
|
||||
}
|
||||
|
||||
if (productsNeedingWeight.length === 0 && productsNeedingDescription.length === 0) {
|
||||
console.log(` ✅ All products have adequate weight and description data`);
|
||||
}
|
||||
console.log(` 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
|
||||
|
||||
return productsXml;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const generateHomepageMetaTags = (baseUrl, config) => {
|
||||
const description = config.descriptions.de.long;
|
||||
const keywords = config.keywords.de;
|
||||
const description = config.descriptions.long;
|
||||
const keywords = config.keywords;
|
||||
const imageUrl = `${baseUrl}${config.images.logo}`;
|
||||
|
||||
// Ensure URLs are properly formatted
|
||||
@@ -12,7 +12,7 @@ const generateHomepageMetaTags = (baseUrl, config) => {
|
||||
<meta name="keywords" content="${keywords}">
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="${config.descriptions.de.short}">
|
||||
<meta property="og:title" content="${config.descriptions.short}">
|
||||
<meta property="og:description" content="${description}">
|
||||
<meta property="og:image" content="${imageUrl}">
|
||||
<meta property="og:url" content="${canonicalUrl}">
|
||||
@@ -21,7 +21,7 @@ const generateHomepageMetaTags = (baseUrl, config) => {
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="${config.descriptions.de.short}">
|
||||
<meta name="twitter:title" content="${config.descriptions.short}">
|
||||
<meta name="twitter:description" content="${description}">
|
||||
<meta name="twitter:image" content="${imageUrl}">
|
||||
|
||||
@@ -41,7 +41,7 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
|
||||
"@type": "WebSite",
|
||||
name: config.brandName,
|
||||
url: canonicalUrl,
|
||||
description: config.descriptions.de.long,
|
||||
description: config.descriptions.long,
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: config.brandName,
|
||||
@@ -73,7 +73,7 @@ const generateHomepageJsonLd = (baseUrl, config, categories = []) => {
|
||||
"@type": "LocalBusiness",
|
||||
"name": config.brandName,
|
||||
"alternateName": config.siteName,
|
||||
"description": config.descriptions.de.long,
|
||||
"description": config.descriptions.long,
|
||||
"url": canonicalUrl,
|
||||
"logo": logoUrl,
|
||||
"image": logoUrl,
|
||||
|
||||
@@ -31,7 +31,6 @@ const {
|
||||
generateLlmsTxt,
|
||||
generateCategoryLlmsTxt,
|
||||
generateAllCategoryLlmsPages,
|
||||
generateCategoryProductList,
|
||||
} = require('./llms.cjs');
|
||||
|
||||
// Export all functions for use in the main application
|
||||
@@ -62,5 +61,4 @@ module.exports = {
|
||||
generateLlmsTxt,
|
||||
generateCategoryLlmsTxt,
|
||||
generateAllCategoryLlmsPages,
|
||||
generateCategoryProductList,
|
||||
};
|
||||
@@ -55,26 +55,39 @@ GrowHeads.de is a German online shop and local store in Dresden specializing in
|
||||
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
const productsPerPage = 50;
|
||||
const totalPages = Math.ceil(productCount / productsPerPage);
|
||||
|
||||
|
||||
llmsTxt += `#### ${category.name} (${productCount} products)`;
|
||||
|
||||
|
||||
if (totalPages > 1) {
|
||||
llmsTxt += `
|
||||
${totalPages} pages available`;
|
||||
for (let p = 1; p <= totalPages; p++) {
|
||||
const start = (p - 1) * productsPerPage + 1;
|
||||
const end = Math.min(p * productsPerPage, productCount);
|
||||
- **Product Catalog**: ${totalPages} pages available
|
||||
- **Page 1**: ${baseUrl}/llms-${categorySlug}-page-1.txt (Products 1-${Math.min(productsPerPage, productCount)})`;
|
||||
|
||||
if (totalPages > 2) {
|
||||
llmsTxt += `
|
||||
- **Page ${p}**: ${baseUrl}/llms-${categorySlug}-page-${p}.txt (Products ${start}-${end})`;
|
||||
- **Page 2**: ${baseUrl}/llms-${categorySlug}-page-2.txt (Products ${productsPerPage + 1}-${Math.min(productsPerPage * 2, productCount)})`;
|
||||
}
|
||||
|
||||
if (totalPages > 3) {
|
||||
llmsTxt += `
|
||||
- **...**: Additional pages available`;
|
||||
}
|
||||
|
||||
if (totalPages > 2) {
|
||||
llmsTxt += `
|
||||
- **Page ${totalPages}**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt (Products ${((totalPages - 1) * productsPerPage) + 1}-${productCount})`;
|
||||
}
|
||||
|
||||
llmsTxt += `
|
||||
- **Access Pattern**: Replace "page-X" with desired page number (1-${totalPages})`;
|
||||
} else if (productCount > 0) {
|
||||
llmsTxt += `
|
||||
${baseUrl}/llms-${categorySlug}-page-1.txt`;
|
||||
- **Product Catalog**: ${baseUrl}/llms-${categorySlug}-page-1.txt`;
|
||||
} else {
|
||||
llmsTxt += `
|
||||
No products available`;
|
||||
- **Product Catalog**: No products available`;
|
||||
}
|
||||
|
||||
|
||||
llmsTxt += `
|
||||
|
||||
`;
|
||||
@@ -93,7 +106,7 @@ No products available`;
|
||||
const generateCategoryLlmsTxt = (category, categoryProducts = [], baseUrl, config, pageNumber = 1, productsPerPage = 50) => {
|
||||
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
|
||||
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
|
||||
|
||||
// Calculate pagination
|
||||
const totalProducts = categoryProducts.length;
|
||||
const totalPages = Math.ceil(totalProducts / productsPerPage);
|
||||
@@ -127,28 +140,28 @@ This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in
|
||||
|
||||
**How to access other pages in this category:**
|
||||
`;
|
||||
|
||||
|
||||
if (pageNumber > 1) {
|
||||
categoryLlmsTxt += `- **Previous Page**: ${baseUrl}/llms-${categorySlug}-page-${pageNumber - 1}.txt
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
if (pageNumber < totalPages) {
|
||||
categoryLlmsTxt += `- **Next Page**: ${baseUrl}/llms-${categorySlug}-page-${pageNumber + 1}.txt
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
categoryLlmsTxt += `- **First Page**: ${baseUrl}/llms-${categorySlug}-page-1.txt
|
||||
- **Last Page**: ${baseUrl}/llms-${categorySlug}-page-${totalPages}.txt
|
||||
|
||||
**All pages in this category:**
|
||||
`;
|
||||
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
categoryLlmsTxt += `- **Page ${i}**: ${baseUrl}/llms-${categorySlug}-page-${i}.txt (Products ${((i - 1) * productsPerPage) + 1}-${Math.min(i * productsPerPage, totalProducts)})
|
||||
categoryLlmsTxt += `- **Page ${i}**: ${baseUrl}/llms-${categorySlug}-page-${i}.txt (Products ${((i-1) * productsPerPage) + 1}-${Math.min(i * productsPerPage, totalProducts)})
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
categoryLlmsTxt += `
|
||||
|
||||
`;
|
||||
@@ -160,23 +173,17 @@ This file contains products ${startIndex + 1}-${endIndex} of ${totalProducts} in
|
||||
// Clean description for markdown (remove HTML tags and limit length)
|
||||
const cleanDescription = product.description
|
||||
? product.description
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\n/g, " ")
|
||||
.trim()
|
||||
.substring(0, 300)
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\n/g, " ")
|
||||
.trim()
|
||||
.substring(0, 300)
|
||||
: "";
|
||||
|
||||
const globalIndex = startIndex + index + 1;
|
||||
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'}`;
|
||||
@@ -221,13 +228,13 @@ This category currently contains no products.
|
||||
if (pageNumber > 1) {
|
||||
categoryLlmsTxt += `← [Previous Page](${baseUrl}/llms-${categorySlug}-page-${pageNumber - 1}.txt) | `;
|
||||
}
|
||||
|
||||
|
||||
categoryLlmsTxt += `[Category Overview](${baseUrl}/llms-${categorySlug}-page-1.txt)`;
|
||||
|
||||
|
||||
if (pageNumber < totalPages) {
|
||||
categoryLlmsTxt += ` | [Next Page](${baseUrl}/llms-${categorySlug}-page-${pageNumber + 1}.txt) →`;
|
||||
}
|
||||
|
||||
|
||||
categoryLlmsTxt += `
|
||||
|
||||
`;
|
||||
@@ -241,52 +248,17 @@ This category currently contains no products.
|
||||
return categoryLlmsTxt;
|
||||
};
|
||||
|
||||
// Helper function to generate a simple product list for a category
|
||||
const generateCategoryProductList = (category, categoryProducts = []) => {
|
||||
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
const fileName = `llms-${categorySlug}-list.txt`;
|
||||
|
||||
const subcategoryIds = (category.subcategories || []).join(',');
|
||||
let content = '';//`${String(category.name)},${String(category.id)},[${subcategoryIds}]\n`;
|
||||
|
||||
categoryProducts.forEach((product) => {
|
||||
const artnr = String(product.articleNumber || '');
|
||||
const price = String(product.price || '0.00');
|
||||
const name = String(product.name || '');
|
||||
const kurzBeschreibung = String(product.kurzBeschreibung || '');
|
||||
|
||||
// Escape commas in fields by wrapping in quotes if they contain commas
|
||||
const escapeField = (field) => {
|
||||
const fieldStr = String(field || '');
|
||||
if (fieldStr.includes(',')) {
|
||||
return `"${fieldStr.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return fieldStr;
|
||||
};
|
||||
|
||||
content += `${escapeField(artnr)},${escapeField(price)},${escapeField(name)},${escapeField(kurzBeschreibung)}\n`;
|
||||
});
|
||||
|
||||
return {
|
||||
fileName,
|
||||
content,
|
||||
categoryName: category.name,
|
||||
categoryId: category.id,
|
||||
productCount: categoryProducts.length
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to generate all pages for a category
|
||||
const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl, config, productsPerPage = 50) => {
|
||||
const totalProducts = categoryProducts.length;
|
||||
const totalPages = Math.ceil(totalProducts / productsPerPage);
|
||||
const pages = [];
|
||||
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
|
||||
const pageContent = generateCategoryLlmsTxt(category, categoryProducts, baseUrl, config, pageNumber, productsPerPage);
|
||||
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
const fileName = `llms-${categorySlug}-page-${pageNumber}.txt`;
|
||||
|
||||
|
||||
pages.push({
|
||||
fileName,
|
||||
content: pageContent,
|
||||
@@ -294,7 +266,7 @@ const generateAllCategoryLlmsPages = (category, categoryProducts = [], baseUrl,
|
||||
totalPages
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
@@ -302,5 +274,4 @@ module.exports = {
|
||||
generateLlmsTxt,
|
||||
generateCategoryLlmsTxt,
|
||||
generateAllCategoryLlmsPages,
|
||||
generateCategoryProductList,
|
||||
};
|
||||
@@ -1,31 +1,19 @@
|
||||
const generateProductMetaTags = (product, baseUrl, config) => {
|
||||
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
|
||||
|
||||
const pictureFirstId =
|
||||
const imageUrl =
|
||||
product.pictureList && product.pictureList.trim()
|
||||
? product.pictureList.split(",")[0].trim()
|
||||
: null;
|
||||
|
||||
const imageUrl = pictureFirstId
|
||||
? `${baseUrl}/assets/images/prod${pictureFirstId}.avif`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
|
||||
const twitterImageUrl = pictureFirstId
|
||||
? `${baseUrl}/assets/images/prod${pictureFirstId}.jpg`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||
.split(",")[0]
|
||||
.trim()}.jpg`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
|
||||
// Clean description for meta (remove HTML tags and limit length)
|
||||
const cleanDescription = product.kurzBeschreibung
|
||||
? product.kurzBeschreibung
|
||||
const cleanDescription = product.description
|
||||
? product.description
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\n/g, " ")
|
||||
.substring(0, 160)
|
||||
: product.description
|
||||
? product.description
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\n/g, " ")
|
||||
.substring(0, 160)
|
||||
: `${product.name} - Art.-Nr.: ${product.articleNumber}`;
|
||||
: `${product.name} - Art.-Nr.: ${product.articleNumber}`;
|
||||
|
||||
return `
|
||||
<!-- SEO Meta Tags -->
|
||||
@@ -37,7 +25,7 @@ const generateProductMetaTags = (product, baseUrl, config) => {
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="${product.name}">
|
||||
<meta property="og:description" content="${cleanDescription}">
|
||||
<meta property="og:image" content="${twitterImageUrl}">
|
||||
<meta property="og:image" content="${imageUrl}">
|
||||
<meta property="og:url" content="${productUrl}">
|
||||
<meta property="og:type" content="product">
|
||||
<meta property="og:site_name" content="${config.siteName}">
|
||||
@@ -54,28 +42,22 @@ const generateProductMetaTags = (product, baseUrl, config) => {
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="${product.name}">
|
||||
<meta name="twitter:description" content="${cleanDescription}">
|
||||
<meta name="twitter:image" content="${twitterImageUrl}">
|
||||
<meta name="twitter:image" content="${imageUrl}">
|
||||
|
||||
<!-- 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>
|
||||
`;
|
||||
};
|
||||
|
||||
const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) => {
|
||||
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
|
||||
const pictureFirstId =
|
||||
const imageUrl =
|
||||
product.pictureList && product.pictureList.trim()
|
||||
? product.pictureList.split(",")[0].trim()
|
||||
: null;
|
||||
const imageUrl = pictureFirstId
|
||||
? `${baseUrl}/assets/images/prod${pictureFirstId}.avif`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||
.split(",")[0]
|
||||
.trim()}.jpg`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
|
||||
// Clean description for JSON-LD (remove HTML tags)
|
||||
const cleanDescription = product.description
|
||||
@@ -86,7 +68,7 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
|
||||
const priceValidDate = new Date();
|
||||
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
|
||||
|
||||
const productJsonLd = {
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org/",
|
||||
"@type": "Product",
|
||||
name: product.name,
|
||||
@@ -112,52 +94,12 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
|
||||
"@type": "Organization",
|
||||
name: config.brandName,
|
||||
},
|
||||
hasMerchantReturnPolicy: {
|
||||
"@type": "MerchantReturnPolicy",
|
||||
applicableCountry: "DE",
|
||||
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
|
||||
merchantReturnDays: 14,
|
||||
returnMethod: "https://schema.org/ReturnByMail",
|
||||
returnFees: "https://schema.org/FreeReturn",
|
||||
},
|
||||
shippingDetails: {
|
||||
"@type": "OfferShippingDetails",
|
||||
shippingRate: {
|
||||
"@type": "MonetaryAmount",
|
||||
value: 5.90,
|
||||
currency: "EUR",
|
||||
},
|
||||
shippingDestination: {
|
||||
"@type": "DefinedRegion",
|
||||
addressCountry: "DE",
|
||||
},
|
||||
deliveryTime: {
|
||||
"@type": "ShippingDeliveryTime",
|
||||
handlingTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
transitTime: {
|
||||
"@type": "QuantitativeValue",
|
||||
minValue: 2,
|
||||
maxValue: 3,
|
||||
unitCode: "DAY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const productScript = `<script type="application/ld+json">${JSON.stringify(
|
||||
productJsonLd
|
||||
)}</script>`;
|
||||
|
||||
// BreadcrumbList is not a valid property on Product; emit as its own JSON-LD block (WebPage path context).
|
||||
// Add breadcrumb if category information is available
|
||||
if (categoryInfo && categoryInfo.name && categoryInfo.seoName) {
|
||||
const breadcrumbJsonLd = {
|
||||
"@context": "https://schema.org/",
|
||||
jsonLd.breadcrumb = {
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{
|
||||
@@ -180,13 +122,11 @@ const generateProductJsonLd = (product, baseUrl, config, categoryInfo = null) =>
|
||||
},
|
||||
],
|
||||
};
|
||||
const breadcrumbScript = `<script type="application/ld+json">${JSON.stringify(
|
||||
breadcrumbJsonLd
|
||||
)}</script>`;
|
||||
return `${productScript}\n${breadcrumbScript}`;
|
||||
}
|
||||
|
||||
return productScript;
|
||||
return `<script type="application/ld+json">${JSON.stringify(
|
||||
jsonLd
|
||||
)}</script>`;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -7,17 +7,11 @@ const collectAllCategories = (categoryNode, categories = []) => {
|
||||
|
||||
// Add current category (skip root category 209)
|
||||
if (categoryNode.id !== 209) {
|
||||
// Extract subcategory IDs from children
|
||||
const subcategoryIds = categoryNode.children
|
||||
? categoryNode.children.map(child => child.id)
|
||||
: [];
|
||||
|
||||
categories.push({
|
||||
id: categoryNode.id,
|
||||
name: categoryNode.name,
|
||||
seoName: categoryNode.seoName,
|
||||
parentId: categoryNode.parentId,
|
||||
subcategories: subcategoryIds
|
||||
parentId: categoryNode.parentId
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Read the input file from public
|
||||
const inputFile = path.join(__dirname, 'public', 'llms-cat.txt');
|
||||
// Write the output file to dist
|
||||
const outputFile = path.join(__dirname, 'dist', 'llms-cat.txt');
|
||||
|
||||
// Function to parse a CSV line with escaped quotes
|
||||
function parseCSVLine(line) {
|
||||
const fields = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
let i = 0;
|
||||
|
||||
while (i < line.length) {
|
||||
const char = line[i];
|
||||
|
||||
if (char === '"') {
|
||||
// Check if this is an escaped quote
|
||||
if (i + 1 < line.length && line[i + 1] === '"') {
|
||||
current += '"'; // Add single quote (unescaped)
|
||||
i += 2; // Skip both quotes
|
||||
continue;
|
||||
} else {
|
||||
inQuotes = !inQuotes; // Toggle quote state
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
fields.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
fields.push(current); // Add the last field
|
||||
return fields;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(inputFile)) {
|
||||
throw new Error(`Input file not found: ${inputFile}`);
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(inputFile, 'utf8');
|
||||
const lines = data.trim().split('\n');
|
||||
|
||||
// Keep the header as intended: URL and Description
|
||||
const outputLines = ['URL of product list for article numbers,SEO Description'];
|
||||
|
||||
let skippedLines = 0;
|
||||
let processedLines = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '') continue;
|
||||
|
||||
// Skip comment lines or lines not starting with a number/quote (simple heuristic for header/comments)
|
||||
// The file starts with text "this file has..." and then header "categoryId..."
|
||||
// Actual data lines start with "
|
||||
if (!line.trim().startsWith('"')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse the CSV line properly handling escaped quotes
|
||||
const fields = parseCSVLine(line);
|
||||
|
||||
if (fields.length !== 3) {
|
||||
console.warn(`Skipping malformed line ${i + 1} (got ${fields.length} fields): ${line.substring(0, 50)}...`);
|
||||
skippedLines++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Input: categoryId, listFileName, seoDescription
|
||||
// Output: URL, SEO Description
|
||||
const [categoryId, listFileName, seoDescription] = fields;
|
||||
|
||||
// Use listFileName as URL
|
||||
const url = listFileName;
|
||||
|
||||
// Use seoDescription as description directly (it's already a string)
|
||||
const description = seoDescription;
|
||||
|
||||
// Escape quotes for CSV output
|
||||
const escapedDescription = '"' + description.replace(/"/g, '""') + '"';
|
||||
|
||||
outputLines.push(`${url},${escapedDescription}`);
|
||||
processedLines++;
|
||||
}
|
||||
|
||||
// Ensure dist directory exists
|
||||
const distDir = path.dirname(outputFile);
|
||||
if (!fs.existsSync(distDir)) {
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write the output CSV
|
||||
fs.writeFileSync(outputFile, outputLines.join('\n'), 'utf8');
|
||||
console.log(`Processed ${processedLines} lines (skipped ${skippedLines}) and created ${outputFile}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing file:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
@@ -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">
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
this file has the list of category overview lists, where you can find article numbers
|
||||
|
||||
categoryId,listFileName,seoDescription
|
||||
"703","https://growheads.de/llms-abluft-sets-list.txt","Abluft-Sets für Growbox & Indoor-Grow: leise, energiesparend & mit Aktivkohlefilter zur Geruchsneutralisation. Ideal für Zelte von 60 cm bis 1 m²."
|
||||
"317","https://growheads.de/llms-air-pot-list.txt","Air-Pot Pflanztöpfe für maximales Wurzelwachstum: Air-Pruning, optimale Belüftung & Drainage. Ideal für Indoor, Outdoor, Hydroponik & Anzucht."
|
||||
"922","https://growheads.de/llms-aktivkohlefilter-tips-list.txt","Aktivkohlefilter & Tips für Zigaretten und Selbstgedrehte – milderer Geschmack, weniger Schadstoffe, optimale Rauchfilterung und hoher Genuss."
|
||||
"372","https://growheads.de/llms-autopot-list.txt","AutoPot Bewässerungssysteme & Zubehör: Stromlose, automatische Pflanzenbewässerung mit Tanks, Schläuchen, FlexiPots & AQUAvalve5 für Hydroponik & Garten."
|
||||
"389","https://growheads.de/llms-blumat-list.txt","Blumat Bewässerungssysteme & Zubehör: Tropf-Bewässerung, Erdfeuchte-Sensoren und Ventile für automatische, bedarfsgerechte Pflanzenbewässerung."
|
||||
"355","https://growheads.de/llms-boveda-integra-boost-list.txt","Boveda & Integra Boost Hygro-Packs für perfekte Feuchtigkeitskontrolle Deiner Kräuter. Verhindern Schimmel, Austrocknung und Aroma-Verlust bei der Lagerung."
|
||||
"749","https://growheads.de/llms-chillums-diffusoren-kupplungen-list.txt","Chillums, Diffusoren & Kupplungen für Bongs – große Auswahl an 14,5mm, 18,8mm & 29,2mm Adaptern aus Glas für bessere Kühlung & sanfteren Rauchgenuss."
|
||||
"449","https://growheads.de/llms-cliptuetchen-list.txt","Cliptütchen & Mylarbeutel: hochwertige Zip Bags und Schnellverschlussbeutel in vielen Größen, starken Folienstärken und Farben – ideal zur sicheren Lagerung."
|
||||
"924","https://growheads.de/llms-dabbing-list.txt","Entdecken Sie hochwertiges Dabbing-Zubehör für konzentrierte Aromagenuss – Dab Rigs, Tools und mehr für ein intensives, sauberes Dab-Erlebnis."
|
||||
"691","https://growheads.de/llms-duenger-list.txt","Dünger & Pflanzennährstoffe für Erde, Coco & Hydro: Bio- und Mineraldünger, Booster, Wurzelstimulatoren, PK-Additive & pH-Regulatoren für maximale Erträge."
|
||||
"692","https://growheads.de/llms-duenger-zubehoer-list.txt","Dünger-Zubehör: Abfüllhähne, Wurmhumus, pH-Eichlösungen & Desinfektionsmittel für präzise Dosierung, Hygiene und optimale Nährstoffversorgung der Pflanzen."
|
||||
"489","https://growheads.de/llms-eazyplug-jiffy-list.txt","EazyPlug & Jiffy Anzuchtmedien: nachhaltige Anzuchtwürfel, Torftöpfe & Trays für Stecklinge und Sämlinge mit optimalem Wasser-Luft-Verhältnis."
|
||||
"243","https://growheads.de/llms-erde-list.txt","Hochwertige Blumenerde & Bio-Substrate: torffrei, organisch, vorgedüngt – ideal für Indoor-Grow, Urban Gardening, Kräuter, Gemüse & Cannabis-Anbau."
|
||||
"302","https://growheads.de/llms-erntemaschinen-list.txt","Erntemaschinen & Leaf Trimmer für professionelle Ernteverarbeitung – vom manuellen Trimmer bis zur automatisierten Profigerät, inkl. Zubehör & Ersatzteile."
|
||||
"280","https://growheads.de/llms-erntescheeren-list.txt","Hochwertige Erntescheren & Gartenscheren für präzise Pflanzenschnitte. Entdecken Sie Profi-Erntescheren aus Edelstahl & Japanstahl für Garten & Indoor-Grow."
|
||||
"424","https://growheads.de/llms-etiketten-schilder-list.txt","Etiketten & Schilder für Garten & Gewächshaus – wasserfeste Stecketiketten in vielen Farben für Pflanzenbeschriftung, Sortierung und Kennzeichnung."
|
||||
"278","https://growheads.de/llms-extraktion-list.txt","Hochwertige Extraktionszubehör & -geräte: Pollenmaschinen, Extraktorbeutel, DME-Gas, Rosin Bags & Infuser für saubere Pflanzen- und Öl-Extraktionen."
|
||||
"379","https://growheads.de/llms-geruchsneutralisation-list.txt","Effektive Geruchsneutralisation für Haushalt, Grow-Räume & Gewerbe – ONA & BIODOR Gel, Spray, Filter und Ozongeneratoren gegen Tabak-, Cannabis- & Tiergerüche."
|
||||
"359","https://growheads.de/llms-gewaechshaeuser-list.txt","Gewächshäuser & Anzuchtgewächshäuser für drinnen: beheizt, mit LED & Lüftung. Ideal für Kräuter, Gemüse & Stecklinge – für erfolgreiche Pflanzenanzucht."
|
||||
"539","https://growheads.de/llms-glaeser-dosen-list.txt","Gläser & Dosen für luftdichte, lichtgeschützte und geruchsneutrale Aufbewahrung von Kräutern, Lebensmitteln & Wertsachen. Vakuum-, Stash- & Miron-Glas."
|
||||
"710","https://growheads.de/llms-grinder-list.txt","Hochwertige Grinder & Kräutermühlen aus Aluminium, Holz & Edelstahl – 2-, 3- & 4-teilig, Pollinator, Non-Sticky & Design-Grinder für Tabak & Kräuter."
|
||||
"407","https://growheads.de/llms-grove-bags-list.txt","Grove Bags TerpLoc – professionelle Lagerung für Cannabis & Kräuter. Schimmelschutz, Feuchtigkeitskontrolle, Terpene & Aroma optimal bewahren."
|
||||
"408","https://growheads.de/llms-growcontrol-list.txt","GrowControl Steuerungen & Sensoren für präzises Klima-, CO₂- und Lichtmanagement im Indoor-Grow. Made in Germany, kompatibel mit EC-Lüftern & LED-Systemen."
|
||||
"373","https://growheads.de/llms-growtool-list.txt","GrowTool Zubehör für professionelle Bewässerung & GrowRacks: stabile Unterbauten, aeroponische Systeme, Adapter & Wasserkühler für optimales Indoor-Growing."
|
||||
"310","https://growheads.de/llms-heizmatten-list.txt","Heizmatten für Gewächshaus, Growbox & Terrarium: Effiziente Wurzelwärme, schnellere Keimung & gesundes Pflanzenwachstum mit Thermostat-Steuerung."
|
||||
"748","https://growheads.de/llms-koepfe-list.txt","Hochwertige Bong-Köpfe & Glasbowls: Entdecke Trichterköpfe, Flutschköpfe und Zenit Premium-Köpfe in 14,5 & 18,8 mm in vielen Farben online."
|
||||
"269","https://growheads.de/llms-kokos-list.txt","Entdecken Sie hochwertige Kokossubstrate & Kokosmatten für Hydroponik, Indoor-Grow & Topfkulturen – torffreie, nachhaltige Coco-Erden für starkes Wurzelwachstum."
|
||||
"364","https://growheads.de/llms-kunststofftoepfe-list.txt","Kunststofftöpfe für Pflanzen, Anzucht und Umtopfen: Viereckige Pflanztöpfe, Airpots & Mini-Pots in vielen Größen für gesundes Wurzelwachstum."
|
||||
"694","https://growheads.de/llms-lampen-list.txt","Entdecken Sie hochwertige LED Grow Lampen & Pflanzenlampen mit Vollspektrum für Indoor-Grow, Wachstum & Blüte. Effizient, dimmbar & langlebig."
|
||||
"261","https://growheads.de/llms-lampenzubehoer-list.txt","Hochwertiges Lampenzubehör für Growbox & Gewächshaus: Aufhängungen, Dimmer, Reflektoren, Netzteile & SANlight-Zubehör für optimales Pflanzenlicht."
|
||||
"387","https://growheads.de/llms-literatur-list.txt","Entdecke Fachliteratur zu Cannabis-Anbau, Bio-Grow, LED, Hydrokultur & Extraktion – praxisnahe Bücher für Indoor- und Outdoor-Gärtner, Anfänger & Profis."
|
||||
"658","https://growheads.de/llms-luftbe-und-entfeuchter-list.txt","Effektive Luftbefeuchter & Luftentfeuchter für Growroom & Indoor-Anbau. Optimale Luftfeuchtigkeit, Schimmelvorbeugung & gesundes Pflanzenwachstum."
|
||||
"403","https://growheads.de/llms-messbecher-mehr-list.txt","Messbecher, Pipetten & Einwegspritzen zum präzisen Abmessen von Flüssigkeiten – ideal für Dünger, Zusätze, Labor und Garten. Verschiedene Größen."
|
||||
"344","https://growheads.de/llms-messgeraete-list.txt","Präzise pH-, EC-, Temperatur- und Klimamessgeräte für Garten, Hydroponik & Labor. Entdecke Profi-Messinstrumente, Sonden und Kalibrierlösungen."
|
||||
"555","https://growheads.de/llms-mikroskope-list.txt","Mikroskope & Lupen für Hobby, Schule & Elektronik: Entdecken Sie 5x–100x Vergrößerung, USB-Mikroskope, LED-Modelle und mobile Zoom-Mikroskope."
|
||||
"923","https://growheads.de/llms-papes-blunts-list.txt","Entdecke hochwertige Papers & Blunts für perfekten Rauchgenuss – von klassischen Blättchen bis aromatisierten Blunt Wraps in vielen Größen und Stärken."
|
||||
"222","https://growheads.de/llms-pe-teile-list.txt","PE-Teile für Bewässerung: Absperrhähne, Kupplungen, T-Stücke & Endkappen für PE-Schläuche – ideal für Gartenbewässerung und Tropfbewässerung."
|
||||
"580","https://growheads.de/llms-perlite-blaehton-list.txt","Perlite & Blähton für Hydroponik, Hydrokultur & Gartenbau. Optimale Drainage, Belüftung und Wasserspeicherung für gesundes Wurzelwachstum."
|
||||
"921","https://growheads.de/llms-pfeifen-list.txt","Entdecken Sie Pfeifen für Aktivkohlefilter: langlebige Holzpfeifen und hochwertige Aluminium-Pfeifen mit Royal Filter Adapter für ein reines Raucherlebnis."
|
||||
"239","https://growheads.de/llms-pflanzenschutz-list.txt","Pflanzenschutz biologisch & chemiefrei: Nützlinge, Neemöl, Gelbtafeln & Raubmilben gegen Trauermücken, Thripse, Spinnmilben, Blattläuse & Weiße Fliege."
|
||||
"259","https://growheads.de/llms-pressen-list.txt","Hydraulische Rosin Pressen, Pollenpressen & Rosin Bags für professionelle, lösungsmittelfreie Extraktion und Harzpressung. Große Auswahl & Top-Marken."
|
||||
"297","https://growheads.de/llms-pumpen-list.txt","Entdecken Sie leistungsstarke Pumpen für Bewässerung, Hydroponik & Aquaristik: Tauchpumpen, Umwälzpumpen, Belüftungs- und Luftpumpen für jeden Bedarf."
|
||||
"519","https://growheads.de/llms-pumpsprueher-list.txt","Pumpsprüher & Drucksprüher für Garten, Haushalt & Industrie. Hochwertige 1–8L Sprüher für Pflanzenpflege, Reinigung und Pflanzenschutz online kaufen."
|
||||
"920","https://growheads.de/llms-raeucherstaebchen-list.txt","Entdecken Sie hochwertige Räucherstäbchen wie Goloka Nag Champa und Satya für Meditation, Ayurveda, Chakra-Harmonisierung und entspannende Duftmomente."
|
||||
"450","https://growheads.de/llms-restposten-list.txt","Günstige Restposten: stark reduzierte Markenartikel, Sonderposten und Einzelstücke für cleveres Sparen. Jetzt Restbestände kaufen und Schnäppchen sichern."
|
||||
"916","https://growheads.de/llms-rollen-bauen-list.txt","Entdecke Rolling Trays, Tin Boxen & Rolling Sets für perfektes Drehen – praktische Aufbewahrung, integriertes Zubehör & stylische Designs."
|
||||
"609","https://growheads.de/llms-schalldaempfer-list.txt","Schalldämpfer für Lüftungsanlagen: hochwertige Rohr- & Telefonieschalldämpfer zur effektiven Geräuschreduzierung in Wohnraum, Gewerbe & Technikräumen."
|
||||
"405","https://growheads.de/llms-schlaeuche-1-list.txt","Schläuche für Bewässerung & Garten: Tropfschläuche, Mikroschläuche und flexible Gartenschläuche in verschiedenen Durchmessern für präzise Wasserversorgung."
|
||||
"250","https://growheads.de/llms-schlaeuche-list.txt","Hochwertige Lüftungs- und Abluftschläuche: Aluflex-, Combi-, Phonic Trap & Sonodec für leise, effiziente Belüftung in Growroom, Werkstatt & Haus."
|
||||
"689","https://growheads.de/llms-seeds-list.txt","Entdecke hochwertige Samen: Cannabis-, Gemüse- und Kräutersamen für Indoor & Outdoor, inklusive Autoflower, CBD, Fast Version & Bio-Gartensaatgut."
|
||||
"915","https://growheads.de/llms-set-zubehoer-list.txt","Set-Zubehör für Grow & Indoor-Garten: Erde, Dünger-Starterkit, Ernteschere, Thermo-Hygrometer & WLAN Zeitschaltuhr für optimale Pflanzenpflege."
|
||||
"4","https://growheads.de/llms-sonstiges-list.txt","Sonstiges Garten- & Grow-Zubehör: LST Pflanzenbieger, Kabel, CBD-Aromaöle, Adventskalender, Schutzbrillen & Bambusstäbe günstig online kaufen."
|
||||
"354","https://growheads.de/llms-sprueher-list.txt","Sprüher & Sprühflaschen fürs Pflanzenwässern: Drucksprüher, Handsprüher, Gießstäbe & Hozelock-Spritzdüsen für Gewächshaus, Garten & Indoor-Grow."
|
||||
"706","https://growheads.de/llms-stecklinge-list.txt","Entdecke hochwertige Cannabis-Stecklinge: Top-Genetiken, feminisierte & Autoflower Sorten, hohe THC- und CBD-Werte, ideal für Indoor- & Outdoor-Grower."
|
||||
"298","https://growheads.de/llms-steinwolltrays-list.txt","Steinwolltrays & Anzuchtsysteme für Stecklinge & Samen – Grodan, Speedgrow & Joplug. Optimale Bewurzelung, Hydroponik-tauglich, pH-neutral & effizient."
|
||||
"314","https://growheads.de/llms-steuergeraete-list.txt","Steuergeräte für Indoor-Grow & Gewächshaus: Klimacontroller, Lüftersteuerungen, CO₂-Regler & Thermostate für optimales Grow-Klima online kaufen."
|
||||
"301","https://growheads.de/llms-stofftoepfe-list.txt","Stofftöpfe & Pflanzsäcke für gesundes Wurzelwachstum – atmungsaktive, nachhaltige Fabric Pots aus recyceltem Material für Indoor & Outdoor Anbau."
|
||||
"292","https://growheads.de/llms-trays-fluttische-list.txt","Trays & Fluttische für Growbox & Gewächshaus: stabile Pflanzschalen, Fluttischböden, Water Trays und Eisenracks für effiziente Bewässerung & Trocknung."
|
||||
"293","https://growheads.de/llms-trockennetze-list.txt","Trockennetze & Dry Bags für Kräuter, Blüten & Samen: platzsparend, geruchsarm & schimmelfrei trocknen – ideal für Growbox, Indoor & Balkon."
|
||||
"480","https://growheads.de/llms-tropfer-list.txt","Tropfer & Mikroschläuche für professionelle Tropfbewässerung – Zubehör, Verbinder und Systeme für Garten, Gewächshaus & Containerpflanzen."
|
||||
"214","https://growheads.de/llms-umluft-ventilatoren-list.txt","Umluft-Ventilatoren für Growbox, Growraum & Haushalt: leise Clip‑, Box‑ und Wandventilatoren mit Oszillation, EC-Motor, energieeffizient & langlebig."
|
||||
"220","https://growheads.de/llms-untersetzer-list.txt","Untersetzer & Auffangschalen für Pflanztöpfe: eckig & rund, verschiedene Größen, robust, wasserdicht – ideal für Indoor-Grow, Balkon & Zimmerpflanzen."
|
||||
"346","https://growheads.de/llms-vakuumbeutel-list.txt","Vakuumbeutel & Alu-Bügelbeutel für Lebensmittel, Fermentation & Lagerung – luftdicht, robust, BPA-frei. Passend zu allen gängigen Vakuumierern."
|
||||
"896","https://growheads.de/llms-vaporizer-list.txt","Vaporizer & E-Rigs für Kräuter & Konzentrate: Entdecke Premium-Verdampfer, Dab Tools & Zubehör von Puffco, Storz & Bickel, Wolkenkraft u.v.m."
|
||||
"374","https://growheads.de/llms-verbindungsteile-list.txt","Verbindungsteile für Lüftungsanlagen: Außen- & Innenverbinder, Reduzierstücke, Gummimuffen, Dichtbänder, T- und Y-Stücke für luftdichte Rohrverbindungen."
|
||||
"421","https://growheads.de/llms-vermehrungszubehoer-list.txt","Vermehrungszubehör für Stecklinge & Jungpflanzen: Bewurzelungsgel, Clonex Mist, Jiffy Quelltöpfe, Skalpelle & Substrate für erfolgreiche Pflanzenzucht."
|
||||
"187","https://growheads.de/llms-waagen-list.txt","Präzisionswaagen, Taschenwaagen & Paketwaagen: Entdecken Sie digitale Waagen, Juwelierwaagen und Eichgewichte für Labor, Versand, Haushalt & Hobby."
|
||||
"425","https://growheads.de/llms-wassertanks-list.txt","Wassertanks & Nährstofftanks für Bewässerung & Hydroponik – robuste Tanks, flexible Flex-Tanks, Tankdurchführungen & Zubehör für Growbox und Garten."
|
||||
"186","https://growheads.de/llms-wiegen-verpacken-list.txt","Wiegen & Verpacken: Präzisionswaagen, Vakuumbeutel, Grove Bags, Boveda, Integra Boost, Cliptütchen sowie Gläser & Dosen für sichere Lagerung."
|
||||
"693","https://growheads.de/llms-zelte-list.txt","Entdecke hochwertige Growzelte für Indoor-Growing – von kompakten Mini-Growboxen bis zu Profi-Zelten mit Mylar, PAR+ & stabilen Stahlrahmen."
|
||||
"226","https://growheads.de/llms-zeltzubehoer-list.txt","Zeltzubehör für Growbox & Growzelt: Scrog-Netze, Stütznetze, Space Booster, Stoffböden, Verbinder & Zubehör für stabile, effiziente Indoor-Grows."
|
||||
"686","https://growheads.de/llms-zubehoer-1-list.txt","Zubehör für Aktivkohlefilter, Vorfilter und Flansche: hochwertiges Lüftungs- & Filterzubehör für Prima Klima und Can Filters in Profi-Qualität."
|
||||
"741","https://growheads.de/llms-zubehoer-2-list.txt","Zubehör für Lüfter & Klima: EC-Controller, Temperaturregler, Netzstecker & Gewebeband für leisen, effizienten und sicheren Betrieb Ihrer Lüftungsanlage."
|
||||
"294","https://growheads.de/llms-zubehoer-3-list.txt","Praktisches Zubehör für Bewässerung, Hydroponik & Garten: Schläuche, Filter, Heizstäbe, Verbinder und mehr für effiziente Grow- & Bewässerungssysteme."
|
||||
"714","https://growheads.de/llms-zubehoer-list.txt","Zubehör für Bongs & Wasserpfeifen: Aktivkohle, Adapter, Filter, Köpfe, Vorkühler, Reinigungsmittel & Tools von Zenit, Smokebuddy, Black Leaf u.v.m."
|
||||
"392","https://growheads.de/llms-zuluftfilter-list.txt","Zuluftfilter für Growroom & Gewächshaus: saubere Frischluft, Schutz vor Pollen, Staub & Insekten, optimales Klima und gesundes Pflanzenwachstum."
|
||||
"308","https://growheads.de/llms-ab-und-zuluft-list.txt","Ab- und Zuluft für Growbox & Raumklima: leise EC-Lüfter, Rohrventilatoren, Iso-Boxen, Schläuche, Filter, Schalldämpfer & Zubehör für Profi-Lüftung."
|
||||
"248","https://growheads.de/llms-aktivkohlefilter-list.txt","Aktivkohlefilter für Growbox, Industrie & Lüftung: hochwertige Geruchsneutralisation, Luftreinigung und Zubehör von Can Filters, Prima Klima, Rhino u.v.m."
|
||||
"240","https://growheads.de/llms-anbauzubehoer-list.txt","Anbauzubehör für Indoor & Outdoor Grow: Kabel, Zeitschaltuhren, Pflanzentraining, Befestigung, Gewächshausheizung & mehr für Hobby- und Profigärtner."
|
||||
"286","https://growheads.de/llms-anzucht-list.txt","Anzucht-Zubehör für erfolgreiches Vorziehen: Steinwolltrays, Heizmatten, Gewächshäuser, Vermehrungszubehör sowie EazyPlug & Jiffy Substrate online kaufen."
|
||||
"247","https://growheads.de/llms-belueftung-list.txt","Belüftung für Growbox & Indoor-Grow: Umluft-Ventilatoren, Aktivkohlefilter, Ab- und Zuluft, Steuergeräte, Luftbefeuchter & Entfeuchter, Geruchsneutralisation."
|
||||
"221","https://growheads.de/llms-bewaesserung-list.txt","Bewässerungssysteme für Garten, Gewächshaus & Indoor-Grow: Pumpen, Schläuche, Tropfer, AutoPot, Blumat, Trays, Wassertanks & Zubehör günstig kaufen."
|
||||
"242","https://growheads.de/llms-boeden-list.txt","Böden & Substrate für Profi- und Hobby-Grower: Erde, Kokos, Perlite & Blähton für Indoor-Grow, Hydroponik und Gartenbau. Optimale Drainage & Nährstoffversorgung."
|
||||
"711","https://growheads.de/llms-bongs-list.txt","Bongs online kaufen: Glasbongs, Acrylbongs & Ölbongs von Black Leaf, Jelly Joker, Grace Glass, Boost, Zenit u.v.m. Für Kräuter, Öl & Dabs – große Auswahl."
|
||||
"258","https://growheads.de/llms-ernte-verarbeitung-list.txt","Ernte & Verarbeitung: Pressen, Extraktion, Erntescheren, Trockennetze & Erntemaschinen für effiziente, schonende Blüten- und Kräuterverarbeitung."
|
||||
"376","https://growheads.de/llms-grow-sets-list.txt","Entdecken Sie hochwertige Grow-Sets für Indoor-Growing: Komplettsets mit LED-Beleuchtung, Growbox, Abluftsystem & Zubehör für Anfänger und Profis."
|
||||
"709","https://growheads.de/llms-headshop-list.txt","Headshop mit Bongs, Vaporizern, Pfeifen, Dabbing‑Zubehör, Papes, Grinder, Filtern, Waagen, Rolling Trays & Räucherstäbchen – alles für dein Rauch‑Setup."
|
||||
"219","https://growheads.de/llms-toepfe-list.txt","Töpfe für Indoor- und Outdoorgrowing: Stofftöpfe, Air-Pots, Kunststofftöpfe & Untersetzer für optimales Wurzelwachstum und professionelle Pflanzenzucht."
|
||||
"695","https://growheads.de/llms-zubehoer-4-list.txt","Zubehör für Growbox & Indoor-Grow: Zeltzubehör, Anbauzubehör, Lampenzubehör, Messgeräte, Ernte & Verarbeitung sowie Dünger-Zubehör online kaufen."
|
||||
@@ -1,368 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Reports which keys from src/i18n/locales/de are referenced in application code.
|
||||
*
|
||||
* - Loads the same namespaces as src/i18n/index.js (translation bundle + legal-* bundles).
|
||||
* - Parses static t("...") / t('...') calls and maps useTranslation() aliases to namespaces.
|
||||
* - Legal text is duplicated: separate namespaces (e.g. legal-agb-delivery) AND embedded under
|
||||
* translation (legalAgbDelivery.*). When a separate-namespace key is used, the embedded
|
||||
* translation::* copy is treated as used too.
|
||||
* - Known dynamic patterns from AGB.js, Datenschutz.js, Impressum.js are expanded so those
|
||||
* keys are not falsely listed as unused.
|
||||
*
|
||||
* Usage: node scripts/check-i18n-keys.mjs
|
||||
* node scripts/check-i18n-keys.mjs --json
|
||||
*/
|
||||
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const SRC = path.join(ROOT, 'src');
|
||||
const LOCALE_DE = path.join(SRC, 'i18n', 'locales', 'de');
|
||||
|
||||
const SEPARATE_NAMESPACES = [
|
||||
'legal-agb-delivery',
|
||||
'legal-agb-payment',
|
||||
'legal-agb-consumer',
|
||||
'legal-datenschutz-basic',
|
||||
'legal-datenschutz-customer',
|
||||
'legal-datenschutz-google-orders',
|
||||
'legal-datenschutz-newsletter',
|
||||
'legal-datenschutz-chatbot',
|
||||
'legal-datenschutz-push',
|
||||
'legal-datenschutz-cookies-payment',
|
||||
'legal-datenschutz-rights',
|
||||
'legal-impressum',
|
||||
'legal-widerruf',
|
||||
'legal-batterie',
|
||||
];
|
||||
|
||||
/** Separate i18n namespace -> key prefix inside translation bundle (locales/de/index.js). */
|
||||
const NS_TO_EMBEDDED_PREFIX = {
|
||||
'legal-agb-delivery': 'legalAgbDelivery',
|
||||
'legal-agb-payment': 'legalAgbPayment',
|
||||
'legal-agb-consumer': 'legalAgbConsumer',
|
||||
'legal-datenschutz-basic': 'legalDatenschutzBasic',
|
||||
'legal-datenschutz-customer': 'legalDatenschutzCustomer',
|
||||
'legal-datenschutz-google-orders': 'legalDatenschutzGoogleOrders',
|
||||
'legal-datenschutz-newsletter': 'legalDatenschutzNewsletter',
|
||||
'legal-datenschutz-chatbot': 'legalDatenschutzChatbot',
|
||||
'legal-datenschutz-cookies-payment': 'legalDatenschutzCookiesPayment',
|
||||
'legal-datenschutz-rights': 'legalDatenschutzRights',
|
||||
};
|
||||
|
||||
/**
|
||||
* Keys reached only via t(`…${var}…`) in a few pages — expand so they count as used.
|
||||
*/
|
||||
function addKnownDynamicLegalKeys(used) {
|
||||
for (let n = 1; n <= 14; n++) {
|
||||
used.add(keySet('legal-agb-delivery', `deliveryTerms.${n}`));
|
||||
}
|
||||
for (const n of [1, 2, 3, 5, 6, 7, 8]) {
|
||||
used.add(keySet('legal-agb-consumer', `distanceSelling.sections.${n}.title`));
|
||||
used.add(keySet('legal-agb-consumer', `distanceSelling.sections.${n}.content`));
|
||||
}
|
||||
for (const section of ['informationDeletion', 'serverLogfiles']) {
|
||||
used.add(keySet('legal-datenschutz-basic', `sections.${section}.title`));
|
||||
used.add(keySet('legal-datenschutz-basic', `sections.${section}.content`));
|
||||
}
|
||||
for (const section of ['operator', 'contact', 'vatId', 'disclaimer', 'copyright']) {
|
||||
used.add(keySet('legal-impressum', `sections.${section}.title`));
|
||||
used.add(keySet('legal-impressum', `sections.${section}.content`));
|
||||
}
|
||||
}
|
||||
|
||||
/** If legal-agb-delivery::foo is used, translation::legalAgbDelivery.foo is the same strings. */
|
||||
function propagateEmbeddedCopies(used) {
|
||||
const additions = [];
|
||||
for (const entry of used) {
|
||||
const sep = entry.indexOf('::');
|
||||
if (sep === -1) continue;
|
||||
const ns = entry.slice(0, sep);
|
||||
const keyPath = entry.slice(sep + 2);
|
||||
const prefix = NS_TO_EMBEDDED_PREFIX[ns];
|
||||
if (!prefix) continue;
|
||||
additions.push(keySet('translation', `${prefix}.${keyPath}`));
|
||||
}
|
||||
for (const a of additions) used.add(a);
|
||||
}
|
||||
|
||||
function flattenLeaves(obj, prefix = '') {
|
||||
const keys = [];
|
||||
if (obj === null || obj === undefined) return keys;
|
||||
if (typeof obj !== 'object' || Array.isArray(obj)) {
|
||||
if (prefix) keys.push(prefix);
|
||||
return keys;
|
||||
}
|
||||
const entries = Object.entries(obj);
|
||||
if (entries.length === 0 && prefix) keys.push(prefix);
|
||||
for (const [k, v] of entries) {
|
||||
const next = prefix ? `${prefix}.${k}` : k;
|
||||
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
||||
keys.push(...flattenLeaves(v, next));
|
||||
} else {
|
||||
keys.push(next);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function keySet(ns, keyPath) {
|
||||
return `${ns}::${keyPath}`;
|
||||
}
|
||||
|
||||
async function loadDefinedKeys() {
|
||||
const translationMod = await import(
|
||||
pathToFileUrl(path.join(LOCALE_DE, 'index.js'))
|
||||
);
|
||||
const translation = translationMod.default;
|
||||
const defined = new Map();
|
||||
defined.set('translation', new Set(flattenLeaves(translation)));
|
||||
|
||||
for (const ns of SEPARATE_NAMESPACES) {
|
||||
const mod = await import(pathToFileUrl(path.join(LOCALE_DE, `${ns}.js`)));
|
||||
defined.set(ns, new Set(flattenLeaves(mod.default)));
|
||||
}
|
||||
|
||||
return defined;
|
||||
}
|
||||
|
||||
function pathToFileUrl(p) {
|
||||
const normalized = path.resolve(p);
|
||||
return new URL(`file://${normalized}`).href;
|
||||
}
|
||||
|
||||
async function collectSourceFiles(dir, out = []) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (full.includes(`${path.sep}i18n${path.sep}locales`)) continue;
|
||||
await collectSourceFiles(full, out);
|
||||
} else if (/\.(jsx?|tsx?)$/.test(e.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per file: map translation function alias -> i18next namespace.
|
||||
*/
|
||||
function buildAliasMap(source) {
|
||||
const map = new Map();
|
||||
|
||||
const named = /const\s*\{\s*t:\s*(\w+)\s*\}\s*=\s*useTranslation\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
let m;
|
||||
while ((m = named.exec(source))) {
|
||||
map.set(m[1], m[2]);
|
||||
}
|
||||
|
||||
const tWithNs =
|
||||
/const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
while ((m = tWithNs.exec(source))) {
|
||||
map.set('t', m[1]);
|
||||
}
|
||||
|
||||
const tDefault = /const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(\s*\)/g;
|
||||
while (tDefault.exec(source)) {
|
||||
map.set('t', 'translation');
|
||||
}
|
||||
|
||||
// withTranslation HOC: t comes from props (any position in destructuring)
|
||||
const tFromThisProps =
|
||||
/const\s*\{[^}]*\bt(?:\s*:\s*(\w+))?\b[^}]*\}\s*=\s*this\.props/;
|
||||
const mProps = tFromThisProps.exec(source);
|
||||
if (mProps) {
|
||||
map.set(mProps[1] || 't', 'translation');
|
||||
}
|
||||
const tFromProps =
|
||||
/const\s*\{[^}]*\bt(?:\s*:\s*(\w+))?\b[^}]*\}\s*=\s*props\b/;
|
||||
const mP = tFromProps.exec(source);
|
||||
if (mP) {
|
||||
map.set(mP[1] || 't', 'translation');
|
||||
}
|
||||
|
||||
if (!map.has('t')) {
|
||||
map.set('t', 'translation');
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function escapeRe(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract static keys from t-like calls for known aliases.
|
||||
*/
|
||||
function extractUsedKeysFromSource(source, filePath) {
|
||||
const aliasMap = buildAliasMap(source);
|
||||
const used = new Set();
|
||||
const dynamicHints = [];
|
||||
let m;
|
||||
|
||||
const propsT =
|
||||
/(?:this\.props\.t|props\.t)\(\s*['"]([^'"]+)['"]/g;
|
||||
while ((m = propsT.exec(source))) {
|
||||
used.add(keySet('translation', m[1]));
|
||||
}
|
||||
|
||||
for (const [alias, ns] of aliasMap) {
|
||||
const re = new RegExp(
|
||||
`\\b${escapeRe(alias)}\\(\\s*['"]([^'"]+)['"]`,
|
||||
'g'
|
||||
);
|
||||
while ((m = re.exec(source))) {
|
||||
used.add(keySet(ns, m[1]));
|
||||
}
|
||||
}
|
||||
|
||||
const tplStatic = /\b(\w+)\(\s*`([^`${}]*)`\s*\)/g;
|
||||
while ((m = tplStatic.exec(source))) {
|
||||
const alias = m[1];
|
||||
const key = m[2].trim();
|
||||
if (!key || !aliasMap.has(alias)) continue;
|
||||
if (/^[a-zA-Z][a-zA-Z0-9_.]*$/.test(key)) {
|
||||
used.add(keySet(aliasMap.get(alias), key));
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
/(?:this\.props\.t|props\.t|\bt\w*)\(\s*`[^`]*\$\{/.test(source)
|
||||
) {
|
||||
dynamicHints.push(filePath);
|
||||
}
|
||||
|
||||
return { used, dynamicHints };
|
||||
}
|
||||
|
||||
function mergeSets(into, from) {
|
||||
for (const x of from) into.add(x);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const jsonOut = process.argv.includes('--json');
|
||||
|
||||
const defined = await loadDefinedKeys();
|
||||
const files = await collectSourceFiles(SRC);
|
||||
|
||||
const allUsed = new Set();
|
||||
const dynamicFiles = new Set();
|
||||
|
||||
for (const file of files) {
|
||||
const source = await readFile(file, 'utf8');
|
||||
const { used, dynamicHints } = extractUsedKeysFromSource(source, file);
|
||||
mergeSets(allUsed, used);
|
||||
if (dynamicHints.length) dynamicFiles.add(file);
|
||||
}
|
||||
|
||||
addKnownDynamicLegalKeys(allUsed);
|
||||
propagateEmbeddedCopies(allUsed);
|
||||
|
||||
const unusedByNs = new Map();
|
||||
let totalDefined = 0;
|
||||
let totalUnused = 0;
|
||||
|
||||
for (const [ns, keys] of defined) {
|
||||
const unused = [];
|
||||
for (const k of keys) {
|
||||
totalDefined++;
|
||||
const full = keySet(ns, k);
|
||||
if (!allUsed.has(full)) {
|
||||
unused.push(k);
|
||||
totalUnused++;
|
||||
}
|
||||
}
|
||||
if (unused.length) unusedByNs.set(ns, unused.sort());
|
||||
}
|
||||
|
||||
const orphans = new Set();
|
||||
for (const u of allUsed) {
|
||||
const [ns, keyPath] = u.split('::');
|
||||
const set = defined.get(ns);
|
||||
if (!set || !set.has(keyPath)) {
|
||||
orphans.add(u);
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonOut) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
totalDefined,
|
||||
totalUsed: allUsed.size,
|
||||
totalUnused,
|
||||
unusedByNamespace: Object.fromEntries(unusedByNs),
|
||||
usedButNotDefined: [...orphans].sort(),
|
||||
filesWithLikelyDynamicT: [...dynamicFiles].map((f) =>
|
||||
path.relative(ROOT, f)
|
||||
),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('i18n key usage (locale: de)\n');
|
||||
console.log(
|
||||
'Legal strings exist twice: separate namespaces (AGB, Datenschutz, …) and embedded copies'
|
||||
);
|
||||
console.log(
|
||||
'under translation (legalAgbDelivery.*, …). The latter are marked used when the former are.\n'
|
||||
);
|
||||
console.log(`Defined keys (all namespaces): ${totalDefined}`);
|
||||
console.log(`References after static scan + known dynamic legal patterns: ${allUsed.size}`);
|
||||
console.log(`Unused (best-effort): ${totalUnused}`);
|
||||
console.log(`Used but not in locale files: ${orphans.size}\n`);
|
||||
|
||||
const stillDynamic = [...dynamicFiles].filter(
|
||||
(f) =>
|
||||
![
|
||||
'pages/AGB.js',
|
||||
'pages/Datenschutz.js',
|
||||
'pages/Impressum.js',
|
||||
].includes(path.relative(SRC, f).replace(/\\/g, '/'))
|
||||
);
|
||||
if (stillDynamic.length) {
|
||||
console.log(
|
||||
'Other files with t(`…${…}…`) (may still hide unused keys — not expanded):'
|
||||
);
|
||||
for (const f of stillDynamic.sort()) {
|
||||
console.log(` ${path.relative(ROOT, f)}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
for (const [ns, keys] of [...unusedByNs.entries()].sort((a, b) =>
|
||||
a[0].localeCompare(b[0])
|
||||
)) {
|
||||
console.log(`--- ${ns} (${keys.length} unused) ---`);
|
||||
for (const k of keys) {
|
||||
console.log(` ${k}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (orphans.size) {
|
||||
console.log('--- Referenced in code but missing from de locale ---');
|
||||
for (const o of [...orphans].sort()) {
|
||||
console.log(` ${o.replace('::', ' / ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\nNote: Keys built from arbitrary variables or unknown dynamic templates may still look unused.'
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
const sharp = require('sharp');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const imagesToConvert = [
|
||||
{ src: 'sh.png', dest: 'sh.avif' },
|
||||
{ src: 'seeds.jpg', dest: 'seeds.avif' },
|
||||
{ src: 'cutlings.jpg', dest: 'cutlings.avif' },
|
||||
{ src: 'gg.png', dest: 'gg.avif' },
|
||||
{ src: 'konfigurator.png', dest: 'konfigurator.avif' },
|
||||
{ src: 'maps.png', dest: 'maps.avif' }
|
||||
];
|
||||
|
||||
const run = async () => {
|
||||
const imagesDir = path.join(__dirname, '../public/assets/images');
|
||||
let hasError = false;
|
||||
|
||||
for (const image of imagesToConvert) {
|
||||
const inputPath = path.join(imagesDir, image.src);
|
||||
const outputPath = path.join(imagesDir, image.dest);
|
||||
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
console.warn(`⚠️ Input file not found: ${inputPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if output file exists and compare modification times
|
||||
// Only convert if source is newer or destination doesn't exist
|
||||
let shouldConvert = true;
|
||||
if (fs.existsSync(outputPath)) {
|
||||
const inputStat = fs.statSync(inputPath);
|
||||
const outputStat = fs.statSync(outputPath);
|
||||
if (inputStat.mtime <= outputStat.mtime) {
|
||||
shouldConvert = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldConvert) {
|
||||
try {
|
||||
await sharp(inputPath)
|
||||
.toFormat('avif')
|
||||
.toFile(outputPath);
|
||||
console.log(`✅ Converted ${image.src} to ${image.dest}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error converting ${image.src}:`, error.message);
|
||||
hasError = true;
|
||||
}
|
||||
} else {
|
||||
// Silent skip if already up to date to keep logs clean, or use verbose flag
|
||||
// console.log(`Skipping ${image.src} (already up to date)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
@@ -1,26 +0,0 @@
|
||||
const sharp = require('sharp');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const run = async () => {
|
||||
const inputPath = path.join(__dirname, '../public/assets/images/sh.png');
|
||||
const outputPath = path.join(__dirname, '../public/assets/images/sh.avif');
|
||||
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
console.error('Input file not found:', inputPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await sharp(inputPath)
|
||||
.toFormat('avif')
|
||||
.toFile(outputPath);
|
||||
console.log(`Successfully converted ${inputPath} to ${outputPath}`);
|
||||
} catch (error) {
|
||||
console.error('Error converting image:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
|
||||
@@ -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;
|
||||
402
src/App.js
@@ -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,33 +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 { ProductContextProvider } from "./context/ProductContext.js";
|
||||
import { CategoryContextProvider } from "./context/CategoryContext.js";
|
||||
import TitleUpdater from "./components/TitleUpdater.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 IdleMainPagesSlideshow from "./components/IdleMainPagesSlideshow.js";
|
||||
import Home from "./pages/Home.js";
|
||||
|
||||
import Content from "./components/Content.js";
|
||||
import ProductDetail from "./components/ProductDetail.js";
|
||||
|
||||
// Lazy load rarely-accessed pages
|
||||
const ProfilePage = lazy(() => import(/* webpackChunkName: "profile" */ "./pages/ProfilePage.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"));
|
||||
const LinkTelegramPage = lazy(() => import(/* webpackChunkName: "link-telegram" */ "./pages/LinkTelegramPage.js"));
|
||||
|
||||
// Lazy load admin pages - only loaded when admin users access them
|
||||
const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js"));
|
||||
@@ -51,9 +40,8 @@ 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 CategoriesPage = lazy(() => import(/* webpackChunkName: "categories" */ "./pages/CategoriesPage.js"));
|
||||
const Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
|
||||
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
|
||||
const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js"));
|
||||
@@ -62,52 +50,53 @@ 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.pathname !== "/profile") {
|
||||
navigate(`/profile${location.hash}`, { replace: true });
|
||||
}
|
||||
if (location.hash && location.hash.startsWith("#ORD-")) {
|
||||
if (location.pathname !== "/profile") {
|
||||
navigate(`/profile${location.hash}`, { replace: true });
|
||||
}
|
||||
}
|
||||
}, [location, navigate]);
|
||||
@@ -122,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();
|
||||
@@ -184,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={{
|
||||
@@ -229,118 +179,85 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
|
||||
bgcolor: "background.default",
|
||||
}}
|
||||
>
|
||||
<TitleUpdater />
|
||||
<ScrollToTop />
|
||||
<TelemetryInitializer socket={socket} />
|
||||
<Header active categoryId={categoryId} key={authVersion} />
|
||||
<Box component="main" sx={{ flexGrow: 1 }}>
|
||||
<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",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
>
|
||||
<CircularProgress color="primary" />
|
||||
</Box>
|
||||
)
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
>
|
||||
<CircularProgress color="primary" />
|
||||
</Box>
|
||||
}>
|
||||
<CarouselProvider>
|
||||
<IdleMainPagesSlideshow />
|
||||
<Routes>
|
||||
{/* Main pages using unified component */}
|
||||
<Route path="/" element={<MainPageLayout />} />
|
||||
<Route path="/aktionen" element={<MainPageLayout />} />
|
||||
<Route path="/filiale" element={<MainPageLayout />} />
|
||||
<Routes>
|
||||
{/* Home page with text only */}
|
||||
<Route path="/" element={<Home />} />
|
||||
|
||||
{/* Category page - Render Content in parallel */}
|
||||
<Route
|
||||
path="/Kategorie/:categoryId"
|
||||
element={<Content />}
|
||||
/>
|
||||
{/* Single product page */}
|
||||
<Route
|
||||
path="/Artikel/:seoName"
|
||||
element={<ProductDetail />}
|
||||
/>
|
||||
{/* Category page - Render Content in parallel */}
|
||||
<Route
|
||||
path="/Kategorie/:categoryId"
|
||||
element={<Content socket={socket} socketB={socketB} />}
|
||||
/>
|
||||
{/* Single product page */}
|
||||
<Route
|
||||
path="/Artikel/:seoName"
|
||||
element={<ProductDetailWithSocket />}
|
||||
/>
|
||||
|
||||
{/* Search page - Render Content in parallel */}
|
||||
<Route path="/search" element={<Content />} />
|
||||
{/* Search page - Render Content in parallel */}
|
||||
<Route path="/search" element={<Content socket={socket} socketB={socketB} />} />
|
||||
|
||||
{/* Profile page */}
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
{/* Link Telegram id (expects ?id=... or /linkTelegram/:id) */}
|
||||
<Route path="/linkTelegram" element={<LinkTelegramPage />} />
|
||||
<Route path="/linkTelegram/:id" element={<LinkTelegramPage />} />
|
||||
{/* Profile page */}
|
||||
<Route path="/profile" element={<ProfilePageWithSocket />} />
|
||||
|
||||
{/* Payment success page for Mollie redirects */}
|
||||
<Route path="/payment/success" element={<PaymentSuccess />} />
|
||||
{/* Reset password page */}
|
||||
<Route
|
||||
path="/resetPassword"
|
||||
element={<ResetPassword socket={socket} socketB={socketB} />}
|
||||
/>
|
||||
|
||||
{/* Reset password page */}
|
||||
<Route
|
||||
path="/resetPassword"
|
||||
element={<ResetPassword />}
|
||||
/>
|
||||
{/* Admin page */}
|
||||
<Route path="/admin" element={<AdminPage socket={socket} socketB={socketB} />} />
|
||||
|
||||
{/* Admin Users page */}
|
||||
<Route path="/admin/users" element={<UsersPage socket={socket} socketB={socketB} />} />
|
||||
|
||||
{/* Admin Server Logs page */}
|
||||
<Route path="/admin/logs" element={<ServerLogsPage socket={socket} socketB={socketB} />} />
|
||||
|
||||
{/* Admin page */}
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
{/* 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
|
||||
path="/batteriegesetzhinweise"
|
||||
element={<Batteriegesetzhinweise />}
|
||||
/>
|
||||
<Route path="/widerrufsrecht" element={<Widerrufsrecht />} />
|
||||
|
||||
{/* Admin Users page */}
|
||||
<Route path="/admin/users" element={<UsersPage />} />
|
||||
{/* Grow Tent Configurator */}
|
||||
<Route path="/Konfigurator" element={<GrowTentKonfigurator />} />
|
||||
|
||||
{/* Admin Server Logs page */}
|
||||
<Route path="/admin/logs" element={<ServerLogsPage />} />
|
||||
|
||||
{/* Legal pages */}
|
||||
<Route path="/datenschutz" element={<Datenschutz />} />
|
||||
<Route path="/agb" element={<AGB />} />
|
||||
<Route path="/sitemap" element={<Sitemap />} />
|
||||
<Route path="/Kategorien" element={<CategoriesPage />} />
|
||||
<Route path="/impressum" element={<Impressum />} />
|
||||
<Route
|
||||
path="/batteriegesetzhinweise"
|
||||
element={<Batteriegesetzhinweise />}
|
||||
/>
|
||||
<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 />} />
|
||||
|
||||
{/* Fallback for undefined routes */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</CarouselProvider>
|
||||
{/* Fallback for undefined routes */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</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>
|
||||
)}
|
||||
@@ -362,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"
|
||||
@@ -377,7 +294,7 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
|
||||
>
|
||||
<BugReportIcon sx={{ fontSize: "1.2rem" }} />
|
||||
</Fab>
|
||||
</Tooltip>*/}
|
||||
</Tooltip>
|
||||
|
||||
{/* Development-only Theme Customizer FAB */}
|
||||
{isDevelopment && (
|
||||
@@ -398,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)}
|
||||
@@ -455,26 +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}>
|
||||
<ProductContextProvider>
|
||||
<CategoryContextProvider>
|
||||
<CssBaseline />
|
||||
<AppContent
|
||||
currentTheme={currentTheme}
|
||||
dynamicTheme={dynamicTheme}
|
||||
onThemeChange={handleThemeChange}
|
||||
/>
|
||||
</CategoryContextProvider>
|
||||
</ProductContextProvider>
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
<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}
|
||||
onThemeChange={handleThemeChange}
|
||||
/>
|
||||
</SocketProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -44,12 +43,10 @@ const PrerenderAppContent = (socket) => (
|
||||
<CategoryList categoryId={209} activeCategoryId={null} socket={socket}/>
|
||||
</AppBar>
|
||||
|
||||
<Box component="main" sx={{ flexGrow: 1 }}>
|
||||
<CarouselProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<MainPageLayout />} />
|
||||
</Routes>
|
||||
</CarouselProvider>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
|
||||
<Footer/>
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import LegalPage from './pages/LegalPage.js';
|
||||
import CategoryBox from './components/CategoryBox.js';
|
||||
|
||||
const PrerenderCategoriesPage = ({ categoryData }) => {
|
||||
// Helper function to recursively collect all categories from the tree
|
||||
const collectAllCategories = (categoryNode, categories = [], level = 0) => {
|
||||
if (!categoryNode) return categories;
|
||||
|
||||
// Add current category (skip root category 209)
|
||||
if (categoryNode.id !== 209 && categoryNode.seoName) {
|
||||
categories.push({
|
||||
id: categoryNode.id,
|
||||
name: categoryNode.name,
|
||||
seoName: categoryNode.seoName,
|
||||
level: level
|
||||
});
|
||||
}
|
||||
|
||||
// Recursively add children
|
||||
if (categoryNode.children) {
|
||||
for (const child of categoryNode.children) {
|
||||
collectAllCategories(child, categories, level + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return categories;
|
||||
};
|
||||
|
||||
// The categoryData passed prop is the root tree (id: 209)
|
||||
const rootTree = categoryData;
|
||||
|
||||
const renderLevel1Section = (l1Node) => {
|
||||
// Collect all descendants (excluding the L1 node itself, which collectAllCategories would include first)
|
||||
const descendants = collectAllCategories(l1Node).slice(1);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
key={l1Node.id}
|
||||
elevation={1}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
alignItems: { xs: 'flex-start', md: 'flex-start' },
|
||||
gap: 3
|
||||
}}
|
||||
>
|
||||
{/* Level 1 Header/Box */}
|
||||
<Box sx={{
|
||||
minWidth: '150px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1
|
||||
}}>
|
||||
<CategoryBox
|
||||
id={l1Node.id}
|
||||
name={l1Node.name}
|
||||
seoName={l1Node.seoName}
|
||||
sx={{
|
||||
boxShadow: 4,
|
||||
width: '150px',
|
||||
height: '150px'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Descendants area */}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2
|
||||
}}>
|
||||
{descendants.map((cat) => (
|
||||
<CategoryBox
|
||||
key={cat.id}
|
||||
id={cat.id}
|
||||
name={cat.name}
|
||||
seoName={cat.seoName}
|
||||
sx={{
|
||||
width: '100px',
|
||||
height: '100px',
|
||||
minWidth: '100px',
|
||||
minHeight: '100px',
|
||||
boxShadow: 1,
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<Box>
|
||||
<Box>
|
||||
{rootTree && rootTree.children && rootTree.children.map((child) => (
|
||||
renderLevel1Section(child)
|
||||
))}
|
||||
{(!rootTree || !rootTree.children || rootTree.children.length === 0) && (
|
||||
<Typography>Keine Kategorien gefunden.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return <LegalPage title="Kategorien" content={content} />;
|
||||
};
|
||||
|
||||
export default PrerenderCategoriesPage;
|
||||
@@ -3,7 +3,7 @@ import { Box, AppBar, Toolbar, Container, Typography, Grid, Card, CardMedia, Car
|
||||
import Footer from './components/Footer.js';
|
||||
import { Logo, SearchBar, CategoryList } from './components/header/index.js';
|
||||
|
||||
const PrerenderCategory = ({ categoryId, categoryName, categorySeoName: _categorySeoName, productData }) => {
|
||||
const PrerenderCategory = ({ categoryId, categoryName, categorySeoName, productData }) => {
|
||||
const products = productData?.products || [];
|
||||
|
||||
return (
|
||||
@@ -111,7 +111,7 @@ const PrerenderCategory = ({ categoryId, categoryName, categorySeoName: _categor
|
||||
component="img"
|
||||
height="200"
|
||||
image={product.pictureList && product.pictureList.trim()
|
||||
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.avif`
|
||||
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.jpg`
|
||||
: '/assets/images/nopicture.jpg'
|
||||
}
|
||||
alt={product.name}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Container
|
||||
} from '@mui/material';
|
||||
import Footer from './components/Footer.js';
|
||||
import { Logo, CategoryList } from './components/header/index.js';
|
||||
|
||||
const React = require('react');
|
||||
const {
|
||||
Box,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Container
|
||||
} = require('@mui/material');
|
||||
const Footer = require('./components/Footer.js').default;
|
||||
const { Logo, CategoryList } = require('./components/header/index.js');
|
||||
const Home = require('./pages/Home.js').default;
|
||||
|
||||
class PrerenderHome extends React.Component {
|
||||
render() {
|
||||
@@ -28,103 +28,45 @@ 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,
|
||||
{
|
||||
sx: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
{
|
||||
sx: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
flexDirection: { xs: 'column', sm: 'row' }
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
{
|
||||
sx: {
|
||||
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 };
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -1,25 +1,18 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
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,
|
||||
@@ -63,508 +48,149 @@ class PrerenderProduct extends React.Component {
|
||||
bgcolor: 'background.default'
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
React.createElement(
|
||||
AppBar,
|
||||
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
|
||||
React.createElement(
|
||||
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 } },
|
||||
Container,
|
||||
{ maxWidth: 'lg', sx: { py: 4, flexGrow: 1 } },
|
||||
React.createElement(
|
||||
Container,
|
||||
{
|
||||
maxWidth: "lg",
|
||||
sx: {
|
||||
p: { xs: 2, md: 2 },
|
||||
pb: { xs: 4, md: 8 },
|
||||
flexGrow: 1
|
||||
}
|
||||
},
|
||||
// Back button (breadcrumbs section)
|
||||
Grid,
|
||||
{ container: true, spacing: 4 },
|
||||
// Product Image
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
mb: 2,
|
||||
position: ["-webkit-sticky", "sticky"],
|
||||
// No CategoryList in prerender — two-row toolbar only; safe-area for notched phones.
|
||||
top: {
|
||||
xs: "calc(env(safe-area-inset-top, 0px) + 128px)",
|
||||
sm: "80px",
|
||||
},
|
||||
left: 0,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
zIndex: (theme) => theme.zIndex.appBar - 1,
|
||||
py: 0,
|
||||
px: 2,
|
||||
}
|
||||
},
|
||||
Grid,
|
||||
{ item: true, xs: 12, md: 6 },
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
ml: { xs: 0, md: 0 },
|
||||
display: "inline-flex",
|
||||
px: 0,
|
||||
py: 1,
|
||||
backgroundColor: "#2e7d32", // primary dark green
|
||||
borderRadius: 1,
|
||||
}
|
||||
},
|
||||
Card,
|
||||
{ sx: { height: '100%' } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: "body2", color: "text.secondary" },
|
||||
React.createElement(
|
||||
'a',
|
||||
{
|
||||
href: "#",
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
},
|
||||
style: {
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
textDecoration: "none",
|
||||
color: "#fff",
|
||||
fontWeight: "bold",
|
||||
cursor: "pointer"
|
||||
}
|
||||
},
|
||||
this.props.t ? this.props.t('common.back') : 'Zurück'
|
||||
)
|
||||
CardMedia,
|
||||
{
|
||||
component: 'img',
|
||||
height: '400',
|
||||
image: mainImage,
|
||||
alt: product.name,
|
||||
sx: { objectFit: 'contain', p: 2 }
|
||||
}
|
||||
)
|
||||
)
|
||||
),
|
||||
// Product Details
|
||||
React.createElement(
|
||||
Grid,
|
||||
{ item: true, xs: 12, md: 6 },
|
||||
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
|
||||
Stack,
|
||||
{ spacing: 3 },
|
||||
React.createElement(
|
||||
ProductImage,
|
||||
{
|
||||
product: product,
|
||||
socket: null,
|
||||
socketB: null,
|
||||
fullscreenOpen: false,
|
||||
onOpenFullscreen: null,
|
||||
onCloseFullscreen: null
|
||||
}
|
||||
Typography,
|
||||
{ variant: 'h3', component: 'h1', gutterBottom: true },
|
||||
product.name
|
||||
),
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h6', color: 'text.secondary' },
|
||||
'Artikelnummer: '+product.articleNumber+' '+(product.gtin ? ` | GTIN: ${product.gtin}` : "")
|
||||
),
|
||||
// Product Details Section
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
flex: "1 1 60%",
|
||||
p: { xs: 2, md: 4 },
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}
|
||||
},
|
||||
// Product identifiers
|
||||
{ sx: { mt: 1 } },
|
||||
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}` : "")
|
||||
)
|
||||
Typography,
|
||||
{ variant: 'h4', color: 'primary', fontWeight: 'bold' },
|
||||
new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(product.price)
|
||||
),
|
||||
product.vat && React.createElement(
|
||||
Typography,
|
||||
{ variant: 'body2', color: 'text.secondary' },
|
||||
`inkl. ${product.vat}% MwSt.`
|
||||
),
|
||||
// Product title
|
||||
React.createElement(
|
||||
Typography,
|
||||
{
|
||||
variant: 'h4',
|
||||
component: 'h1',
|
||||
gutterBottom: true,
|
||||
sx: {
|
||||
fontWeight: 600,
|
||||
color: "#333"
|
||||
}
|
||||
variant: 'body1',
|
||||
color: product.available ? 'success.main' : 'error.main',
|
||||
fontWeight: 'medium',
|
||||
sx: { mt: 1 }
|
||||
},
|
||||
cleanProductName(product.name)
|
||||
),
|
||||
// Manufacturer if available - exact match to SPA: only render Box if manufacturer exists
|
||||
product.manufacturer && React.createElement(
|
||||
Box,
|
||||
{ sx: { display: "flex", alignItems: "center", mb: 2 } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'body2', sx: { fontStyle: "italic" } },
|
||||
(this.props.t ? this.props.t('product.manufacturer') : 'Hersteller')+': '+product.manufacturer
|
||||
)
|
||||
),
|
||||
// Attribute images and chips with action buttons section - exact replica of SPA version
|
||||
// SPA condition: (attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert]))
|
||||
// This essentially means "if there are any attributes at all"
|
||||
// For products with no attributes (like Vakuumbeutel), this section should NOT render
|
||||
(attributes.length > 0) && React.createElement(
|
||||
Box,
|
||||
{ sx: { display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2, gap: 2 } },
|
||||
// Left side - attributes
|
||||
React.createElement(
|
||||
Stack,
|
||||
{ direction: 'row', spacing: 2, sx: { flexWrap: "wrap", gap: 1, flex: 1 } },
|
||||
// In prerender: attributes.filter(attribute => attributeImages[attribute.kMerkmalWert]) = [] (empty)
|
||||
// Then: attributes.filter(attribute => !attributeImages[attribute.kMerkmalWert]) = all attributes as Chips
|
||||
...attributes.map((attribute, index) =>
|
||||
React.createElement(
|
||||
Chip,
|
||||
{
|
||||
key: attribute.kMerkmalWert || index,
|
||||
label: attribute.cWert,
|
||||
disabled: true,
|
||||
sx: { mb: 1 }
|
||||
}
|
||||
)
|
||||
)
|
||||
),
|
||||
// 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
|
||||
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 } },
|
||||
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
|
||||
product.available ? '✅ Verfügbar' : '❌ Nicht verfügbar'
|
||||
)
|
||||
),
|
||||
product.description && React.createElement(
|
||||
Box,
|
||||
{ sx: { mt: 2 } },
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
mt: "auto",
|
||||
transform: "translateY(-1px)", // Move 1px up
|
||||
p: 3,
|
||||
background: "#f9f9f9",
|
||||
borderRadius: 2,
|
||||
Typography,
|
||||
{ variant: 'h6', gutterBottom: true },
|
||||
'Beschreibung'
|
||||
),
|
||||
React.createElement(
|
||||
'div',
|
||||
{
|
||||
dangerouslySetInnerHTML: { __html: product.description },
|
||||
style: {
|
||||
fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.5',
|
||||
color: '#33691E'
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
justifyContent: "space-between",
|
||||
alignItems: { xs: "flex-start", sm: "flex-start" },
|
||||
gap: 2,
|
||||
}
|
||||
)
|
||||
),
|
||||
// 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'
|
||||
}
|
||||
},
|
||||
// Left side - Price information (exact match to SPA)
|
||||
React.createElement(
|
||||
Box,
|
||||
null,
|
||||
React.createElement(
|
||||
Typography,
|
||||
{
|
||||
variant: "h4",
|
||||
color: "primary",
|
||||
sx: { fontWeight: "bold" }
|
||||
},
|
||||
priceWithTax
|
||||
),
|
||||
// VAT info (exact match to SPA - direct Typography, no wrapper)
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'body2', color: 'text.secondary' },
|
||||
(this.props.t ? this.props.t('product.inclVat', { vat: product.vat }) : `inkl. ${product.vat}% MwSt.`) +
|
||||
(product.cGrundEinheit && product.fGrundPreis ?
|
||||
`; ${new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(product.fGrundPreis)}/${product.cGrundEinheit}` :
|
||||
"")
|
||||
),
|
||||
// Shipping class (exact match to SPA - direct Typography, conditional render)
|
||||
product.versandklasse &&
|
||||
product.versandklasse != "standard" &&
|
||||
product.versandklasse != "kostenlos" && React.createElement(
|
||||
Typography,
|
||||
{ variant: 'body2', color: 'text.secondary' },
|
||||
product.versandklasse
|
||||
)
|
||||
),
|
||||
// Right side - Complex cart button area structure (matching SPA exactly)
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
gap: 2,
|
||||
alignItems: "flex-start",
|
||||
}
|
||||
},
|
||||
// Empty steckling column placeholder - maintains flex positioning
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { display: "flex", flexDirection: "column" } }
|
||||
// Empty - no steckling for this product
|
||||
),
|
||||
// Main cart button column (exact match to SPA structure)
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}
|
||||
},
|
||||
// AddToCartButton placeholder - invisible button that reserves exact space
|
||||
React.createElement(
|
||||
Button,
|
||||
{
|
||||
variant: "contained",
|
||||
size: "large",
|
||||
sx: {
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none",
|
||||
height: "36px",
|
||||
width: "140px",
|
||||
minWidth: "140px",
|
||||
maxWidth: "140px"
|
||||
}
|
||||
},
|
||||
"In den Warenkorb"
|
||||
),
|
||||
// Delivery time Typography (exact match to SPA)
|
||||
React.createElement(
|
||||
Typography,
|
||||
{
|
||||
variant: 'caption',
|
||||
sx: {
|
||||
fontStyle: "italic",
|
||||
color: "text.secondary",
|
||||
textAlign: "center",
|
||||
mt: 1
|
||||
}
|
||||
},
|
||||
product.id && product.id.toString().endsWith("steckling") ?
|
||||
(this.props.t ? this.props.t('delivery.times.cutting14Days') : "Lieferzeit: 14 Tage") :
|
||||
product.available == 1 ?
|
||||
(this.props.t ? this.props.t('delivery.times.standard2to3Days') : "Lieferzeit: 2-3 Tage") :
|
||||
product.availableSupplier == 1 ?
|
||||
(this.props.t ? this.props.t('delivery.times.supplier7to9Days') : "Lieferzeit: 7-9 Tage") :
|
||||
""
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
// Product full description - separate card
|
||||
product.description && React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
mt: 4,
|
||||
p: 4,
|
||||
background: "#fff",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
mt: 2,
|
||||
lineHeight: 1.7,
|
||||
"& p": { mt: 0, mb: 2 },
|
||||
"& strong": { fontWeight: 600 },
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
'div',
|
||||
{
|
||||
dangerouslySetInnerHTML: {
|
||||
__html: sanitizeHtml(product.description, {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
|
||||
allowedAttributes: {
|
||||
'*': ['class', 'style'],
|
||||
'a': ['href', 'title'],
|
||||
'img': ['src', 'alt', 'width', 'height']
|
||||
},
|
||||
disallowedTagsMode: 'discard'
|
||||
})
|
||||
},
|
||||
style: {
|
||||
fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.7',
|
||||
color: '#333'
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
React.createElement(Footer)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PrerenderProduct;
|
||||
module.exports = { default: PrerenderProduct };
|
||||
@@ -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 };
|
||||
@@ -10,23 +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 NotificationsIcon from "@mui/icons-material/Notifications";
|
||||
import NotificationsActiveIcon from "@mui/icons-material/NotificationsActive";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { withI18n } from "../i18n/withTranslation.js";
|
||||
import {
|
||||
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
|
||||
emitPushSubscriptionsChanged,
|
||||
isPushApiSupported,
|
||||
fetchPushConfiguration,
|
||||
registerPushServiceWorker,
|
||||
ensurePushSubscription,
|
||||
articlePushStatus,
|
||||
articlePushSubscribe,
|
||||
articlePushUnsubscribe,
|
||||
parseSubscribedStatus,
|
||||
parseSuccess,
|
||||
} from "../utils/articlePush.js";
|
||||
|
||||
if (!Array.isArray(window.cart)) window.cart = [];
|
||||
|
||||
@@ -40,136 +23,9 @@ class AddToCartButton extends Component {
|
||||
: 0,
|
||||
isEditing: false,
|
||||
editValue: "",
|
||||
pushInteractive: false,
|
||||
pushSubscribed: false,
|
||||
pushBusy: false,
|
||||
pushError: null,
|
||||
};
|
||||
}
|
||||
|
||||
kArtikelNumber = () => {
|
||||
const { id } = this.props;
|
||||
const n = typeof id === "number" ? id : parseInt(id, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
};
|
||||
|
||||
refreshIncomingPushStatus = async () => {
|
||||
const { available, incoming } = this.props;
|
||||
if (available || !incoming) {
|
||||
this.setState({
|
||||
pushInteractive: false,
|
||||
pushSubscribed: false,
|
||||
pushError: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const kArtikel = this.kArtikelNumber();
|
||||
if (!kArtikel || !isPushApiSupported()) {
|
||||
this.setState({ pushInteractive: false });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cfg = await fetchPushConfiguration();
|
||||
if (!cfg.configured || !cfg.publicKey) {
|
||||
this.setState({ pushInteractive: false });
|
||||
return;
|
||||
}
|
||||
await registerPushServiceWorker();
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) {
|
||||
this.setState({
|
||||
pushInteractive: true,
|
||||
pushSubscribed: false,
|
||||
pushError: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const statusData = await articlePushStatus(kArtikel, subscription.endpoint);
|
||||
this.setState({
|
||||
pushInteractive: true,
|
||||
pushSubscribed: parseSubscribedStatus(statusData),
|
||||
pushError: null,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("AddToCartButton: incoming push init failed", e);
|
||||
this.setState({ pushInteractive: false });
|
||||
}
|
||||
};
|
||||
|
||||
handleIncomingPushClick = async () => {
|
||||
if (!this.state.pushInteractive || this.state.pushBusy) return;
|
||||
const kArtikel = this.kArtikelNumber();
|
||||
if (!kArtikel) return;
|
||||
const t = this.props.t;
|
||||
this.setState({ pushBusy: true, pushError: null });
|
||||
try {
|
||||
if (this.state.pushSubscribed) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) {
|
||||
this.setState({ pushSubscribed: false, pushBusy: false });
|
||||
return;
|
||||
}
|
||||
const res = await articlePushUnsubscribe(subscription.endpoint, kArtikel);
|
||||
if (parseSuccess(res)) {
|
||||
this.setState({ pushSubscribed: false });
|
||||
emitPushSubscriptionsChanged();
|
||||
} else {
|
||||
this.setState({
|
||||
pushError:
|
||||
res?.message ||
|
||||
res?.error ||
|
||||
(t ? t("productDialogs.pushNotifyError") : ""),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const perm = await Notification.requestPermission();
|
||||
if (perm !== "granted") {
|
||||
this.setState({
|
||||
pushError: t
|
||||
? t("productDialogs.pushNotifyPermissionDenied")
|
||||
: "",
|
||||
pushBusy: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const cfg = await fetchPushConfiguration();
|
||||
if (!cfg.configured || !cfg.publicKey) {
|
||||
this.setState({
|
||||
pushError: t
|
||||
? t("productDialogs.pushNotifyServerDisabled")
|
||||
: "",
|
||||
pushBusy: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await registerPushServiceWorker();
|
||||
const subscription = await ensurePushSubscription(cfg.publicKey);
|
||||
const res = await articlePushSubscribe(kArtikel, subscription);
|
||||
if (parseSuccess(res)) {
|
||||
this.setState({ pushSubscribed: true });
|
||||
emitPushSubscriptionsChanged();
|
||||
} else {
|
||||
this.setState({
|
||||
pushError:
|
||||
res?.message ||
|
||||
res?.error ||
|
||||
(t ? t("productDialogs.pushNotifyError") : ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("AddToCartButton: incoming push", e);
|
||||
this.setState({
|
||||
pushError:
|
||||
e.message || (t ? t("productDialogs.pushNotifyError") : ""),
|
||||
});
|
||||
} finally {
|
||||
this.setState({ pushBusy: false });
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.cart = () => {
|
||||
if (!Array.isArray(window.cart)) window.cart = [];
|
||||
@@ -178,33 +34,11 @@ class AddToCartButton extends Component {
|
||||
if (this.state.quantity !== newQuantity)
|
||||
this.setState({ quantity: newQuantity });
|
||||
};
|
||||
this.onPushSubscriptionsChanged = () => {
|
||||
this.refreshIncomingPushStatus();
|
||||
};
|
||||
window.addEventListener("cart", this.cart);
|
||||
window.addEventListener(
|
||||
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
|
||||
this.onPushSubscriptionsChanged
|
||||
);
|
||||
this.refreshIncomingPushStatus();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
prevProps.available !== this.props.available ||
|
||||
prevProps.incoming !== this.props.incoming ||
|
||||
prevProps.id !== this.props.id
|
||||
) {
|
||||
this.refreshIncomingPushStatus();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("cart", this.cart);
|
||||
window.removeEventListener(
|
||||
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
|
||||
this.onPushSubscriptionsChanged
|
||||
);
|
||||
}
|
||||
|
||||
handleIncrement = () => {
|
||||
@@ -217,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 {
|
||||
@@ -298,80 +129,37 @@ class AddToCartButton extends Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
quantity,
|
||||
isEditing,
|
||||
editValue,
|
||||
pushInteractive,
|
||||
pushSubscribed,
|
||||
pushBusy,
|
||||
pushError,
|
||||
} = this.state;
|
||||
const { quantity, isEditing, editValue } = this.state;
|
||||
const { available, size, incoming, availableSupplier } = this.props;
|
||||
|
||||
// Button is disabled if product is not available
|
||||
if (!available) {
|
||||
if (incoming) {
|
||||
const dateStr = new Date(incoming).toLocaleDateString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
const dateLabel = this.props.t
|
||||
? this.props.t("cart.availableFrom", { date: dateStr })
|
||||
: `Ab ${dateStr}`;
|
||||
|
||||
return (
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size={size || "medium"}
|
||||
onClick={pushInteractive ? this.handleIncomingPushClick : undefined}
|
||||
disabled={pushInteractive && pushBusy}
|
||||
startIcon={
|
||||
pushBusy ? (
|
||||
<CircularProgress size={18} sx={{ color: "inherit" }} />
|
||||
) : pushSubscribed ? (
|
||||
<NotificationsActiveIcon sx={{ color: "#2e7d32" }} />
|
||||
) : (
|
||||
<NotificationsIcon sx={{ color: "rgba(0,0,0,0.75)" }} />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: "bold",
|
||||
backgroundColor: "#ffeb3b",
|
||||
color: "#000000",
|
||||
whiteSpace: "nowrap",
|
||||
flexWrap: "nowrap",
|
||||
"& .MuiButton-label": {
|
||||
whiteSpace: "nowrap",
|
||||
flexWrap: "nowrap",
|
||||
},
|
||||
"&:hover": {
|
||||
backgroundColor: "#fdd835",
|
||||
},
|
||||
...(pushInteractive && {
|
||||
cursor: "pointer",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{dateLabel}
|
||||
</Button>
|
||||
{pushError && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="error"
|
||||
sx={{ display: "block", mt: 0.5, textAlign: "center" }}
|
||||
>
|
||||
{pushError}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size={size || "medium"}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: "bold",
|
||||
backgroundColor: "#ffeb3b",
|
||||
color: "#000000",
|
||||
"&:hover": {
|
||||
backgroundColor: "#fdd835",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Ab{" "}
|
||||
{new Date(incoming).toLocaleDateString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// If availableSupplier is 1, handle both quantity cases
|
||||
if (availableSupplier === 1) {
|
||||
// If no items in cart, show simple "Add to Cart" button with yellowish green
|
||||
@@ -393,13 +181,11 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// If items are in cart, show quantity controls with yellowish green
|
||||
if (quantity > 0) {
|
||||
return (
|
||||
@@ -419,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 />
|
||||
@@ -469,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" },
|
||||
@@ -489,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" },
|
||||
@@ -508,7 +290,7 @@ class AddToCartButton extends Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Button
|
||||
disabled
|
||||
@@ -520,7 +302,7 @@ class AddToCartButton extends Component {
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('product.outOfStock') : 'Out of Stock'}
|
||||
Out of Stock
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -545,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>
|
||||
);
|
||||
}
|
||||
@@ -570,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 />
|
||||
@@ -620,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" },
|
||||
@@ -640,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" },
|
||||
@@ -660,4 +436,4 @@ class AddToCartButton extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default withI18n()(AddToCartButton);
|
||||
export default AddToCartButton;
|
||||
|
||||
@@ -1,241 +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 || this.props.t("productDialogs.errorGeneric")
|
||||
});
|
||||
}
|
||||
|
||||
// Clear messages after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.setState({ success: false, error: null });
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
this.setState({ loading: true });
|
||||
|
||||
// Fallback timeout in case backend doesn't respond
|
||||
setTimeout(() => {
|
||||
if (this.state.loading) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
success: true,
|
||||
name: '',
|
||||
email: '',
|
||||
telegramId: '',
|
||||
notificationMethod: 'email',
|
||||
message: ''
|
||||
});
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.setState({ success: false });
|
||||
}, 3000);
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { name, email, telegramId, notificationMethod, message, loading, success, error } = this.state;
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<Paper id="availability-form" sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
|
||||
{t("productDialogs.availabilityTitle")}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{t("productDialogs.availabilitySubtitle")}
|
||||
</Typography>
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
{notificationMethod === 'email' ? t("productDialogs.availabilitySuccessEmail") : t("productDialogs.availabilitySuccessTelegram")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label={t("productDialogs.nameLabel")}
|
||||
value={name}
|
||||
onChange={this.handleInputChange('name')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder={t("productDialogs.namePlaceholder")}
|
||||
/>
|
||||
|
||||
<FormControl component="fieldset" disabled={loading}>
|
||||
<FormLabel component="legend" sx={{ mb: 1 }}>
|
||||
{t("productDialogs.notificationMethodLabel")}
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
value={notificationMethod}
|
||||
onChange={this.handleNotificationMethodChange}
|
||||
row
|
||||
>
|
||||
<FormControlLabel
|
||||
value="email"
|
||||
control={<Radio />}
|
||||
label={t("productDialogs.emailLabel")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="telegram"
|
||||
control={<Radio />}
|
||||
label={t("productDialogs.telegramBotLabel")}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{notificationMethod === 'email' && (
|
||||
<TextField
|
||||
label={t("productDialogs.emailLabel")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={this.handleInputChange('email')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder={t("productDialogs.emailPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{notificationMethod === 'telegram' && (
|
||||
<TextField
|
||||
label={t("productDialogs.telegramIdLabel")}
|
||||
value={telegramId}
|
||||
onChange={this.handleInputChange('telegramId')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder={t("productDialogs.telegramPlaceholder")}
|
||||
helperText={t("productDialogs.telegramHelper")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label={t("productDialogs.messageLabel")}
|
||||
value={message}
|
||||
onChange={this.handleInputChange('message')}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
disabled={loading}
|
||||
placeholder={t("productDialogs.messagePlaceholder")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={loading || !name || (notificationMethod === 'email' && !email) || (notificationMethod === 'telegram' && !telegramId)}
|
||||
sx={{
|
||||
mt: 2,
|
||||
py: 1.5,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
backgroundColor: 'warning.main',
|
||||
'&:hover': {
|
||||
backgroundColor: 'warning.dark'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
{t("productDialogs.sending")}
|
||||
</>
|
||||
) : (
|
||||
t("productDialogs.submitAvailability")
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withI18n()(ArticleAvailabilityForm);
|
||||
@@ -1,235 +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 || this.props.t("productDialogs.errorGeneric")
|
||||
});
|
||||
}
|
||||
|
||||
// Clear messages after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.setState({ success: false, error: null });
|
||||
}, 3000);
|
||||
});
|
||||
} catch {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: this.props.t("productDialogs.errorPhotos")
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback timeout in case backend doesn't respond
|
||||
setTimeout(() => {
|
||||
if (this.state.loading) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
success: true,
|
||||
name: '',
|
||||
email: '',
|
||||
question: '',
|
||||
photos: []
|
||||
});
|
||||
// Reset the photo upload component
|
||||
if (this.photoUploadRef.current) {
|
||||
this.photoUploadRef.current.reset();
|
||||
}
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.setState({ success: false });
|
||||
}, 3000);
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { name, email, question, loading, success, error } = this.state;
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
|
||||
{t("productDialogs.questionTitle")}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{t("productDialogs.questionSubtitle")}
|
||||
</Typography>
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
{t("productDialogs.questionSuccess")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label={t("productDialogs.nameLabel")}
|
||||
value={name}
|
||||
onChange={this.handleInputChange('name')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder={t("productDialogs.namePlaceholder")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={t("productDialogs.emailLabel")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={this.handleInputChange('email')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder={t("productDialogs.emailPlaceholder")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={t("productDialogs.questionLabel")}
|
||||
value={question}
|
||||
onChange={this.handleInputChange('question')}
|
||||
required
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
disabled={loading}
|
||||
placeholder={t("productDialogs.questionPlaceholder")}
|
||||
/>
|
||||
|
||||
<PhotoUpload
|
||||
ref={this.photoUploadRef}
|
||||
onChange={this.handlePhotosChange}
|
||||
disabled={loading}
|
||||
maxFiles={3}
|
||||
label={t("productDialogs.photosLabelQuestion")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={loading || !name || !email || !question}
|
||||
sx={{
|
||||
mt: 2,
|
||||
py: 1.5,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
{t("productDialogs.sending")}
|
||||
</>
|
||||
) : (
|
||||
t("productDialogs.submitQuestion")
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withI18n()(ArticleQuestionForm);
|
||||
@@ -1,263 +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 || this.props.t("productDialogs.errorGeneric")
|
||||
});
|
||||
}
|
||||
|
||||
// Clear messages after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.setState({ success: false, error: null });
|
||||
}, 3000);
|
||||
});
|
||||
} catch {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: this.props.t("productDialogs.errorPhotos")
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback timeout in case backend doesn't respond
|
||||
setTimeout(() => {
|
||||
if (this.state.loading) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
success: true,
|
||||
name: '',
|
||||
email: '',
|
||||
rating: 0,
|
||||
review: '',
|
||||
photos: []
|
||||
});
|
||||
// Reset the photo upload component
|
||||
if (this.photoUploadRef.current) {
|
||||
this.photoUploadRef.current.reset();
|
||||
}
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.setState({ success: false });
|
||||
}, 3000);
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { name, email, rating, review, loading, success, error } = this.state;
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, mt: 4, borderRadius: 2, boxShadow: "0 2px 8px rgba(0,0,0,0.08)" }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#333' }}>
|
||||
{t("productDialogs.ratingTitle")}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{t("productDialogs.ratingSubtitle")}
|
||||
</Typography>
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
{t("productDialogs.ratingSuccess")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={this.handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label={t("productDialogs.nameLabel")}
|
||||
value={name}
|
||||
onChange={this.handleInputChange('name')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder={t("productDialogs.namePlaceholder")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={t("productDialogs.emailLabel")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={this.handleInputChange('email')}
|
||||
required
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
placeholder={t("productDialogs.emailPlaceholder")}
|
||||
helperText={t("productDialogs.emailHelper")}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{t("productDialogs.ratingLabel")}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Rating
|
||||
name="article-rating"
|
||||
value={rating}
|
||||
onChange={this.handleRatingChange}
|
||||
size="large"
|
||||
disabled={loading}
|
||||
emptyIcon={<StarIcon style={{ opacity: 0.55 }} fontSize="inherit" />}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{rating > 0 ? t("productDialogs.ratingStars", { rating }) : t("productDialogs.pleaseRate")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label={t("productDialogs.reviewLabel")}
|
||||
value={review}
|
||||
onChange={this.handleInputChange('review')}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
disabled={loading}
|
||||
placeholder={t("productDialogs.reviewPlaceholder")}
|
||||
/>
|
||||
|
||||
<PhotoUpload
|
||||
ref={this.photoUploadRef}
|
||||
onChange={this.handlePhotosChange}
|
||||
disabled={loading}
|
||||
maxFiles={5}
|
||||
label={t("productDialogs.photosLabelRating")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={loading || !name || !email || rating === 0}
|
||||
sx={{
|
||||
mt: 2,
|
||||
py: 1.5,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
{t("productDialogs.sending")}
|
||||
</>
|
||||
) : (
|
||||
t("productDialogs.submitRating")
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withI18n()(ArticleRatingForm);
|
||||
@@ -8,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>
|
||||
<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 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)
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{deliveryCost > 0 && (
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Versandkosten:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{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;
|
||||
@@ -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,13 +19,14 @@ 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/avif' }));
|
||||
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
this.setState({image: window.tinyPicCache[picid], loading: false});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,24 +75,10 @@ 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
|
||||
)}
|
||||
<Link to={`/Artikel/${item.seoName}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
{item.name}
|
||||
</Link>
|
||||
</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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -47,7 +47,7 @@ const CategoryBox = ({
|
||||
// Create fresh blob URL from cached binary data
|
||||
try {
|
||||
const uint8Array = new Uint8Array(cachedImageData);
|
||||
const blob = new Blob([uint8Array], { type: 'image/avif' });
|
||||
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageUrl(objectUrl);
|
||||
setImageError(false);
|
||||
@@ -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) {
|
||||
@@ -73,7 +74,7 @@ const CategoryBox = ({
|
||||
try {
|
||||
// Convert binary data to blob URL
|
||||
const uint8Array = new Uint8Array(imageData);
|
||||
const blob = new Blob([uint8Array], { type: 'image/avif' });
|
||||
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageUrl(objectUrl);
|
||||
setImageError(false);
|
||||
@@ -118,7 +119,7 @@ const CategoryBox = ({
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [id, isLoading]);
|
||||
}, [context, context?.socket?.connected, id, isLoading]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -158,7 +159,7 @@ const CategoryBox = ({
|
||||
position: 'relative',
|
||||
backgroundImage: ((typeof window !== 'undefined' && window.__PRERENDER_FALLBACK__) ||
|
||||
(typeof global !== 'undefined' && global.window && global.window.__PRERENDER_FALLBACK__))
|
||||
? `url("/assets/images/cat${id}.avif")`
|
||||
? `url("/assets/images/cat${id}.jpg")`
|
||||
: (imageUrl && !imageError ? `url("${imageUrl}")` : 'none'),
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
@@ -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>
|
||||
|
||||
@@ -15,13 +15,7 @@ import StopIcon from '@mui/icons-material/Stop';
|
||||
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
|
||||
import parse, { domToReact } from 'html-react-parser';
|
||||
import { Link } from 'react-router-dom';
|
||||
import MuiLink from '@mui/material/Link';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
import { isUserLoggedIn } from './LoginComponent.js';
|
||||
import { withTranslation } from '../i18n/withTranslation.js';
|
||||
|
||||
const TELEGRAM_ASSISTANT_URL = 'https://t.me/Growheads_de_Bot';
|
||||
// Initialize window object for storing messages
|
||||
if (!window.chatMessages) {
|
||||
window.chatMessages = [];
|
||||
@@ -52,60 +46,24 @@ class ChatAssistant extends Component {
|
||||
this.fileInputRef = React.createRef();
|
||||
this.recordingTimer = null;
|
||||
}
|
||||
|
||||
buildPrivacyPromptHtml = () => {
|
||||
const { t } = this.props;
|
||||
return `${t('chat.privacyPromptBefore')}<a href="/datenschutz" target="_blank" rel="noopener noreferrer">${t('chat.privacyPolicyLink')}</a>${t('chat.privacyPromptAfter')}<button data-confirm-privacy="true">${t('chat.privacyRead')}</button>`;
|
||||
};
|
||||
|
||||
/** Keep stored privacy bubble in sync with i18n (language switcher, lazy bundle load). */
|
||||
applyPrivacyPromptTranslation = () => {
|
||||
this.setState((prev) => {
|
||||
if (prev.privacyConfirmed) return null;
|
||||
const idx = prev.messages.findIndex((m) => m.id === 'privacy-prompt');
|
||||
if (idx === -1) return null;
|
||||
const updatedMessages = [...prev.messages];
|
||||
updatedMessages[idx] = {
|
||||
...updatedMessages[idx],
|
||||
text: this.buildPrivacyPromptHtml(),
|
||||
};
|
||||
window.chatMessages = updatedMessages;
|
||||
return { messages: updatedMessages };
|
||||
});
|
||||
};
|
||||
|
||||
handleI18nLanguageChanged = () => {
|
||||
this.applyPrivacyPromptTranslation();
|
||||
};
|
||||
|
||||
|
||||
componentDidMount() {
|
||||
// Add socket listeners if socket is available and connected
|
||||
this.addSocketListeners();
|
||||
|
||||
this.props.i18n?.on('languageChanged', this.handleI18nLanguageChanged);
|
||||
|
||||
const userStatus = isUserLoggedIn();
|
||||
const isGuest = !userStatus.isLoggedIn;
|
||||
|
||||
if (isGuest && !this.state.privacyConfirmed) {
|
||||
this.setState(prevState => {
|
||||
if (prevState.messages.find(msg => msg.id === 'privacy-prompt')) {
|
||||
const updatedMessages = prevState.messages.map((msg) =>
|
||||
msg.id === 'privacy-prompt'
|
||||
? { ...msg, text: this.buildPrivacyPromptHtml() }
|
||||
: msg
|
||||
);
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
messages: updatedMessages,
|
||||
isGuest: true,
|
||||
};
|
||||
return { isGuest: true };
|
||||
}
|
||||
|
||||
const privacyMessage = {
|
||||
id: 'privacy-prompt',
|
||||
sender: 'bot',
|
||||
text: this.buildPrivacyPromptHtml(),
|
||||
text: 'Bitte bestätigen Sie, dass Sie die <a href="/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> gelesen haben und damit einverstanden sind. <button data-confirm-privacy="true">Gelesen & Akzeptiert</button>',
|
||||
};
|
||||
const updatedMessages = [privacyMessage, ...prevState.messages];
|
||||
window.chatMessages = updatedMessages;
|
||||
@@ -120,16 +78,24 @@ class ChatAssistant extends Component {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevProps.i18n?.language !== this.props.i18n?.language) {
|
||||
this.applyPrivacyPromptTranslation();
|
||||
}
|
||||
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() {
|
||||
this.props.i18n?.off('languageChanged', this.handleI18nLanguageChanged);
|
||||
this.removeSocketListeners();
|
||||
this.stopRecording();
|
||||
if (this.recordingTimer) {
|
||||
@@ -138,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) => {
|
||||
@@ -227,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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -284,7 +251,7 @@ class ChatAssistant extends Component {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error accessing microphone:", err);
|
||||
alert(this.props.t('chat.micPermissionDenied'));
|
||||
alert("Could not access microphone. Please check your browser permissions.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -333,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'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -399,7 +368,7 @@ class ChatAssistant extends Component {
|
||||
const newUserMessage = {
|
||||
id: Date.now(),
|
||||
sender: 'user',
|
||||
text: `<img src="${imageUrl}" alt="${this.props.t('chat.uploadedImageAlt')}" style="max-width: 100%; height: auto; border-radius: 8px;" />`,
|
||||
text: `<img src="${imageUrl}" alt="Uploaded image" style="max-width: 100%; height: auto; border-radius: 8px;" />`,
|
||||
isImage: true
|
||||
};
|
||||
|
||||
@@ -420,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'
|
||||
});
|
||||
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -491,15 +460,14 @@ class ChatAssistant extends Component {
|
||||
}
|
||||
|
||||
if (domNode.name === 'button' && domNode.attribs && domNode.attribs['data-confirm-privacy']) {
|
||||
return <Button variant="contained" size="small" onClick={this.handlePrivacyConfirm}>{this.props.t('chat.privacyRead')}</Button>;
|
||||
return <Button variant="contained" size="small" onClick={this.handlePrivacyConfirm}>Gelesen & Akzeptiert</Button>;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
render() {
|
||||
const { open, onClose, t } = this.props;
|
||||
const { open, onClose } = this.props;
|
||||
const { messages, inputValue, isTyping, isRecording, recordingTime, isGuest, privacyConfirmed } = this.state;
|
||||
const showTelegramHint = !messages.some((m) => m.sender === 'user');
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
@@ -508,20 +476,20 @@ class ChatAssistant extends Component {
|
||||
const inputsDisabled = isGuest && !privacyConfirmed;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={4}
|
||||
<Paper
|
||||
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,
|
||||
@@ -545,12 +513,12 @@ class ChatAssistant extends Component {
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" component="div">
|
||||
{t('chat.assistantTitle')}
|
||||
Assistent
|
||||
<Typography component="span" color={this.state.aiThink ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🧠</Typography>
|
||||
<Typography component="span" color={this.state.atDatabase ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🛢</Typography>
|
||||
<Typography component="span" color={this.state.atWeb ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🌐</Typography>
|
||||
</Typography>
|
||||
<IconButton onClick={onClose} size="small" aria-label={t('chat.closeAria')} sx={{ color: 'primary.contrastText' }}>
|
||||
<IconButton onClick={onClose} size="small" sx={{ color: 'primary.contrastText' }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
@@ -564,58 +532,6 @@ class ChatAssistant extends Component {
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{showTelegramHint && (
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: 2,
|
||||
borderColor: 'primary.main',
|
||||
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.14),
|
||||
boxShadow: (theme) =>
|
||||
`0 4px 14px ${alpha(theme.palette.primary.main, 0.35)}`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
|
||||
<TelegramIcon
|
||||
sx={{
|
||||
fontSize: 40,
|
||||
color: 'primary.main',
|
||||
flexShrink: 0,
|
||||
filter: (theme) =>
|
||||
`drop-shadow(0 1px 2px ${alpha(theme.palette.primary.dark, 0.45)})`,
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component="div"
|
||||
fontWeight={700}
|
||||
color="text.primary"
|
||||
sx={{ lineHeight: 1.45, mb: 0.25 }}
|
||||
>
|
||||
{t('chat.telegramAssistantIntro')}
|
||||
</Typography>
|
||||
<MuiLink
|
||||
href={TELEGRAM_ASSISTANT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="subtitle1"
|
||||
fontWeight={800}
|
||||
sx={{
|
||||
wordBreak: 'break-all',
|
||||
color: 'primary.dark',
|
||||
textDecorationColor: 'primary.main',
|
||||
'&:hover': { color: 'primary.main' },
|
||||
}}
|
||||
>
|
||||
{t('chat.telegramAssistantLink')}
|
||||
</MuiLink>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
{messages &&messages.map((message) => (
|
||||
<Box
|
||||
key={message.id}
|
||||
@@ -662,13 +578,11 @@ class ChatAssistant extends Component {
|
||||
)}
|
||||
<div ref={this.messagesEndRef} />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
gap: { xs: 1, sm: 0 },
|
||||
p: 1,
|
||||
borderTop: 1,
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
p: 1,
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
@@ -680,22 +594,22 @@ class ChatAssistant extends Component {
|
||||
onChange={this.handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
size="small"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
placeholder={isRecording ? t('chat.placeholderRecording') : t('chat.inputPlaceholder')}
|
||||
placeholder={isRecording ? "Aufnahme läuft..." : "Du kannst mich nach Cannabissorten fragen..."}
|
||||
value={inputValue}
|
||||
onChange={this.handleInputChange}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
disabled={isRecording || inputsDisabled}
|
||||
slotProps={{
|
||||
input: {
|
||||
maxLength: 300,
|
||||
input: {
|
||||
maxLength: 300,
|
||||
endAdornment: isRecording && (
|
||||
<Typography variant="caption" color="primary" sx={{ mr: 1 }}>
|
||||
{this.formatTime(recordingTime)}
|
||||
@@ -705,51 +619,46 @@ class ChatAssistant extends Component {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
{isRecording ? (
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={this.stopRecording}
|
||||
aria-label={t('chat.micStopAria')}
|
||||
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||
>
|
||||
<StopIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={this.startRecording}
|
||||
aria-label={t('chat.micStartAria')}
|
||||
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||
disabled={isTyping || inputsDisabled}
|
||||
>
|
||||
<MicIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={this.handleImageUpload}
|
||||
aria-label={t('chat.uploadImageAria')}
|
||||
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||
disabled={isTyping || isRecording || inputsDisabled}
|
||||
{isRecording ? (
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={this.stopRecording}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<PhotoCameraIcon />
|
||||
<StopIcon />
|
||||
</IconButton>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||
onClick={this.handleSendMessage}
|
||||
disabled={isTyping || isRecording || inputsDisabled}
|
||||
) : (
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={this.startRecording}
|
||||
sx={{ ml: 1 }}
|
||||
disabled={isTyping || inputsDisabled}
|
||||
>
|
||||
{t('chat.send')}
|
||||
</Button>
|
||||
</Box>
|
||||
<MicIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={this.handleImageUpload}
|
||||
sx={{ ml: 1 }}
|
||||
disabled={isTyping || isRecording || inputsDisabled}
|
||||
>
|
||||
<PhotoCameraIcon />
|
||||
</IconButton>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ ml: 1 }}
|
||||
onClick={this.handleSendMessage}
|
||||
disabled={isTyping || isRecording || inputsDisabled}
|
||||
>
|
||||
Senden
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(ChatAssistant);
|
||||
export default ChatAssistant;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
@@ -139,25 +138,21 @@ class Filter extends Component {
|
||||
|
||||
handleOptionChange = (event) => {
|
||||
const { name, checked } = event.target;
|
||||
const narrow =
|
||||
typeof window !== "undefined" && window.innerWidth < 600;
|
||||
|
||||
this.setState((prevState) => {
|
||||
const nextOptions = {
|
||||
|
||||
// Update local state first to ensure immediate UI feedback
|
||||
this.setState(prevState => ({
|
||||
options: {
|
||||
...prevState.options,
|
||||
[name]: checked,
|
||||
};
|
||||
return {
|
||||
options: nextOptions,
|
||||
...(narrow && checked ? { isCollapsed: true } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
[name]: checked
|
||||
}
|
||||
}));
|
||||
|
||||
// Then notify the parent component
|
||||
if (this.props.onFilterChange) {
|
||||
this.props.onFilterChange({
|
||||
type: this.props.filterType || "default",
|
||||
name,
|
||||
value: checked,
|
||||
this.props.onFilterChange({
|
||||
type: this.props.filterType || 'default',
|
||||
name: name,
|
||||
value: checked
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -186,13 +181,6 @@ class Filter extends Component {
|
||||
}));
|
||||
};
|
||||
|
||||
clearFilterOption = (optionId) => (event) => {
|
||||
event.stopPropagation();
|
||||
this.handleOptionChange({
|
||||
target: { name: optionId, checked: false },
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { options, counts, isCollapsed } = this.state;
|
||||
const { title, options: optionsList = [] } = this.props;
|
||||
@@ -279,79 +267,11 @@ class Filter extends Component {
|
||||
)}
|
||||
</Typography>
|
||||
{isXsScreen && (
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={isCollapsed ? "Filter erweitern" : "Filter einklappen"}
|
||||
sx={{ p: 0 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleCollapse();
|
||||
}}
|
||||
>
|
||||
<IconButton size="small" sx={{ p: 0 }}>
|
||||
{isCollapsed ? <ExpandMoreIcon /> : <ExpandLessIcon />}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isXsScreen &&
|
||||
isCollapsed &&
|
||||
optionsList.some((o) => options[o.id]) && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 0.75,
|
||||
mt: 0.5,
|
||||
mb: 1,
|
||||
pl: 0.25,
|
||||
}}
|
||||
>
|
||||
{optionsList
|
||||
.filter((o) => options[o.id])
|
||||
.map((option) => (
|
||||
<Chip
|
||||
key={option.id}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
onClick={this.clearFilterOption(option.id)}
|
||||
onDelete={this.clearFilterOption(option.id)}
|
||||
label={
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
>
|
||||
{this.props.filterType === "manufacturer" &&
|
||||
this.props.manufacturerImages?.get(option.id) && (
|
||||
<img
|
||||
src={this.props.manufacturerImages.get(option.id)}
|
||||
alt=""
|
||||
style={{
|
||||
height: 14,
|
||||
width: "auto",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{option.name}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Collapse in={!isXsScreen || !isCollapsed}>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
@@ -376,13 +296,6 @@ class Filter extends Component {
|
||||
const event = { target: { name: option.id, checked: !options[option.id] } };
|
||||
this.handleOptionChange(event);
|
||||
}}>
|
||||
{this.props.filterType === 'manufacturer' && this.props.manufacturerImages?.get(option.id) && (
|
||||
<img
|
||||
src={this.props.manufacturerImages.get(option.id)}
|
||||
alt=""
|
||||
style={{ height: '24px', width: 'auto', marginRight: '6px', verticalAlign: 'middle', objectFit: 'contain' }}
|
||||
/>
|
||||
)}
|
||||
{option.name}
|
||||
</td>
|
||||
<td style={countCellStyle}>
|
||||
|
||||
@@ -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"
|
||||
@@ -272,12 +271,12 @@ class Footer extends Component {
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Stack
|
||||
<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,21 +285,17 @@ 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}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/assets/images/gg.avif"
|
||||
src="/assets/images/gg.png"
|
||||
alt="Google Reviews"
|
||||
sx={{
|
||||
height: { xs: 50, md: 60 },
|
||||
width: { xs: 105, md: 126 },
|
||||
cursor: 'pointer',
|
||||
transition: 'all 2s ease',
|
||||
'&:hover': {
|
||||
@@ -316,21 +311,17 @@ 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}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/assets/images/maps.avif"
|
||||
src="/assets/images/maps.png"
|
||||
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))',
|
||||
@@ -345,44 +336,9 @@ class Footer extends Component {
|
||||
</Stack>
|
||||
|
||||
{/* Copyright Section */}
|
||||
<Box sx={{ pb: 0, textAlign: 'center', filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))', opacity: 0.7 }}>
|
||||
<Box sx={{ pb:'20px',textAlign: 'center', filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))', opacity: 0.7 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
|
||||
{this.props.t ? this.props.t('footer.allPricesIncl') : '* Alle Preise inkl. gesetzlicher USt., zzgl. Versand'}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
fontSize: { xs: '11px', md: '14px' },
|
||||
lineHeight: 1.5,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
}}
|
||||
>
|
||||
Made with
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: '#ffffff',
|
||||
borderRadius: '3px',
|
||||
px: 0.6,
|
||||
py: 0.15,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
fontSize: { xs: '10px', md: '12px' },
|
||||
letterSpacing: '0.3px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
jB
|
||||
</Box>
|
||||
<StyledDomainLink href="https://jbuddy.de" target="_blank" rel="noopener noreferrer">
|
||||
jBuddy.de
|
||||
</StyledDomainLink>
|
||||
* 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>
|
||||
@@ -395,4 +351,4 @@ class Footer extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default withI18n()(Footer);
|
||||
export default Footer;
|
||||
|
||||
@@ -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;
|
||||
@@ -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,9 +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} pathname={this.props.pathname} />
|
||||
)}
|
||||
{(isHomePage || this.props.categoryId || isProfilePage) && <CategoryList categoryId={209} activeCategoryId={this.props.categoryId} socket={socket} socketB={socketB} />}
|
||||
</AppBar>
|
||||
);
|
||||
}
|
||||
@@ -104,20 +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}
|
||||
pathname={location.pathname}
|
||||
/>
|
||||
<SocketContext.Consumer>
|
||||
{({socket,socketB}) => <Header {...props} socket={socket} socketB={socketB} isHomePage={isHomePage} isProfilePage={isProfilePage} />}
|
||||
</SocketContext.Consumer>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
/** Same order as the main landing tiles (home → Aktionen → Filiale). */
|
||||
const MAIN_PAGE_PATHS = ["/", "/aktionen", "/filiale"];
|
||||
|
||||
/** No input for this long before the slideshow starts. */
|
||||
const IDLE_MS = 90_000;
|
||||
|
||||
/** Time between automatic page changes once the slideshow is running. */
|
||||
const SLIDESHOW_STEP_MS = 14_000;
|
||||
|
||||
/** Ignore duplicate events (mousemove etc.) within this window. */
|
||||
const ACTIVITY_THROTTLE_MS = 400;
|
||||
|
||||
/**
|
||||
* After auto-navigation, ignore user-activity handlers briefly — route changes
|
||||
* often emit scroll / mousemove / focus events that would call resetIdle() and
|
||||
* clear the slideshow interval (only one slide before stopping).
|
||||
*/
|
||||
const POST_NAV_GRACE_MS = 3_000;
|
||||
|
||||
/**
|
||||
* After idle on /, /aktionen, or /filiale, cycles those routes slowly.
|
||||
* Lives outside MainPageLayout so it is not reset when the route changes.
|
||||
*/
|
||||
export default function IdleMainPagesSlideshow() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const idleTimerRef = useRef(null);
|
||||
const slideTimerRef = useRef(null);
|
||||
const pathRef = useRef(location.pathname);
|
||||
const wasOnMainPageRef = useRef(false);
|
||||
const lastActivityRef = useRef(0);
|
||||
const ignoreActivityUntilRef = useRef(0);
|
||||
|
||||
const resetIdleRef = useRef(() => {});
|
||||
const clearTimersRef = useRef(() => {});
|
||||
|
||||
pathRef.current = location.pathname;
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (idleTimerRef.current != null) {
|
||||
clearTimeout(idleTimerRef.current);
|
||||
idleTimerRef.current = null;
|
||||
}
|
||||
if (slideTimerRef.current != null) {
|
||||
clearInterval(slideTimerRef.current);
|
||||
slideTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
clearTimersRef.current = clearTimers;
|
||||
|
||||
const startSlideshow = useCallback(() => {
|
||||
let idx = MAIN_PAGE_PATHS.indexOf(pathRef.current);
|
||||
if (idx < 0) idx = 0;
|
||||
const advance = () => {
|
||||
idx = (idx + 1) % MAIN_PAGE_PATHS.length;
|
||||
ignoreActivityUntilRef.current = Date.now() + POST_NAV_GRACE_MS;
|
||||
navigate(MAIN_PAGE_PATHS[idx], { replace: true });
|
||||
};
|
||||
slideTimerRef.current = setInterval(advance, SLIDESHOW_STEP_MS);
|
||||
}, [navigate]);
|
||||
|
||||
const resetIdle = useCallback(() => {
|
||||
clearTimers();
|
||||
if (!MAIN_PAGE_PATHS.includes(pathRef.current)) return;
|
||||
idleTimerRef.current = setTimeout(() => {
|
||||
idleTimerRef.current = null;
|
||||
startSlideshow();
|
||||
}, IDLE_MS);
|
||||
}, [clearTimers, startSlideshow]);
|
||||
|
||||
resetIdleRef.current = resetIdle;
|
||||
|
||||
useEffect(() => {
|
||||
const nowMain = MAIN_PAGE_PATHS.includes(location.pathname);
|
||||
if (!nowMain) {
|
||||
clearTimers();
|
||||
wasOnMainPageRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!wasOnMainPageRef.current) {
|
||||
resetIdle();
|
||||
}
|
||||
wasOnMainPageRef.current = true;
|
||||
}, [location.pathname, clearTimers, resetIdle]);
|
||||
|
||||
useEffect(() => {
|
||||
const onActivity = () => {
|
||||
const now = Date.now();
|
||||
if (now < ignoreActivityUntilRef.current) return;
|
||||
if (now - lastActivityRef.current < ACTIVITY_THROTTLE_MS) return;
|
||||
lastActivityRef.current = now;
|
||||
resetIdleRef.current();
|
||||
};
|
||||
|
||||
const events = [
|
||||
"mousedown",
|
||||
"keydown",
|
||||
"touchstart",
|
||||
"touchmove",
|
||||
"wheel",
|
||||
"click",
|
||||
"scroll",
|
||||
];
|
||||
events.forEach((ev) =>
|
||||
window.addEventListener(ev, onActivity, { passive: true })
|
||||
);
|
||||
window.addEventListener("mousemove", onActivity, { passive: true });
|
||||
|
||||
return () => {
|
||||
events.forEach((ev) => window.removeEventListener(ev, onActivity));
|
||||
window.removeEventListener("mousemove", onActivity);
|
||||
clearTimersRef.current();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -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}.avif`);
|
||||
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,11 +77,9 @@ 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/avif' }));
|
||||
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
|
||||
if(size === 'medium') window.mediumPicCache[bildId] = url;
|
||||
if(size === 'small') window.smallPicCache[bildId] = url;
|
||||
@@ -106,68 +101,43 @@ class Images extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
// SPA version - full functionality with static fallback
|
||||
const getImageSrc = () => {
|
||||
if(window.productImageUrl) return window.productImageUrl;
|
||||
|
||||
// If dynamic image is loaded, use it
|
||||
if (this.state.pics[this.state.mainPic]) {
|
||||
return this.state.pics[this.state.mainPic];
|
||||
}
|
||||
// Otherwise, use static fallback (same as prerender)
|
||||
if (!this.props.pictureList || !this.props.pictureList.trim()) {
|
||||
return '/assets/images/nopicture.jpg';
|
||||
}
|
||||
return `/assets/images/prod${this.props.pictureList.split(',')[0].trim()}.avif`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ position: 'relative', display: 'inline-block', width: '499px', maxWidth: '100%' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="400"
|
||||
fetchPriority="high"
|
||||
loading="eager"
|
||||
alt={this.props.productName || 'Produktbild'}
|
||||
onError={(e) => {
|
||||
// Ensure alt text is always present even on error
|
||||
if (!e.target.alt) {
|
||||
e.target.alt = this.props.productName || 'Produktbild';
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
width: '499px',
|
||||
maxWidth: '100%',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.02)'
|
||||
}
|
||||
}}
|
||||
image={getImageSrc()}
|
||||
onClick={this.props.onOpenFullscreen}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
disableRipple
|
||||
aria-label="Zoom-Symbol"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
color: 'white',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
pointerEvents: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LoupeIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{this.state.pics[this.state.mainPic] && (
|
||||
<Box sx={{ position: 'relative', display: 'inline-block' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="400"
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.02)'
|
||||
}
|
||||
}}
|
||||
image={this.state.pics[this.state.mainPic]}
|
||||
onClick={this.props.onOpenFullscreen}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
disableRipple
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
color: 'white',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
pointerEvents: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LoupeIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
<Stack direction="row" spacing={2} sx={{ justifyContent: 'flex-start', mt: 1,mb: 1 }}>
|
||||
{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',
|
||||
|
||||
@@ -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);
|
||||
@@ -22,37 +22,10 @@ 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 {
|
||||
hasPendingWirePaymentOrder,
|
||||
WIRE_PAYMENT_PENDING_EVENT,
|
||||
} from '../utils/wireGirocodeEligibility.js';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
|
||||
// Lazy load GoogleAuthProvider
|
||||
const GoogleAuthProvider = lazy(() => import('../providers/GoogleAuthProvider.js'));
|
||||
|
||||
const getTokenFromAuthResponse = (response) =>
|
||||
response?.token ||
|
||||
response?.accessToken ||
|
||||
response?.jwt ||
|
||||
response?.user?.token ||
|
||||
response?.user?.accessToken ||
|
||||
null;
|
||||
|
||||
const persistSessionAuth = (response) => {
|
||||
if (response?.user) {
|
||||
sessionStorage.setItem('user', JSON.stringify(response.user));
|
||||
}
|
||||
|
||||
const token = getTokenFromAuthResponse(response);
|
||||
if (token) {
|
||||
sessionStorage.setItem('authToken', token);
|
||||
} else {
|
||||
sessionStorage.removeItem('authToken');
|
||||
}
|
||||
};
|
||||
|
||||
// Function to check if user is logged in
|
||||
export const isUserLoggedIn = () => {
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
@@ -102,7 +75,6 @@ function cartsAreIdentical(cartA, cartB) {
|
||||
export class LoginComponent extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { isLoggedIn, user, isAdmin } = isUserLoggedIn();
|
||||
this.state = {
|
||||
open: false,
|
||||
tabValue: 0,
|
||||
@@ -112,64 +84,47 @@ export class LoginComponent extends Component {
|
||||
error: '',
|
||||
loading: false,
|
||||
success: '',
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
user,
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
user: null,
|
||||
anchorEl: null,
|
||||
showGoogleAuth: false,
|
||||
cartSyncOpen: false,
|
||||
localCartSync: [],
|
||||
serverCartSync: [],
|
||||
pendingNavigate: null,
|
||||
privacyConfirmed: sessionStorage.getItem('privacyConfirmed') === 'true',
|
||||
pendingWirePaymentOrders: false
|
||||
privacyConfirmed: sessionStorage.getItem('privacyConfirmed') === 'true'
|
||||
};
|
||||
}
|
||||
|
||||
refreshPendingWireOrders = () => {
|
||||
if (typeof window === 'undefined' || !window.socketManager) return;
|
||||
window.socketManager.emit('getOrders', (response) => {
|
||||
if (response.success && Array.isArray(response.orders)) {
|
||||
this.setState({
|
||||
pendingWirePaymentOrders: hasPendingWirePaymentOrder(response.orders),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
handleWirePaymentPendingEvent = (e) => {
|
||||
if (e.detail && typeof e.detail.pending === 'boolean') {
|
||||
this.setState({ pendingWirePaymentOrders: e.detail.pending });
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
// Make the open function available globally
|
||||
window.openLoginDrawer = this.handleOpen;
|
||||
|
||||
// Check if user is logged in
|
||||
const { isLoggedIn: userIsLoggedIn, user: storedUser } = isUserLoggedIn();
|
||||
if (userIsLoggedIn) {
|
||||
this.setState({
|
||||
user: storedUser,
|
||||
isAdmin: !!storedUser.admin,
|
||||
isLoggedIn: true
|
||||
});
|
||||
}
|
||||
|
||||
if (this.props.open) {
|
||||
this.setState({ open: true });
|
||||
}
|
||||
|
||||
if (this.state.isLoggedIn) {
|
||||
this.refreshPendingWireOrders();
|
||||
}
|
||||
window.addEventListener(WIRE_PAYMENT_PENDING_EVENT, this.handleWirePaymentPendingEvent);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.open !== prevProps.open) {
|
||||
this.setState({ open: this.props.open });
|
||||
}
|
||||
if (this.state.isLoggedIn && !prevState.isLoggedIn) {
|
||||
this.refreshPendingWireOrders();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup function to remove global reference when component unmounts
|
||||
window.openLoginDrawer = undefined;
|
||||
window.removeEventListener(WIRE_PAYMENT_PENDING_EVENT, this.handleWirePaymentPendingEvent);
|
||||
}
|
||||
|
||||
resetForm = () => {
|
||||
@@ -215,40 +170,40 @@ 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: this.props.t ? this.props.t('auth.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
|
||||
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: '' });
|
||||
|
||||
// Call verifyUser socket endpoint
|
||||
if (!socket || !socket.connected) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Verbindung zum Server verloren. Bitte versuchen Sie es erneut.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
window.socketManager.emit('verifyUser', { email, password }, (response) => {
|
||||
socket.emit('verifyUser', { email, password }, (response) => {
|
||||
console.log('LoginComponent: verifyUser', response);
|
||||
if (response.success) {
|
||||
persistSessionAuth(response);
|
||||
sessionStorage.setItem('user', JSON.stringify(response.user));
|
||||
this.setState({
|
||||
user: response.user,
|
||||
isLoggedIn: true,
|
||||
isAdmin: !!response.user.admin
|
||||
});
|
||||
|
||||
const redirectTo = (() => {
|
||||
// If we started login from the linkTelegram flow, come back there after auth.
|
||||
// This prevents LinkTelegramPage from getting unmounted before the socket emit runs.
|
||||
if (location?.pathname && location.pathname.startsWith('/linkTelegram')) {
|
||||
return `${location.pathname}${location.search || ''}${location.hash || ''}`;
|
||||
}
|
||||
|
||||
return location && location.hash ? `/profile${location.hash}` : '/profile';
|
||||
})();
|
||||
const redirectTo = location && location.hash ? `/profile${location.hash}` : '/profile';
|
||||
const dispatchLoginEvent = () => {
|
||||
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
||||
navigate(redirectTo);
|
||||
@@ -260,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) {
|
||||
@@ -289,7 +244,7 @@ export class LoginComponent extends Component {
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || (this.props.t ? this.props.t('auth.errors.loginFailed') : 'Anmeldung fehlgeschlagen')
|
||||
error: response.message || 'Anmeldung fehlgeschlagen'
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -297,49 +252,50 @@ export class LoginComponent extends Component {
|
||||
|
||||
handleRegister = () => {
|
||||
const { email, password, confirmPassword } = this.state;
|
||||
const { socket } = this.props;
|
||||
|
||||
if (!email || !password || !confirmPassword) {
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.fillAllFields') : 'Bitte füllen Sie alle Felder aus' });
|
||||
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.passwordsNotMatchShort') : 'Passwörter stimmen nicht überein' });
|
||||
this.setState({ error: 'Passwörter stimmen nicht überein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.passwordMinLength') : 'Das Passwort muss mindestens 8 Zeichen lang sein' });
|
||||
this.setState({ error: 'Das Passwort muss mindestens 8 Zeichen lang sein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: '' });
|
||||
|
||||
|
||||
window.socketManager.emit('createUser', { email, password }, (response) => {
|
||||
// Call createUser socket endpoint
|
||||
if (!socket || !socket.connected) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Verbindung zum Server verloren. Bitte versuchen Sie es erneut.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('createUser', { email, password }, (response) => {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
success: this.props.t ? this.props.t('auth.success.registerComplete') : 'Registrierung erfolgreich. Sie können sich jetzt anmelden.',
|
||||
success: 'Registrierung erfolgreich. Sie können sich jetzt anmelden.',
|
||||
tabValue: 0 // Switch to login tab
|
||||
});
|
||||
} else {
|
||||
let errorMessage = this.props.t ? this.props.t('auth.errors.registerFailed') : 'Registrierung fehlgeschlagen';
|
||||
|
||||
if (response.cause === 'emailExists') {
|
||||
errorMessage = this.props.t ? this.props.t('auth.errors.emailExists') : 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits. Bitte verwenden Sie eine andere E-Mail-Adresse oder melden Sie sich an.';
|
||||
} else if (response.message) {
|
||||
errorMessage = response.message;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: errorMessage
|
||||
error: response.message || 'Registrierung fehlgeschlagen'
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -347,7 +303,6 @@ export class LoginComponent extends Component {
|
||||
|
||||
handleUserMenuClick = (event) => {
|
||||
this.setState({ anchorEl: event.currentTarget });
|
||||
this.refreshPendingWireOrders();
|
||||
};
|
||||
|
||||
handleUserMenuClose = () => {
|
||||
@@ -355,10 +310,24 @@ 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');
|
||||
sessionStorage.removeItem('authToken');
|
||||
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
||||
this.props.navigate('/');
|
||||
this.setState({
|
||||
@@ -366,7 +335,6 @@ export class LoginComponent extends Component {
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
anchorEl: null,
|
||||
pendingWirePaymentOrders: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -374,21 +342,22 @@ export class LoginComponent extends Component {
|
||||
|
||||
handleForgotPassword = () => {
|
||||
const { email } = this.state;
|
||||
const { socket } = this.props;
|
||||
|
||||
if (!email) {
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.enterEmail') : 'Bitte geben Sie Ihre E-Mail-Adresse ein' });
|
||||
this.setState({ error: 'Bitte geben Sie Ihre E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: this.props.t ? this.props.t('auth.errors.invalidEmail') : 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: '' });
|
||||
|
||||
|
||||
window.socketManager.emit('resetPassword', {
|
||||
// Call resetPassword socket endpoint
|
||||
socket.emit('resetPassword', {
|
||||
email,
|
||||
domain: window.location.origin
|
||||
}, (response) => {
|
||||
@@ -396,12 +365,12 @@ export class LoginComponent extends Component {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
success: this.props.t ? this.props.t('auth.resetPassword.emailSent') : 'Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.'
|
||||
success: 'Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.'
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || (this.props.t ? this.props.t('auth.resetPassword.emailError') : 'Fehler beim Senden der E-Mail')
|
||||
error: response.message || 'Fehler beim Senden der E-Mail'
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -409,28 +378,23 @@ 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) {
|
||||
persistSessionAuth(response);
|
||||
sessionStorage.setItem('user', JSON.stringify(response.user));
|
||||
this.setState({
|
||||
isLoggedIn: true,
|
||||
isAdmin: !!response.user.admin,
|
||||
user: response.user
|
||||
});
|
||||
|
||||
const redirectTo = (() => {
|
||||
// If we started login from the linkTelegram flow, come back there after auth.
|
||||
if (location?.pathname && location.pathname.startsWith('/linkTelegram')) {
|
||||
return `${location.pathname}${location.search || ''}${location.hash || ''}`;
|
||||
}
|
||||
|
||||
return location && location.hash ? `/profile${location.hash}` : '/profile';
|
||||
})();
|
||||
const redirectTo = location && location.hash ? `/profile${location.hash}` : '/profile';
|
||||
const dispatchLoginEvent = () => {
|
||||
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
||||
navigate(redirectTo);
|
||||
@@ -442,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) {
|
||||
@@ -469,7 +433,7 @@ export class LoginComponent extends Component {
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: this.props.t ? this.props.t('auth.errors.googleLoginFailed') : 'Google-Anmeldung fehlgeschlagen',
|
||||
error: 'Google-Anmeldung fehlgeschlagen',
|
||||
showGoogleAuth: false // Reset Google auth state on failed login
|
||||
});
|
||||
}
|
||||
@@ -479,7 +443,7 @@ export class LoginComponent extends Component {
|
||||
handleGoogleLoginError = (error) => {
|
||||
console.error('Google Login Error:', error);
|
||||
this.setState({
|
||||
error: this.props.t ? this.props.t('auth.errors.googleLoginFailed') : 'Google-Anmeldung fehlgeschlagen',
|
||||
error: 'Google-Anmeldung fehlgeschlagen',
|
||||
showGoogleAuth: false, // Reset Google auth state on error
|
||||
loading: false
|
||||
});
|
||||
@@ -492,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;
|
||||
@@ -528,8 +492,7 @@ export class LoginComponent extends Component {
|
||||
cartSyncOpen,
|
||||
localCartSync,
|
||||
serverCartSync,
|
||||
privacyConfirmed,
|
||||
pendingWirePaymentOrders
|
||||
privacyConfirmed
|
||||
} = this.state;
|
||||
|
||||
const { open: openProp, handleClose: handleCloseProp } = this.props;
|
||||
@@ -547,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}
|
||||
@@ -563,33 +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, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<span>{this.props.t ? this.props.t('auth.menu.orders') : 'Bestellungen'}</span>
|
||||
{pendingWirePaymentOrders ? (
|
||||
<Typography component="span" sx={{ color: 'error.main', fontWeight: 700, fontSize: '0.875rem', flexShrink: 0 }} aria-label="!">
|
||||
[!]
|
||||
</Typography>
|
||||
) : null}
|
||||
</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>
|
||||
</>
|
||||
) : (
|
||||
@@ -599,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>
|
||||
)
|
||||
)}
|
||||
@@ -614,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 />
|
||||
@@ -637,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'
|
||||
@@ -657,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>
|
||||
</>
|
||||
}
|
||||
Mit dem Click auf "Mit Google anmelden" akzeptiere ich die <Link to="/datenschutz" style={{ color: '#4285F4' }}>Datenschutzbestimmungen</Link>
|
||||
</Typography>
|
||||
)}
|
||||
{!showGoogleAuth && (
|
||||
@@ -677,31 +611,30 @@ 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>
|
||||
)}
|
||||
|
||||
{showGoogleAuth && (
|
||||
<Suspense fallback={
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<GoogleIcon />}
|
||||
disabled
|
||||
fullWidth
|
||||
style={{backgroundColor: '#4285F4', color: 'white' }}
|
||||
>
|
||||
Loading...
|
||||
</Button>
|
||||
}>
|
||||
<GoogleAuthProvider clientId={config.googleClientId}>
|
||||
<GoogleLoginButton
|
||||
onSuccess={this.handleGoogleLoginSuccess}
|
||||
onError={this.handleGoogleLoginError}
|
||||
style={{ width: '100%', backgroundColor: '#4285F4' }}
|
||||
autoInitiate={true}
|
||||
/>
|
||||
</GoogleAuthProvider>
|
||||
</Suspense>
|
||||
<Suspense fallback={
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PersonIcon />}
|
||||
sx={{ width: '100%', backgroundColor: '#4285F4', color: 'white' }}
|
||||
>
|
||||
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}
|
||||
/>
|
||||
</GoogleAuthProvider>
|
||||
</Suspense>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -710,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>
|
||||
|
||||
@@ -723,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"
|
||||
@@ -734,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"
|
||||
@@ -756,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>
|
||||
)}
|
||||
@@ -764,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"
|
||||
@@ -786,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>
|
||||
@@ -809,4 +740,4 @@ export class LoginComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(withI18n()(LoginComponent));
|
||||
export default withRouter(LoginComponent);
|
||||
@@ -1,438 +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 { STAR_POLYGON_POINTS } from "../utils/starPolygon.js";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const HOME_STAR_LAYERS = [
|
||||
{ className: "star-rotate-slow-cw", size: 168 },
|
||||
{ className: "star-rotate-slow-ccw", size: 159 },
|
||||
{ className: "star-rotate-medium-cw", size: 150 },
|
||||
];
|
||||
|
||||
/** Teal/cyan stack for the right (Konfigurator) star — same motion, blue color scheme */
|
||||
const TEAL_STAR_LAYERS = [
|
||||
{ className: "star-rotate-slow-ccw", size: 168 },
|
||||
{ className: "star-rotate-medium-cw", size: 159 },
|
||||
{ className: "star-rotate-slow-cw", size: 150 },
|
||||
];
|
||||
|
||||
/** Initial fill per variant (matches keyframe 0%) — avoids black flash before CSS animates */
|
||||
const STAR_INITIAL_FILLS = {
|
||||
home: ["#B8860B", "#DAA520", "#FFD700"],
|
||||
filiale: ["#5F9EA0", "#7FCDCD", "#AFEEEE"],
|
||||
};
|
||||
|
||||
/** Injected in render (not useEffect) so first paint already has keyframes — avoids angle/color snap on load */
|
||||
const STAR_DECORATION_CSS = `
|
||||
@keyframes rotateClockwise {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes rotateCounterClockwise {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(-360deg); }
|
||||
}
|
||||
.star-rotate-slow-cw,
|
||||
.star-rotate-slow-ccw,
|
||||
.star-rotate-medium-cw {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.star-layer-svg-home {
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.92;
|
||||
filter: drop-shadow(3px 3px 6px rgba(0, 0, 0, 0.5)) drop-shadow(0 0 10px rgba(255, 215, 0, 0.6)) drop-shadow(0 0 20px rgba(255, 215, 0, 0.3));
|
||||
}
|
||||
.star-layer-svg-filiale {
|
||||
mix-blend-mode: soft-light;
|
||||
opacity: 0.94;
|
||||
filter: drop-shadow(3px 3px 6px rgba(0, 0, 0, 0.5)) drop-shadow(0 0 10px rgba(127, 205, 205, 0.6)) drop-shadow(0 0 18px rgba(95, 158, 160, 0.35));
|
||||
}
|
||||
.star-layer-svg {
|
||||
shape-rendering: geometricPrecision;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
@keyframes starFillHome0 {
|
||||
0%, 100% { fill: #B8860B; }
|
||||
33% { fill: #FFD700; }
|
||||
66% { fill: #DAA520; }
|
||||
}
|
||||
@keyframes starFillHome1 {
|
||||
0%, 100% { fill: #DAA520; }
|
||||
33% { fill: #B8860B; }
|
||||
66% { fill: #FFD700; }
|
||||
}
|
||||
@keyframes starFillHome2 {
|
||||
0%, 100% { fill: #FFD700; }
|
||||
33% { fill: #DAA520; }
|
||||
66% { fill: #B8860B; }
|
||||
}
|
||||
@keyframes starDriftHome0 {
|
||||
0%, 100% { transform: rotate(20deg) translate(0px, 0px); }
|
||||
50% { transform: rotate(20deg) translate(5px, -5px); }
|
||||
}
|
||||
@keyframes starDriftHome1 {
|
||||
0%, 100% { transform: rotate(-25deg) translate(0px, 0px); }
|
||||
50% { transform: rotate(-25deg) translate(-4px, 6px); }
|
||||
}
|
||||
@keyframes starDriftHome2 {
|
||||
0%, 100% { transform: translate(0px, 0px); }
|
||||
50% { transform: translate(3px, 4px); }
|
||||
}
|
||||
.star-layer-wrap.star-layer-home-0 {
|
||||
animation: starDriftHome0 6.5s ease-in-out infinite;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
.star-layer-wrap.star-layer-home-1 {
|
||||
animation: starDriftHome1 7s ease-in-out 0.4s infinite;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
.star-layer-wrap.star-layer-home-2 {
|
||||
animation: starDriftHome2 5.5s ease-in-out 0.8s infinite;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
.star-poly-home-0 { animation: starFillHome0 10s ease-in-out 0s infinite both; }
|
||||
.star-poly-home-1 { animation: starFillHome1 10s ease-in-out 1.1s infinite both; }
|
||||
.star-poly-home-2 { animation: starFillHome2 10s ease-in-out 2.2s infinite both; }
|
||||
|
||||
@keyframes starFillFil0 {
|
||||
0%, 100% { fill: #5F9EA0; }
|
||||
33% { fill: #AFEEEE; }
|
||||
66% { fill: #7FCDCD; }
|
||||
}
|
||||
@keyframes starFillFil1 {
|
||||
0%, 100% { fill: #7FCDCD; }
|
||||
33% { fill: #5F9EA0; }
|
||||
66% { fill: #AFEEEE; }
|
||||
}
|
||||
@keyframes starFillFil2 {
|
||||
0%, 100% { fill: #AFEEEE; }
|
||||
33% { fill: #7FCDCD; }
|
||||
66% { fill: #5F9EA0; }
|
||||
}
|
||||
@keyframes starDriftFil0 {
|
||||
0%, 100% { transform: rotate(20deg) translate(0px, 0px); }
|
||||
50% { transform: rotate(20deg) translate(4px, -4px); }
|
||||
}
|
||||
@keyframes starDriftFil1 {
|
||||
0%, 100% { transform: rotate(-25deg) translate(0px, 0px); }
|
||||
50% { transform: rotate(-25deg) translate(-5px, 5px); }
|
||||
}
|
||||
@keyframes starDriftFil2 {
|
||||
0%, 100% { transform: translate(0px, 0px); }
|
||||
50% { transform: translate(3px, 3px); }
|
||||
}
|
||||
.star-layer-wrap.star-layer-filiale-0 {
|
||||
animation: starDriftFil0 6.5s ease-in-out infinite;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
.star-layer-wrap.star-layer-filiale-1 {
|
||||
animation: starDriftFil1 7s ease-in-out 0.4s infinite;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
.star-layer-wrap.star-layer-filiale-2 {
|
||||
animation: starDriftFil2 5.5s ease-in-out 0.8s infinite;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
.star-poly-filiale-0 { animation: starFillFil0 10s ease-in-out 0s infinite both; }
|
||||
.star-poly-filiale-1 { animation: starFillFil1 10s ease-in-out 1.1s infinite both; }
|
||||
.star-poly-filiale-2 { animation: starFillFil2 10s ease-in-out 2.2s infinite both; }
|
||||
`;
|
||||
|
||||
const StarDecorationLayers = ({ layers, variant }) => (
|
||||
<>
|
||||
{layers.map(({ className, size }, i) => {
|
||||
const half = size / 2;
|
||||
const initialFill = STAR_INITIAL_FILLS[variant][i];
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`star-layer-wrap star-layer-${variant}-${i}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
width: size,
|
||||
height: size,
|
||||
marginLeft: -half,
|
||||
marginTop: -half,
|
||||
zIndex: 3 - i,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="100%"
|
||||
height="100%"
|
||||
className={`${className} star-layer-svg star-layer-svg-${variant}`}
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
<polygon
|
||||
points={STAR_POLYGON_POINTS}
|
||||
fill={initialFill}
|
||||
className={`star-poly-fill star-poly-${variant}-${i}`}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
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 === "home" && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '-55px',
|
||||
left: '-45px',
|
||||
width: '180px',
|
||||
height: '180px',
|
||||
zIndex: 999,
|
||||
pointerEvents: 'none',
|
||||
'& *': { pointerEvents: 'none' },
|
||||
display: { xs: 'none', sm: 'block' }
|
||||
}}
|
||||
>
|
||||
<StarDecorationLayers layers={HOME_STAR_LAYERS} variant="home" />
|
||||
<div style={{ position: 'absolute', top: '50%', left: '50%', 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.outdoorSeason}
|
||||
</div>
|
||||
<div style={{ position: 'absolute', top: '50%', left: '50%', 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 === "home" && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: '-45px',
|
||||
right: '-65px',
|
||||
width: '180px',
|
||||
height: '180px',
|
||||
zIndex: 999,
|
||||
pointerEvents: 'none',
|
||||
'& *': { pointerEvents: 'none' },
|
||||
display: { xs: 'none', sm: 'block' }
|
||||
}}
|
||||
>
|
||||
<StarDecorationLayers layers={TEAL_STAR_LAYERS} variant="filiale" />
|
||||
<div style={{ position: 'absolute', top: '50%', left: '50%', 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.buildYourSet}
|
||||
</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: "box-shadow 0.3s ease",
|
||||
"&:hover": { boxShadow: 20 },
|
||||
}}
|
||||
onMouseEnter={
|
||||
pageType === "home" && index === 0
|
||||
? () => setStarHovered(true)
|
||||
: undefined
|
||||
}
|
||||
onMouseLeave={
|
||||
pageType === "home" && index === 0
|
||||
? () => 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 = {
|
||||
buildYourSet: t('sections.buildYourSet'),
|
||||
selectSeedRate: t('sections.selectSeedRate'),
|
||||
outdoorSeason: t('sections.outdoorSeason')
|
||||
};
|
||||
|
||||
const isHome = currentPath === "/";
|
||||
const isAktionen = currentPath === "/aktionen";
|
||||
const isFiliale = currentPath === "/filiale";
|
||||
|
||||
const getNavigationConfig = () => {
|
||||
if (isHome) return { leftNav: { text: t('navigation.aktionen'), link: "/aktionen" }, rightNav: { text: t('navigation.filiale'), link: "/filiale" } };
|
||||
if (isAktionen) return { leftNav: { text: t('navigation.filiale'), link: "/filiale" }, rightNav: { text: t('navigation.home'), link: "/" } };
|
||||
if (isFiliale) return { leftNav: { text: t('navigation.home'), link: "/" }, rightNav: { text: t('navigation.aktionen'), link: "/aktionen" } };
|
||||
return { leftNav: null, rightNav: null };
|
||||
};
|
||||
|
||||
const allTitles = {
|
||||
home: t('titles.home'),
|
||||
aktionen: t('titles.aktionen'),
|
||||
filiale: t('titles.filiale')
|
||||
};
|
||||
|
||||
const allContentBoxes = {
|
||||
home: [
|
||||
{ title: t('sections.seeds'), image: "/assets/images/seeds.avif", bgcolor: "#e1f0d3", link: "/Kategorie/Seeds" },
|
||||
{ title: t('sections.konfigurator'), image: "/assets/images/konfigurator.avif", bgcolor: "#e8f5d6", link: "/Konfigurator" }
|
||||
],
|
||||
aktionen: [
|
||||
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/Artikel/Graveda-10t-presse-tagesmiete-inkl-prepress-vorpressform" },
|
||||
{ title: t('sections.thcTest'), image: "/assets/images/purpl.jpg", bgcolor: "#e8f5d6", link: "/Artikel/1x-messung-purplpro-thc-cbd-restfeuchte-wasseraktivitaet" }
|
||||
],
|
||||
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>
|
||||
<style>{STAR_DECORATION_CSS}</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;
|
||||
@@ -1,189 +0,0 @@
|
||||
import React from 'react';
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import { withLanguage } from '../i18n/withTranslation.js';
|
||||
|
||||
const ITEM_WIDTH = 140 + 16; // 140px width + 16px gap
|
||||
const AUTO_SCROLL_SPEED = 1.0;
|
||||
|
||||
class ManufacturerCarousel extends React.Component {
|
||||
_isMounted = false;
|
||||
originalItems = [];
|
||||
animationFrame = null;
|
||||
translateX = 0;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
items: [], // [{ id, name, src }]
|
||||
};
|
||||
this.carouselTrackRef = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
this.loadImages();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
if (this.animationFrame) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
this.animationFrame = null;
|
||||
}
|
||||
// Revoke object URLs to avoid memory leaks
|
||||
for (const item of this.originalItems) {
|
||||
if (item.src) URL.revokeObjectURL(item.src);
|
||||
}
|
||||
}
|
||||
|
||||
loadImages = () => {
|
||||
window.socketManager.emit('getHerstellerImages', {}, (res) => {
|
||||
if (!this._isMounted || !res?.success || !res.manufacturers?.length) return;
|
||||
|
||||
const items = res.manufacturers
|
||||
.filter(m => m.imageBuffer)
|
||||
.map(m => {
|
||||
const blob = new Blob([m.imageBuffer], { type: 'image/avif' });
|
||||
return { id: m.id, name: m.name || '', src: URL.createObjectURL(blob) };
|
||||
})
|
||||
.sort(() => Math.random() - 0.5);
|
||||
|
||||
if (items.length === 0) return;
|
||||
|
||||
this.originalItems = items;
|
||||
this.setState({ items: [...items, ...items] }, () => {
|
||||
this.startAutoScroll();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
startAutoScroll = () => {
|
||||
if (!this.animationFrame) {
|
||||
this.animationFrame = requestAnimationFrame(this.tick);
|
||||
}
|
||||
};
|
||||
|
||||
tick = () => {
|
||||
if (!this._isMounted || this.originalItems.length === 0) return;
|
||||
|
||||
this.translateX -= AUTO_SCROLL_SPEED;
|
||||
|
||||
const maxScroll = ITEM_WIDTH * this.originalItems.length;
|
||||
if (Math.abs(this.translateX) >= maxScroll) {
|
||||
this.translateX = 0;
|
||||
}
|
||||
|
||||
if (this.carouselTrackRef.current) {
|
||||
this.carouselTrackRef.current.style.transform = `translateX(${this.translateX}px)`;
|
||||
}
|
||||
|
||||
this.animationFrame = requestAnimationFrame(this.tick);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { items } = this.state;
|
||||
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="div"
|
||||
sx={{
|
||||
fontFamily: 'SwashingtonCP',
|
||||
textShadow: '3px 3px 10px rgba(0, 0, 0, 0.4)',
|
||||
textAlign: 'center',
|
||||
mb: 2,
|
||||
color: 'primary.main',
|
||||
}}
|
||||
>
|
||||
{t('product.manufacturer')}
|
||||
</Typography>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
padding: '0 20px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{/* Fade edges */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
width: '60px', height: '100%',
|
||||
background: 'linear-gradient(to right, #c8e6c9, transparent)',
|
||||
zIndex: 2, pointerEvents: 'none',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, right: 0,
|
||||
width: '60px', height: '100%',
|
||||
background: 'linear-gradient(to left, #c8e6c9, transparent)',
|
||||
zIndex: 2, pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
padding: '12px 0',
|
||||
width: '100%',
|
||||
maxWidth: '1080px',
|
||||
margin: '0 auto',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={this.carouselTrackRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
alignItems: 'center',
|
||||
width: 'fit-content',
|
||||
transform: 'translateX(0px)',
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={`${item.id}-${index}`}
|
||||
style={{
|
||||
flex: '0 0 140px',
|
||||
width: '140px',
|
||||
height: '140px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={item.src}
|
||||
alt={item.name}
|
||||
draggable={false}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(withLanguage(ManufacturerCarousel));
|
||||
381
src/components/Mollie.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -1,286 +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';
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
|
||||
class PhotoUpload extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
files: [],
|
||||
previews: [],
|
||||
error: null
|
||||
};
|
||||
this.fileInputRef = React.createRef();
|
||||
}
|
||||
|
||||
handleFileSelect = (event) => {
|
||||
const selectedFiles = Array.from(event.target.files);
|
||||
const maxFiles = this.props.maxFiles || 5;
|
||||
const maxSize = this.props.maxSize || 50 * 1024 * 1024; // 50MB default - will be compressed
|
||||
|
||||
// Validate file count
|
||||
if (this.state.files.length + selectedFiles.length > maxFiles) {
|
||||
this.setState({
|
||||
error: this.props.t("productDialogs.photoUploadErrorMaxFiles", { max: maxFiles })
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file types and sizes
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const validFiles = [];
|
||||
const newPreviews = [];
|
||||
|
||||
for (const file of selectedFiles) {
|
||||
if (!validTypes.includes(file.type)) {
|
||||
this.setState({
|
||||
error: this.props.t("productDialogs.photoUploadErrorFileType")
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
this.setState({
|
||||
error: this.props.t("productDialogs.photoUploadErrorFileSize", { maxSize: Math.round(maxSize / (1024 * 1024)) })
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
validFiles.push(file);
|
||||
|
||||
// Create preview and compress image
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
// Compress the image
|
||||
this.compressImage(e.target.result, file.name, (compressedFile) => {
|
||||
newPreviews.push({
|
||||
file: compressedFile,
|
||||
preview: e.target.result,
|
||||
name: file.name,
|
||||
originalSize: file.size,
|
||||
compressedSize: compressedFile.size
|
||||
});
|
||||
|
||||
if (newPreviews.length === validFiles.length) {
|
||||
const compressedFiles = newPreviews.map(p => p.file);
|
||||
this.setState(prevState => ({
|
||||
files: [...prevState.files, ...compressedFiles],
|
||||
previews: [...prevState.previews, ...newPreviews],
|
||||
error: null
|
||||
}), () => {
|
||||
// Notify parent component
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(this.state.files);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// Reset input
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
handleRemoveFile = (index) => {
|
||||
this.setState(prevState => {
|
||||
const newFiles = prevState.files.filter((_, i) => i !== index);
|
||||
const newPreviews = prevState.previews.filter((_, i) => i !== index);
|
||||
|
||||
// Notify parent component
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(newFiles);
|
||||
}
|
||||
|
||||
return {
|
||||
files: newFiles,
|
||||
previews: newPreviews
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
compressImage = (dataURL, fileName, callback) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
// Calculate new dimensions (max 1920x1080 for submission)
|
||||
const maxWidth = 1920;
|
||||
const maxHeight = 1080;
|
||||
let { width, height } = img;
|
||||
|
||||
if (width > height) {
|
||||
if (width > maxWidth) {
|
||||
height = (height * maxWidth) / width;
|
||||
width = maxWidth;
|
||||
}
|
||||
} else {
|
||||
if (height > maxHeight) {
|
||||
width = (width * maxHeight) / height;
|
||||
height = maxHeight;
|
||||
}
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// Draw and compress
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Convert to blob with compression
|
||||
canvas.toBlob((blob) => {
|
||||
const compressedFile = new File([blob], fileName, {
|
||||
type: 'image/jpeg',
|
||||
lastModified: Date.now()
|
||||
});
|
||||
callback(compressedFile);
|
||||
}, 'image/jpeg', 0.8); // 80% quality
|
||||
};
|
||||
|
||||
img.src = dataURL;
|
||||
};
|
||||
|
||||
// Method to reset the component
|
||||
reset = () => {
|
||||
this.setState({
|
||||
files: [],
|
||||
previews: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
// Also reset the file input
|
||||
if (this.fileInputRef.current) {
|
||||
this.fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { files, previews, error } = this.state;
|
||||
const { disabled, label, t } = this.props;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
|
||||
{label || t("productDialogs.photoUploadLabelDefault")}
|
||||
</Typography>
|
||||
|
||||
<input
|
||||
ref={this.fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={this.handleFileSelect}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<CloudUpload />}
|
||||
onClick={() => this.fileInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{t("productDialogs.photoUploadSelect")}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{previews.length > 0 && (
|
||||
<Grid container spacing={2}>
|
||||
{previews.map((preview, index) => (
|
||||
<Grid item xs={6} sm={4} md={3} key={index}>
|
||||
<Paper
|
||||
sx={{
|
||||
position: 'relative',
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={preview.preview}
|
||||
alt={preview.name}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100px',
|
||||
objectFit: 'cover',
|
||||
borderRadius: 1
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => this.handleRemoveFile(index)}
|
||||
disabled={disabled}
|
||||
aria-label={t("productDialogs.photoUploadRemove")}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0,0,0,0.9)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
left: 4,
|
||||
right: 4,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
color: 'white',
|
||||
p: 0.5,
|
||||
borderRadius: 0.5,
|
||||
fontSize: '0.7rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{preview.name}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{files.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
{t("productDialogs.photoUploadSelectedFiles", { count: files.length })}
|
||||
{previews.length > 0 && previews.some(p => p.originalSize && p.compressedSize) && (
|
||||
<span style={{ marginLeft: '8px' }}>
|
||||
{t("productDialogs.photoUploadCompressed")}
|
||||
</span>
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withI18n()(PhotoUpload);
|
||||
@@ -7,227 +7,57 @@ 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';
|
||||
import { STAR_POLYGON_POINTS } from '../utils/starPolygon.js';
|
||||
import {
|
||||
PRODUCT_CARD_MOBILE_MAX_WIDTH_PX,
|
||||
PRODUCT_CARD_WIDTH_SM_PX,
|
||||
PRODUCT_CARD_WIDTH_XS_PX,
|
||||
} from '../utils/productCardLayout.js';
|
||||
|
||||
// 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);
|
||||
|
||||
|
||||
this._isMounted = false;
|
||||
|
||||
|
||||
if (!window.smallPicCache) {
|
||||
window.smallPicCache = {};
|
||||
}
|
||||
|
||||
const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
|
||||
? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
|
||||
: [];
|
||||
|
||||
if (pictureIds.length > 0) {
|
||||
const initialImages = pictureIds.map(id => window.smallPicCache[id] || null);
|
||||
const isFirstCached = !!initialImages[0];
|
||||
|
||||
this.state = {
|
||||
images: initialImages,
|
||||
currentImageIndex: 0,
|
||||
loading: !isFirstCached,
|
||||
error: false,
|
||||
isHovering: false
|
||||
};
|
||||
|
||||
pictureIds.forEach((id, index) => {
|
||||
if (!window.smallPicCache[id]) {
|
||||
this.loadImage(id, index);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.state = { images: [], currentImageIndex: 0, loading: false, error: false, isHovering: false };
|
||||
if(this.props.pictureList && this.props.pictureList.length > 0 && this.props.pictureList.split(',').length > 0) {
|
||||
const bildId = this.props.pictureList.split(',')[0];
|
||||
if(window.smallPicCache[bildId]){
|
||||
this.state = {image:window.smallPicCache[bildId],loading:false, error: false}
|
||||
}else{
|
||||
this.state = {image: null, loading: true, error: false};
|
||||
console.log("Product: Fetching image from socketB", this.props.socketB);
|
||||
this.props.socketB.emit('getPic', { bildId, size:'small' }, (res) => {
|
||||
if(res.success){
|
||||
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
if (this._isMounted) {
|
||||
this.setState({image: window.smallPicCache[bildId], loading: false});
|
||||
} else {
|
||||
this.state.image = window.smallPicCache[bildId];
|
||||
this.state.loading = false;
|
||||
}
|
||||
}else{
|
||||
console.log('Fehler beim Laden des Bildes:', res);
|
||||
if (this._isMounted) {
|
||||
this.setState({error: true, loading: false});
|
||||
} else {
|
||||
this.state.error = true;
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}else{
|
||||
this.state = {image: null, loading: false, error: false};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
this.startRandomFading();
|
||||
}
|
||||
|
||||
startRandomFading = () => {
|
||||
if (this.state.isHovering) return;
|
||||
|
||||
const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
|
||||
? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
|
||||
: [];
|
||||
|
||||
if (pictureIds.length > 1) {
|
||||
const minInterval = 4000;
|
||||
const maxInterval = 8000;
|
||||
const randomInterval = Math.floor(Math.random() * (maxInterval - minInterval + 1)) + minInterval;
|
||||
|
||||
this.fadeTimeout = setTimeout(() => {
|
||||
if (this._isMounted) {
|
||||
this.setState(prevState => {
|
||||
let nextIndex = (prevState.currentImageIndex + 1) % pictureIds.length;
|
||||
let attempts = 0;
|
||||
while (!prevState.images[nextIndex] && attempts < pictureIds.length) {
|
||||
nextIndex = (nextIndex + 1) % pictureIds.length;
|
||||
attempts++;
|
||||
}
|
||||
if (attempts < pictureIds.length && nextIndex !== prevState.currentImageIndex) {
|
||||
return { currentImageIndex: nextIndex };
|
||||
}
|
||||
return null;
|
||||
}, () => {
|
||||
this.startRandomFading();
|
||||
});
|
||||
}
|
||||
}, randomInterval);
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseMove = (e) => {
|
||||
const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
|
||||
? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
|
||||
: [];
|
||||
|
||||
if (pictureIds.length > 1) {
|
||||
if (this.fadeTimeout) {
|
||||
clearTimeout(this.fadeTimeout);
|
||||
this.fadeTimeout = null;
|
||||
}
|
||||
|
||||
const { left, width } = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - left;
|
||||
|
||||
const segmentWidth = width / pictureIds.length;
|
||||
let targetIndex = Math.floor(x / segmentWidth);
|
||||
if (targetIndex >= pictureIds.length) targetIndex = pictureIds.length - 1;
|
||||
if (targetIndex < 0) targetIndex = 0;
|
||||
|
||||
if (this.state.currentImageIndex !== targetIndex) {
|
||||
if (this.state.images[targetIndex]) {
|
||||
this.setState({ currentImageIndex: targetIndex, isHovering: true });
|
||||
} else {
|
||||
this.setState({ isHovering: true });
|
||||
}
|
||||
} else if (!this.state.isHovering) {
|
||||
this.setState({ isHovering: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseLeave = () => {
|
||||
if (this.state.isHovering) {
|
||||
this.setState({ isHovering: false }, () => {
|
||||
this.startRandomFading();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
loadImage = (bildId, index) => {
|
||||
console.log('loadImagevisSocket', bildId);
|
||||
window.socketManager.emit('getPic', { bildId, size: 'small' }, (res) => {
|
||||
if (res.success) {
|
||||
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
|
||||
if (this._isMounted) {
|
||||
this.setState(prevState => {
|
||||
const newImages = [...prevState.images];
|
||||
newImages[index] = window.smallPicCache[bildId];
|
||||
return {
|
||||
images: newImages,
|
||||
loading: index === 0 ? false : prevState.loading
|
||||
};
|
||||
});
|
||||
} else {
|
||||
this.state.images[index] = window.smallPicCache[bildId];
|
||||
if (index === 0) this.state.loading = false;
|
||||
}
|
||||
} else {
|
||||
console.log('Fehler beim Laden des Bildes:', res);
|
||||
if (this._isMounted) {
|
||||
this.setState(prevState => ({
|
||||
error: index === 0 ? true : prevState.error,
|
||||
loading: index === 0 ? false : prevState.loading
|
||||
}));
|
||||
} else {
|
||||
if (index === 0) {
|
||||
this.state.error = true;
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
if (this.fadeTimeout) {
|
||||
clearTimeout(this.fadeTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
handleQuantityChange = (quantity) => {
|
||||
@@ -235,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 {
|
||||
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);
|
||||
@@ -276,21 +89,12 @@ class Product extends Component {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
position: 'relative',
|
||||
<Box sx={{
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
/* Match card width on xs so absolute NEU star is relative to the card, not the full grid row */
|
||||
width: {
|
||||
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
|
||||
sm: 'auto',
|
||||
},
|
||||
minWidth: { xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`, sm: 'auto' },
|
||||
maxWidth: { xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`, sm: 'none' },
|
||||
display: 'flex',
|
||||
justifyContent: { xs: 'center', sm: 'flex-start' },
|
||||
mx: { xs: 'auto', sm: 0 },
|
||||
width: { xs: '100%', sm: 'auto' }
|
||||
}}>
|
||||
{isNew && (
|
||||
<div
|
||||
@@ -305,9 +109,9 @@ class Product extends Component {
|
||||
}}
|
||||
>
|
||||
{/* Background star - slightly larger and rotated */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="56"
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="56"
|
||||
height="56"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -316,17 +120,17 @@ class Product extends Component {
|
||||
transform: 'rotate(20deg)'
|
||||
}}
|
||||
>
|
||||
<polygon
|
||||
points={STAR_POLYGON_POINTS}
|
||||
fill="#20403a"
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#20403a"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
{/* Middle star - medium size with different rotation */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="53"
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="53"
|
||||
height="53"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -335,31 +139,31 @@ class Product extends Component {
|
||||
transform: 'rotate(-25deg)'
|
||||
}}
|
||||
>
|
||||
<polygon
|
||||
points={STAR_POLYGON_POINTS}
|
||||
fill="#40736b"
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#40736b"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
{/* Foreground star - main star with text */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="50"
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="50"
|
||||
height="50"
|
||||
>
|
||||
<polygon
|
||||
points={STAR_POLYGON_POINTS}
|
||||
fill="#609688"
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#609688"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
{/* Text as a separate element to position it at the top */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '40%',
|
||||
top: '45%',
|
||||
left: '45%',
|
||||
transform: 'translate(-50%, -50%) rotate(-10deg)',
|
||||
color: 'white',
|
||||
@@ -369,43 +173,29 @@ class Product extends Component {
|
||||
zIndex: 1000
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t('product.new') : 'NEU'}
|
||||
NEU
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card
|
||||
sx={{
|
||||
width: {
|
||||
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
|
||||
sm: `${PRODUCT_CARD_WIDTH_SM_PX}px`,
|
||||
},
|
||||
minWidth: {
|
||||
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
|
||||
sm: `${PRODUCT_CARD_WIDTH_SM_PX}px`,
|
||||
},
|
||||
maxWidth: {
|
||||
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
|
||||
sm: `${PRODUCT_CARD_WIDTH_SM_PX}px`,
|
||||
},
|
||||
|
||||
<Card
|
||||
sx={{
|
||||
width: { xs: '100vw', sm: '250px' },
|
||||
minWidth: { xs: '100vw', sm: '250px' },
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: { xs: '8px', sm: '8px' },
|
||||
border: { xs: '1px solid', sm: 'inherit' },
|
||||
borderColor: { xs: 'divider', sm: 'inherit' },
|
||||
boxShadow: { xs: '0 1px 4px rgba(0,0,0,0.08)', sm: 'inherit' },
|
||||
mx: { xs: 'auto', sm: 'auto' },
|
||||
borderRadius: { xs: 0, sm: '8px' },
|
||||
border: { xs: 'none', sm: 'inherit' },
|
||||
boxShadow: { xs: 'none', sm: 'inherit' },
|
||||
mx: { xs: 0, sm: 'auto' },
|
||||
'&:hover': {
|
||||
transform: { xs: 'none', sm: 'translateY(-5px)' },
|
||||
boxShadow: {
|
||||
xs: '0 1px 4px rgba(0,0,0,0.08)',
|
||||
sm: '0px 10px 20px rgba(0,0,0,0.1)',
|
||||
},
|
||||
},
|
||||
boxShadow: { xs: 'none', sm: '0px 10px 20px rgba(0,0,0,0.1)' }
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showThcBadge && (
|
||||
@@ -450,102 +240,68 @@ class Product extends Component {
|
||||
transformOrigin: 'top left'
|
||||
}}
|
||||
>
|
||||
{floweringWeeks} {this.props.t ? this.props.t('product.weeks') : 'Wochen'}
|
||||
{floweringWeeks} Wochen
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<Box
|
||||
onClick={this.handleProductClick}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
component={Link}
|
||||
to={`/Artikel/${seoName}`}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer'
|
||||
color: 'inherit'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onMouseMove={this.handleMouseMove}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height: { xs: '240px', sm: '180px' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#ffffff',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px'
|
||||
}}>
|
||||
<Box sx={{
|
||||
position: 'relative',
|
||||
height: { xs: '240px', sm: '180px' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#ffffff',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px'
|
||||
}}>
|
||||
{this.state.loading ? (
|
||||
<CircularProgress sx={{ color: '#90ffc0' }} />
|
||||
) : this.state.images && this.state.images.length > 0 && this.state.images.some(img => img !== null) ? (
|
||||
this.state.images.map((imgSrc, index) => {
|
||||
if (!imgSrc) return null;
|
||||
return (
|
||||
<CardMedia
|
||||
key={index}
|
||||
component="img"
|
||||
height={window.innerWidth < PRODUCT_CARD_MOBILE_MAX_WIDTH_PX ? "240" : "180"}
|
||||
image={imgSrc}
|
||||
alt={name}
|
||||
fetchPriority={this.props.priority === 'high' && index === 0 ? 'high' : 'auto'}
|
||||
loading={this.props.priority === 'high' && index === 0 ? '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',
|
||||
borderTopRightRadius: '8px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
opacity: this.state.currentImageIndex === index ? 1 : 0,
|
||||
transition: this.state.isHovering ? 'opacity 0.2s ease-in-out' : 'opacity 1s ease-in-out'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
) : this.state.image === null ? (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height={ window.innerWidth < 600 ? "240" : "180" }
|
||||
image="/assets/images/nopicture.jpg"
|
||||
alt={name}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px',
|
||||
width: '100%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height={window.innerWidth < PRODUCT_CARD_MOBILE_MAX_WIDTH_PX ? "240" : "180"}
|
||||
image="/assets/images/nopicture.jpg"
|
||||
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',
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0
|
||||
width: '100%'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<CardContent sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
<CardContent sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'&.MuiCardContent-root:last-child': {
|
||||
paddingBottom: 0
|
||||
}
|
||||
@@ -566,73 +322,43 @@ class Product extends Component {
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" style={{ minHeight: '1.5em' }}>
|
||||
<Typography variant="body2" color="text.secondary" style={{minHeight:'1.5em'}}>
|
||||
{manufacturer || ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<div style={{padding:'0px',margin:'0px',minHeight:'3.8em'}}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="primary"
|
||||
sx={{ mt: 'auto', fontWeight: 'bold', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
|
||||
>
|
||||
<span>{new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price)}</span>
|
||||
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>(incl. {vat}% USt.,*)</small>
|
||||
|
||||
|
||||
<div style={{ padding: '0px', margin: '0px' }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="primary"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative', display: 'inline-block' }}>
|
||||
{this.props.rebate && this.props.rebate > 0 && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
left: -8,
|
||||
fontWeight: 'bold',
|
||||
color: 'red',
|
||||
textDecoration: 'line-through',
|
||||
opacity: 0.4,
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
fontSize: 'inherit'
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const rebatePct = this.props.rebate / 100;
|
||||
const originalPrice = Math.round((price / (1 - rebatePct)) * 10) / 10;
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency || 'EUR' }).format(originalPrice);
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ position: 'relative', zIndex: 2 }}>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency || 'EUR' }).format(price)}</span>
|
||||
</Box>
|
||||
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>({this.props.t ? this.props.t('product.inclVatFooter', { vat }) : `incl. ${vat}% USt.,*`})</small>
|
||||
</Typography>
|
||||
|
||||
</Typography>
|
||||
{massMenge != 1 && massEinheit && (<Typography variant="body2" color="text.secondary" sx={{ m: 0,p: 0 }}>
|
||||
({new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price/massMenge)}/{massEinheit})
|
||||
</Typography> )}
|
||||
</div>
|
||||
<div style={{ minHeight: '1.5em' }}>
|
||||
{cGrundEinheit && fGrundPreis && fGrundPreis != price && (<Typography variant="body2" color="text.secondary" sx={{ m: 0, p: 0 }}>
|
||||
({new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency || 'EUR' }).format(fGrundPreis)}/{cGrundEinheit})
|
||||
</Typography>)}
|
||||
</div>
|
||||
{/*incoming*/}
|
||||
{/*incoming*/}
|
||||
</CardContent>
|
||||
</Box>
|
||||
|
||||
|
||||
<Box sx={{ p: 2, pt: 0, display: 'flex', alignItems: 'center' }}>
|
||||
<IconButton
|
||||
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>
|
||||
@@ -640,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;
|
||||
|
||||
@@ -1,773 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import ChevronLeft from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRight from "@mui/icons-material/ChevronRight";
|
||||
import Product from "./Product.js";
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import { withLanguage } from '../i18n/withTranslation.js';
|
||||
import {
|
||||
getProductCarouselItemStridePx,
|
||||
PRODUCT_CARD_WIDTH_SM_PX,
|
||||
PRODUCT_CARD_WIDTH_XS_PX,
|
||||
} from "../utils/productCardLayout.js";
|
||||
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
|
||||
|
||||
const CAROUSEL_LOG = "[ProductCarousel]";
|
||||
|
||||
function logCarousel(phase, detail) {
|
||||
try {
|
||||
console.log(CAROUSEL_LOG, phase, detail != null ? detail : "");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Debug summary of getCategoryProducts callback / socket payload. */
|
||||
function summarizeResponse(response, maxProducts = 8) {
|
||||
if (response == null) {
|
||||
return { type: "null" };
|
||||
}
|
||||
if (typeof response !== "object") {
|
||||
return { type: typeof response, value: response };
|
||||
}
|
||||
const keys = Object.keys(response);
|
||||
const products = response.products;
|
||||
const n = Array.isArray(products) ? products.length : null;
|
||||
const sample =
|
||||
Array.isArray(products) && products.length > 0
|
||||
? products.slice(0, maxProducts).map((p) => ({
|
||||
id: p?.id,
|
||||
seoName: p?.seoName,
|
||||
available: p?.available,
|
||||
availableSupplier: p?.availableSupplier,
|
||||
hasPictureList: Boolean(
|
||||
p?.pictureList &&
|
||||
(typeof p.pictureList === "string"
|
||||
? p.pictureList.trim().length > 0
|
||||
: Array.isArray(p.pictureList)
|
||||
? p.pictureList.length > 0
|
||||
: false)
|
||||
),
|
||||
}))
|
||||
: [];
|
||||
return { keys, productsLength: n, sample };
|
||||
}
|
||||
|
||||
function productHasImage(p) {
|
||||
const pl = p?.pictureList;
|
||||
if (!pl) return false;
|
||||
if (typeof pl === "string") return pl.trim().length > 0;
|
||||
if (Array.isArray(pl)) return pl.length > 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
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,
|
||||
itemStride:
|
||||
typeof window !== "undefined"
|
||||
? getProductCarouselItemStridePx()
|
||||
: PRODUCT_CARD_WIDTH_SM_PX + 16,
|
||||
};
|
||||
|
||||
this.carouselTrackRef = React.createRef();
|
||||
}
|
||||
|
||||
handleCarouselResize = () => {
|
||||
if (!this._isMounted) return;
|
||||
const next = getProductCarouselItemStridePx();
|
||||
if (next !== this.state.itemStride) {
|
||||
this.translateX = 0;
|
||||
this.updateTrackTransform();
|
||||
this.setState({ itemStride: next });
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("resize", this.handleCarouselResize);
|
||||
this.setState({ itemStride: getProductCarouselItemStridePx() });
|
||||
}
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
|
||||
|
||||
logCarousel("mount", {
|
||||
categoryId: this.props.categoryId,
|
||||
language: currentLanguage,
|
||||
filter_availability:
|
||||
typeof sessionStorage !== "undefined"
|
||||
? sessionStorage.getItem("filter_availability")
|
||||
: "(no sessionStorage)",
|
||||
});
|
||||
this.loadProducts(currentLanguage);
|
||||
}
|
||||
|
||||
categoryIdsEqual(a, b) {
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
return a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
}
|
||||
return a === b;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const lang = this.props.languageContext?.currentLanguage || this.props.i18n.language;
|
||||
const langChanged =
|
||||
prevProps.languageContext?.currentLanguage !==
|
||||
this.props.languageContext?.currentLanguage;
|
||||
const catChanged = !this.categoryIdsEqual(
|
||||
prevProps.categoryId,
|
||||
this.props.categoryId
|
||||
);
|
||||
if (langChanged || catChanged) {
|
||||
logCarousel("didUpdate:reload", {
|
||||
langChanged,
|
||||
catChanged,
|
||||
prevCategoryId: prevProps.categoryId,
|
||||
categoryId: this.props.categoryId,
|
||||
language: lang,
|
||||
});
|
||||
}
|
||||
if (langChanged) {
|
||||
this.setState({ products: [] }, () => {
|
||||
this.loadProducts(lang);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (catChanged) {
|
||||
this.setState({ products: [] }, () => {
|
||||
this.loadProducts(lang);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Virtual slugs go to the socket as strings; other ids pass through. */
|
||||
normalizeSocketCategoryId(id) {
|
||||
if (id === "neu" || id === "bald") return id;
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* neu: stub only (no `full`). bald: `full: true` — one emit ack with the full list.
|
||||
* Carousel does not subscribe to `productList:*` (no second update).
|
||||
*/
|
||||
buildGetCategoryProductsPayload(id, language) {
|
||||
const slug = this.normalizeSocketCategoryId(id);
|
||||
const payload = {
|
||||
categoryId: slug,
|
||||
language,
|
||||
requestTranslation: language === "de" ? false : true,
|
||||
};
|
||||
if (slug === "bald") {
|
||||
payload.full = true;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Single getCategoryProducts response (emit ack only). */
|
||||
emitGetCategoryProductsOnce = (rawId, language, onAck) => {
|
||||
const slug = this.normalizeSocketCategoryId(rawId);
|
||||
const payload = this.buildGetCategoryProductsPayload(rawId, language);
|
||||
logCarousel("emit getCategoryProducts (ack only)", { slug, payload });
|
||||
window.socketManager.emit("getCategoryProducts", payload, (response) => {
|
||||
logCarousel("getCategoryProducts CALLBACK", {
|
||||
slug,
|
||||
summary: summarizeResponse(response),
|
||||
});
|
||||
onAck(response);
|
||||
});
|
||||
};
|
||||
|
||||
finalizeCarouselList = (products) => {
|
||||
if (!this._isMounted) {
|
||||
logCarousel("finalizeCarouselList:skip (unmounted)", {
|
||||
incomingLen: products?.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!products || products.length === 0) {
|
||||
logCarousel("finalizeCarouselList:skip (empty)", {});
|
||||
return;
|
||||
}
|
||||
const shuffledProducts = this.shuffleArray(products).slice(0, 15);
|
||||
logCarousel("finalizeCarouselList:ok", {
|
||||
inputCount: products.length,
|
||||
afterShuffleCap15: shuffledProducts.length,
|
||||
ids: shuffledProducts.map((p) => p?.id),
|
||||
});
|
||||
|
||||
this.originalProducts = shuffledProducts;
|
||||
this.products = [...shuffledProducts, ...shuffledProducts];
|
||||
this.setState({ products: this.products });
|
||||
this.startAutoScroll();
|
||||
};
|
||||
|
||||
processLoadedProducts = (rawProducts, sourceTag = "processLoadedProducts") => {
|
||||
if (!this._isMounted) {
|
||||
logCarousel(`${sourceTag}:skip (unmounted)`, {
|
||||
rawLen: rawProducts?.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!rawProducts || rawProducts.length === 0) {
|
||||
logCarousel(`${sourceTag}:skip (empty products)`, {});
|
||||
return;
|
||||
}
|
||||
logCarousel(`${sourceTag}:incoming`, {
|
||||
count: rawProducts.length,
|
||||
idsSample: rawProducts.slice(0, 12).map((p) => p?.id),
|
||||
});
|
||||
this.finalizeCarouselList(rawProducts.slice());
|
||||
};
|
||||
|
||||
/** Merge neu + bald; neu ids win on duplicate. */
|
||||
processNeuAndBaldMerged = (neuRaw, baldRaw) => {
|
||||
const neuList = neuRaw || [];
|
||||
const baldList = baldRaw || [];
|
||||
|
||||
const seen = new Set();
|
||||
const merged = [];
|
||||
for (const p of neuList) {
|
||||
if (p && p.id != null && !seen.has(p.id)) {
|
||||
seen.add(p.id);
|
||||
merged.push(p);
|
||||
}
|
||||
}
|
||||
let baldWithImage = 0;
|
||||
for (const p of baldList) {
|
||||
if (!p || p.id == null || seen.has(p.id)) continue;
|
||||
if (!productHasImage(p)) continue;
|
||||
baldWithImage += 1;
|
||||
seen.add(p.id);
|
||||
merged.push(p);
|
||||
}
|
||||
|
||||
logCarousel("processNeuAndBaldMerged:counts", {
|
||||
neu: neuList.length,
|
||||
bald: baldList.length,
|
||||
baldWithImage,
|
||||
mergedUnique: merged.length,
|
||||
neuIdsSample: neuList.slice(0, 8).map((p) => p?.id),
|
||||
baldIdsSample: baldList.slice(0, 8).map((p) => p?.id),
|
||||
});
|
||||
|
||||
if (merged.length === 0) {
|
||||
logCarousel("processNeuAndBaldMerged:skip (merged empty)", {});
|
||||
return;
|
||||
}
|
||||
this.finalizeCarouselList(merged);
|
||||
};
|
||||
|
||||
loadProducts = (language) => {
|
||||
const { categoryId } = this.props;
|
||||
const ids = Array.isArray(categoryId) ? categoryId : [categoryId];
|
||||
const filterAvailability =
|
||||
typeof sessionStorage !== "undefined"
|
||||
? sessionStorage.getItem("filter_availability")
|
||||
: null;
|
||||
|
||||
logCarousel("loadProducts:start", {
|
||||
language,
|
||||
categoryId,
|
||||
ids,
|
||||
filter_availability: filterAvailability,
|
||||
});
|
||||
|
||||
if (ids.length === 1) {
|
||||
const slug = this.normalizeSocketCategoryId(ids[0]);
|
||||
this.emitGetCategoryProductsOnce(ids[0], language, (response) => {
|
||||
let products = response?.products ?? [];
|
||||
if (slug === "bald") {
|
||||
const before = products.length;
|
||||
products = products.filter(productHasImage);
|
||||
logCarousel("loadProducts:single:bald filter image", {
|
||||
before,
|
||||
after: products.length,
|
||||
});
|
||||
}
|
||||
if (products.length > 0) {
|
||||
this.processLoadedProducts(products, "single");
|
||||
} else {
|
||||
logCarousel("loadProducts:single: empty", { slug });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const neuBaldMode =
|
||||
ids.length === 2 &&
|
||||
ids.map((x) => this.normalizeSocketCategoryId(x)).sort().join(",") ===
|
||||
"bald,neu";
|
||||
|
||||
if (neuBaldMode) {
|
||||
logCarousel("loadProducts:mode neu+bald (stub neu + full bald → merge → shuffle → max 15)", {
|
||||
ids,
|
||||
});
|
||||
|
||||
let neuProducts = null;
|
||||
let baldProducts = null;
|
||||
|
||||
const tryMerge = () => {
|
||||
if (neuProducts === null || baldProducts === null) {
|
||||
return;
|
||||
}
|
||||
logCarousel("loadProducts:neu+bald merge inputs", {
|
||||
neuLen: neuProducts.length,
|
||||
baldLen: baldProducts.length,
|
||||
});
|
||||
this.processNeuAndBaldMerged(neuProducts, baldProducts);
|
||||
};
|
||||
|
||||
this.emitGetCategoryProductsOnce("neu", language, (response) => {
|
||||
neuProducts = response?.products ?? [];
|
||||
tryMerge();
|
||||
});
|
||||
this.emitGetCategoryProductsOnce("bald", language, (response) => {
|
||||
baldProducts = response?.products ?? [];
|
||||
tryMerge();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logCarousel("loadProducts:mode generic merge", { ids });
|
||||
|
||||
const mergedById = new Map();
|
||||
let remaining = ids.length;
|
||||
|
||||
const onResponse = (response, idForLog) => {
|
||||
logCarousel("loadProducts:merge CALLBACK", {
|
||||
id: idForLog,
|
||||
summary: summarizeResponse(response),
|
||||
});
|
||||
let products = response?.products ?? [];
|
||||
if (this.normalizeSocketCategoryId(idForLog) === "bald") {
|
||||
products = products.filter(productHasImage);
|
||||
}
|
||||
if (products.length > 0) {
|
||||
for (const p of products) {
|
||||
if (p && p.id != null && !mergedById.has(p.id)) {
|
||||
mergedById.set(p.id, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
remaining -= 1;
|
||||
if (remaining === 0) {
|
||||
logCarousel("loadProducts:merge complete", {
|
||||
unique: mergedById.size,
|
||||
idsSample: Array.from(mergedById.keys()).slice(0, 15),
|
||||
});
|
||||
this.processLoadedProducts(
|
||||
Array.from(mergedById.values()),
|
||||
"genericMerge"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ids.forEach((id) => {
|
||||
this.emitGetCategoryProductsOnce(id, language, (response) => {
|
||||
onResponse(response, id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("resize", this.handleCarouselResize);
|
||||
}
|
||||
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 { itemStride } = this.state;
|
||||
const originalItemCount = this.originalProducts.length;
|
||||
const maxScroll = itemStride * 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 { itemStride } = this.state;
|
||||
const originalItemCount = this.originalProducts.length;
|
||||
const maxScroll = itemStride * originalItemCount;
|
||||
|
||||
this.translateX += direction * itemStride;
|
||||
|
||||
// Handle wrap-around when scrolling left (positive translateX)
|
||||
if (this.translateX > 0) {
|
||||
this.translateX = -(maxScroll - itemStride);
|
||||
}
|
||||
// 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 { itemStride } = this.state;
|
||||
const originalItemCount = this.originalProducts.length;
|
||||
const viewportWidth =
|
||||
typeof window !== "undefined"
|
||||
? Math.min(1080, Math.max(0, window.innerWidth - 56))
|
||||
: 1080;
|
||||
const itemsInView = Math.max(1, Math.floor(viewportWidth / itemStride));
|
||||
|
||||
// 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 = itemStride * originalItemCount;
|
||||
const effectivePosition = maxScroll + this.translateX;
|
||||
currentItemIndex = Math.floor(effectivePosition / itemStride);
|
||||
} else {
|
||||
currentItemIndex = Math.floor(Math.abs(this.translateX) / itemStride);
|
||||
}
|
||||
|
||||
// Ensure we stay within bounds
|
||||
currentItemIndex = Math.max(0, Math.min(currentItemIndex, originalItemCount - 1));
|
||||
|
||||
// Calculate scrollbar position
|
||||
const lastPossibleFirstItem = Math.max(0, originalItemCount - itemsInView);
|
||||
const thumbPosition = lastPossibleFirstItem > 0 ? Math.min((currentItemIndex / lastPossibleFirstItem) * 100, 100) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="virtual-scrollbar"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '5px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '200px',
|
||||
height: '4px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: '2px',
|
||||
zIndex: 1000,
|
||||
opacity: this.state.showScrollbar ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease-in-out'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="scrollbar-thumb"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: `${thumbPosition}%`,
|
||||
width: '20px',
|
||||
height: '4px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: '2px',
|
||||
transform: 'translateX(-50%)',
|
||||
transition: 'left 0.2s ease-out'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, title } = this.props;
|
||||
const { products } = this.state;
|
||||
|
||||
if (!products || products.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Box
|
||||
component={Link}
|
||||
to="/Kategorie/neu"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textDecoration: "none",
|
||||
color: "primary.main",
|
||||
mb: 2,
|
||||
transition: "all 0.3s ease",
|
||||
"&:hover": {
|
||||
transform: "translateX(5px)",
|
||||
color: "primary.dark"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: "SwashingtonCP",
|
||||
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
|
||||
}}
|
||||
>
|
||||
{title || t('product.new')}
|
||||
</Typography>
|
||||
<ChevronRight sx={{ fontSize: "2.5rem", ml: 1 }} />
|
||||
</Box>
|
||||
|
||||
<div
|
||||
className="product-carousel-wrapper"
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'visible',
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
padding: '0 20px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
{/* Left Arrow */}
|
||||
<IconButton
|
||||
aria-label="Vorherige Produkte anzeigen"
|
||||
onClick={this.handleLeftClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '8px',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 1000,
|
||||
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: 1000,
|
||||
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) => (
|
||||
<Box
|
||||
key={`${product.id}-${index}`}
|
||||
className="product-carousel-item"
|
||||
sx={{
|
||||
flex: {
|
||||
xs: `0 0 ${PRODUCT_CARD_WIDTH_XS_PX}px`,
|
||||
sm: `0 0 ${PRODUCT_CARD_WIDTH_SM_PX}px`,
|
||||
},
|
||||
width: { xs: PRODUCT_CARD_WIDTH_XS_PX, sm: PRODUCT_CARD_WIDTH_SM_PX },
|
||||
maxWidth: { xs: PRODUCT_CARD_WIDTH_XS_PX, sm: PRODUCT_CARD_WIDTH_SM_PX },
|
||||
minWidth: { xs: PRODUCT_CARD_WIDTH_XS_PX, sm: PRODUCT_CARD_WIDTH_SM_PX },
|
||||
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}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</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));
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// This file re-exports ProductDetailWithSocket to maintain compatibility with App.js imports
|
||||
import ProductDetailWithSocket from './ProductDetailWithSocket.js';
|
||||
|
||||
export default ProductDetailWithSocket;
|
||||
@@ -1,22 +1,19 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import SocketContext from '../contexts/SocketContext.js';
|
||||
import ProductDetailPage from './ProductDetailPage.js';
|
||||
import { useProduct } from '../context/ProductContext.js';
|
||||
|
||||
// Wrapper component for individual product detail page with socket
|
||||
const ProductDetailWithSocket = () => {
|
||||
const { seoName } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { setCurrentProduct } = useProduct();
|
||||
|
||||
return (
|
||||
<ProductDetailPage
|
||||
seoName={seoName}
|
||||
navigate={navigate}
|
||||
location={location}
|
||||
setCurrentProduct={setCurrentProduct}
|
||||
/>
|
||||
<SocketContext.Consumer>
|
||||
{({socket,socketB}) => <ProductDetailPage seoName={seoName} navigate={navigate} location={location} socket={socket} socketB={socketB} />}
|
||||
</SocketContext.Consumer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailWithSocket;
|
||||
export default ProductDetailWithSocket;
|
||||
@@ -1,35 +1,12 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
|
||||
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';
|
||||
import {
|
||||
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
|
||||
emitPushSubscriptionsChanged,
|
||||
isPushApiSupported,
|
||||
fetchPushConfiguration,
|
||||
registerPushServiceWorker,
|
||||
ensurePushSubscription,
|
||||
categoryPushStatus,
|
||||
categoryPushSubscribe,
|
||||
categoryPushUnsubscribe,
|
||||
parseSubscribedStatus,
|
||||
parseSuccess,
|
||||
} from '../utils/categoryPush.js';
|
||||
|
||||
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
/** Category push subscribe UI only when the category has more than this many articles. */
|
||||
const MIN_ARTICLES_FOR_CATEGORY_PUSH = 10;
|
||||
|
||||
// HOC to provide router props to class components
|
||||
const withRouter = (ClassComponent) => {
|
||||
return (props) => {
|
||||
@@ -58,230 +35,30 @@ class ProductFilters extends Component {
|
||||
this.state = {
|
||||
availabilityValues,
|
||||
uniqueManufacturerArray,
|
||||
attributeGroups,
|
||||
manufacturerImages: new Map(), // id (number) → object URL
|
||||
pushInteractive: false,
|
||||
pushSubscribed: false,
|
||||
pushBusy: false,
|
||||
pushError: null,
|
||||
attributeGroups
|
||||
};
|
||||
|
||||
this._manufacturerImageUrls = []; // track for cleanup
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.onPushSubscriptionsChanged = () => {
|
||||
this.refreshCategoryPushStatus();
|
||||
};
|
||||
// Measure the available space dynamically
|
||||
this.adjustPaperHeight();
|
||||
// Add event listener for window resize
|
||||
window.addEventListener('resize', this.adjustPaperHeight);
|
||||
window.addEventListener(
|
||||
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
|
||||
this.onPushSubscriptionsChanged
|
||||
);
|
||||
this._loadManufacturerImages();
|
||||
this.refreshCategoryPushStatus();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Remove event listener when component unmounts
|
||||
window.removeEventListener('resize', this.adjustPaperHeight);
|
||||
window.removeEventListener(
|
||||
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
|
||||
this.onPushSubscriptionsChanged
|
||||
);
|
||||
this._manufacturerImageUrls.forEach(url => URL.revokeObjectURL(url));
|
||||
}
|
||||
|
||||
_loadManufacturerImages = () => {
|
||||
window.socketManager.emit('getHerstellerImages', {}, (res) => {
|
||||
if (!res?.success || !res.manufacturers?.length) return;
|
||||
const map = new Map();
|
||||
for (const m of res.manufacturers) {
|
||||
if (!m.imageBuffer) continue;
|
||||
const blob = new Blob([m.imageBuffer], { type: 'image/avif' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
map.set(m.id, url);
|
||||
this._manufacturerImageUrls.push(url);
|
||||
}
|
||||
this.setState({ manufacturerImages: map });
|
||||
});
|
||||
};
|
||||
|
||||
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});
|
||||
}
|
||||
|
||||
const prevCount = prevProps.products?.length || 0;
|
||||
const nextCount = this.props.products?.length || 0;
|
||||
if (
|
||||
prevProps.dataParam !== this.props.dataParam ||
|
||||
prevProps.dataType !== this.props.dataType ||
|
||||
prevProps.params?.categoryId !== this.props.params?.categoryId ||
|
||||
prevCount !== nextCount
|
||||
) {
|
||||
this.refreshCategoryPushStatus();
|
||||
}
|
||||
}
|
||||
|
||||
kKategorieNumber = () => {
|
||||
const { dataParam, dataType } = this.props;
|
||||
if (dataType !== 'category') return null;
|
||||
if (dataParam == null || dataParam === '') return null;
|
||||
const n = typeof dataParam === 'number' ? dataParam : parseInt(String(dataParam), 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
};
|
||||
|
||||
shouldShowCategoryPush = () =>
|
||||
(this.props.products?.length || 0) > MIN_ARTICLES_FOR_CATEGORY_PUSH;
|
||||
|
||||
refreshCategoryPushStatus = async () => {
|
||||
const kKat = this.kKategorieNumber();
|
||||
if (!kKat || !this.shouldShowCategoryPush() || !isPushApiSupported()) {
|
||||
this.setState({
|
||||
pushInteractive: false,
|
||||
pushSubscribed: false,
|
||||
pushError: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cfg = await fetchPushConfiguration();
|
||||
if (!cfg.configured || !cfg.publicKey) {
|
||||
this.setState({ pushInteractive: false });
|
||||
return;
|
||||
}
|
||||
await registerPushServiceWorker();
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) {
|
||||
this.setState({
|
||||
pushInteractive: true,
|
||||
pushSubscribed: false,
|
||||
pushError: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const statusData = await categoryPushStatus(kKat, subscription.endpoint);
|
||||
this.setState({
|
||||
pushInteractive: true,
|
||||
pushSubscribed: parseSubscribedStatus(statusData),
|
||||
pushError: null,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('ProductFilters: category push init failed', e);
|
||||
this.setState({ pushInteractive: false });
|
||||
}
|
||||
};
|
||||
|
||||
handleCategoryPushClick = async () => {
|
||||
const t = this.props.t;
|
||||
if (!this.state.pushInteractive || this.state.pushBusy) return;
|
||||
const kKat = this.kKategorieNumber();
|
||||
if (!kKat || !this.shouldShowCategoryPush()) return;
|
||||
this.setState({ pushBusy: true, pushError: null });
|
||||
try {
|
||||
if (this.state.pushSubscribed) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) {
|
||||
this.setState({ pushSubscribed: false, pushBusy: false });
|
||||
return;
|
||||
}
|
||||
const res = await categoryPushUnsubscribe(subscription.endpoint, kKat);
|
||||
if (parseSuccess(res)) {
|
||||
this.setState({ pushSubscribed: false });
|
||||
emitPushSubscriptionsChanged();
|
||||
} else {
|
||||
this.setState({
|
||||
pushError:
|
||||
res?.message ||
|
||||
res?.error ||
|
||||
(t ? t('productDialogs.pushNotifyError') : ''),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const perm = await Notification.requestPermission();
|
||||
if (perm !== 'granted') {
|
||||
this.setState({
|
||||
pushError: t ? t('productDialogs.pushNotifyPermissionDenied') : '',
|
||||
pushBusy: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const cfg = await fetchPushConfiguration();
|
||||
if (!cfg.configured || !cfg.publicKey) {
|
||||
this.setState({
|
||||
pushError: t ? t('productDialogs.pushNotifyServerDisabled') : '',
|
||||
pushBusy: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await registerPushServiceWorker();
|
||||
const subscription = await ensurePushSubscription(cfg.publicKey);
|
||||
const res = await categoryPushSubscribe(kKat, subscription);
|
||||
if (parseSuccess(res)) {
|
||||
this.setState({ pushSubscribed: true });
|
||||
emitPushSubscriptionsChanged();
|
||||
} else {
|
||||
this.setState({
|
||||
pushError:
|
||||
res?.message ||
|
||||
res?.error ||
|
||||
(t ? t('productDialogs.pushNotifyError') : ''),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('ProductFilters: category push', e);
|
||||
this.setState({
|
||||
pushError: e.message || (t ? t('productDialogs.pushNotifyError') : ''),
|
||||
});
|
||||
} finally {
|
||||
this.setState({ pushBusy: false });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
adjustPaperHeight = () => {
|
||||
// Skip height adjustment on xs screens
|
||||
if (window.innerWidth < 600) return;
|
||||
|
||||
// Get reference to our paper element
|
||||
const paperEl = document.getElementById('filters-paper');
|
||||
if (!paperEl) return;
|
||||
|
||||
// No min-height on mobile — also clears inline style after resize from desktop
|
||||
if (window.innerWidth < 600) {
|
||||
paperEl.style.minHeight = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Get viewport height
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
@@ -316,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
|
||||
@@ -338,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)
|
||||
@@ -369,146 +159,41 @@ class ProductFilters extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const kKategorie = this.kKategorieNumber();
|
||||
const showCategoryPush = kKategorie && this.shouldShowCategoryPush();
|
||||
const {
|
||||
pushInteractive,
|
||||
pushSubscribed,
|
||||
pushBusy,
|
||||
pushError,
|
||||
} = this.state;
|
||||
const pushDisabledHint =
|
||||
showCategoryPush && !pushInteractive && !pushBusy
|
||||
? isPushApiSupported()
|
||||
? this.props.t
|
||||
? this.props.t('productDialogs.pushNotifyServerDisabled')
|
||||
: ''
|
||||
: this.props.t
|
||||
? this.props.t('filters.notifyNewArticlesBrowserUnsupported')
|
||||
: 'Ihr Browser unterstützt keine Push-Benachrichtigungen.'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
px: { xs: 2, sm: 0 },
|
||||
pt: { xs: 2, sm: 0 },
|
||||
/* Room below Paper so elevation shadow isn’t clipped by grid/parent */
|
||||
pb: { xs: 2, sm: 2 },
|
||||
overflow: 'visible',
|
||||
/* Same green as ProductList / product strip mobile (#e8f5e8), not theme background.default (#C8E6C9) */
|
||||
bgcolor: { xs: '#e8f5e8', sm: 'transparent' },
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
id="filters-paper"
|
||||
elevation={1}
|
||||
elevation={window.innerWidth < 600 ? 0 : 1}
|
||||
sx={{
|
||||
p: { xs: 2.5, sm: 2.5 },
|
||||
mx: { sm: 'auto' },
|
||||
maxWidth: '100%',
|
||||
borderRadius: 2,
|
||||
p: { xs: 1, sm: 2 },
|
||||
borderRadius: { xs: 0, sm: 2 },
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
border: { xs: 'none', sm: 'inherit' },
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'visible',
|
||||
boxShadow: { xs: 'none', sm: 'inherit' },
|
||||
mx: { xs: 0, sm: 'auto' },
|
||||
width: { xs: '100%', sm: 'auto' }
|
||||
}}
|
||||
>
|
||||
|
||||
{this.props.dataType == 'category' && (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography
|
||||
variant="h3"
|
||||
component="h1"
|
||||
sx={{
|
||||
mb: showCategoryPush ? 1.5 : 4,
|
||||
fontFamily: 'SwashingtonCP',
|
||||
color: 'primary.main',
|
||||
}}
|
||||
>
|
||||
{this.props.categoryName}
|
||||
</Typography>
|
||||
{showCategoryPush && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Tooltip title={pushDisabledHint} arrow>
|
||||
<span style={{ display: 'block', width: '100%' }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={this.handleCategoryPushClick}
|
||||
disabled={!pushInteractive || pushBusy}
|
||||
startIcon={
|
||||
pushBusy ? (
|
||||
<CircularProgress size={14} sx={{ color: 'inherit' }} />
|
||||
) : pushSubscribed ? (
|
||||
<NotificationsActiveIcon sx={{ fontSize: 18, color: '#2e7d32' }} />
|
||||
) : (
|
||||
<NotificationsIcon sx={{ fontSize: 18, color: 'rgba(0,0,0,0.65)' }} />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.7rem',
|
||||
lineHeight: 1.2,
|
||||
backgroundColor: '#fff',
|
||||
color: 'text.primary',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
boxShadow: 'none',
|
||||
whiteSpace: 'normal',
|
||||
textAlign: 'center',
|
||||
py: 0.4,
|
||||
px: 0.75,
|
||||
minHeight: 28,
|
||||
'& .MuiButton-label': {
|
||||
whiteSpace: 'normal',
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
'& .MuiButton-startIcon': {
|
||||
mr: 0.5,
|
||||
'& > *:nth-of-type(1)': { fontSize: 18 },
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'grey.50',
|
||||
borderColor: 'divider',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
backgroundColor: '#fff',
|
||||
color: 'action.disabled',
|
||||
borderColor: 'action.disabledBackground',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{this.props.t
|
||||
? this.props.t('filters.notifyNewArticles')
|
||||
: 'Bei neuen Artikeln benachrichtigen'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{pushError && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="error"
|
||||
sx={{ display: 'block', mt: 0.5, textAlign: 'center' }}
|
||||
>
|
||||
{pushError}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="h3"
|
||||
component="h1"
|
||||
sx={{
|
||||
mb: 4,
|
||||
fontFamily: 'SwashingtonCP',
|
||||
color: 'primary.main'
|
||||
}}
|
||||
>
|
||||
{this.props.dataParam}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
|
||||
{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}
|
||||
@@ -551,13 +236,12 @@ 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}
|
||||
filteredProducts={this.props.filteredProducts}
|
||||
attributes={this.props.attributes}
|
||||
manufacturerImages={this.state.manufacturerImages}
|
||||
onFilterChange={(msg)=>{
|
||||
if(msg.value) {
|
||||
setSessionSetting(`filter_${msg.type}_${msg.name}`, 'true');
|
||||
@@ -569,9 +253,8 @@ class ProductFilters extends Component {
|
||||
/>
|
||||
</>)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(withI18n()(ProductFilters));
|
||||
export default withRouter(ProductFilters);
|
||||
@@ -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;
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +240,7 @@ class ProductList extends Component {
|
||||
|
||||
|
||||
<Box sx={{
|
||||
display: { xs: 'none', sm: 'flex' },
|
||||
display: 'flex',
|
||||
gap: { xs: 0.5, sm: 1 },
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
@@ -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()}
|
||||
@@ -438,11 +437,7 @@ class ProductList extends Component {
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Grid
|
||||
container
|
||||
spacing={{ xs: 0, sm: 2 }}
|
||||
sx={{ bgcolor: { xs: '#e8f5e8', sm: 'transparent' } }}
|
||||
>
|
||||
<Grid container spacing={{ xs: 0, sm: 2 }}>
|
||||
{this.renderNoProductsMessage()}
|
||||
{products.map((product, index) => (
|
||||
<Grid
|
||||
@@ -452,14 +447,13 @@ class ProductList extends Component {
|
||||
justifyContent: { xs: 'stretch', sm: 'center' },
|
||||
mb: { xs: 0, sm: 1 },
|
||||
width: { xs: '100%', sm: 'auto' },
|
||||
bgcolor: { xs: '#e8f5e8', sm: 'transparent' },
|
||||
borderBottom: {
|
||||
xs: index < products.length - 1 ? '16px solid #e8f5e8' : 'none',
|
||||
sm: 'none'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Product
|
||||
<Product
|
||||
id={product.id}
|
||||
name={product.name}
|
||||
seoName={product.seoName}
|
||||
@@ -468,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>
|
||||
))}
|
||||
@@ -504,4 +495,4 @@ class ProductList extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default withI18n()(ProductList);
|
||||
export default ProductList;
|
||||
@@ -1,431 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import ChevronLeft from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRight from "@mui/icons-material/ChevronRight";
|
||||
import CategoryBox from "./CategoryBox.js";
|
||||
import ProductCarousel from "./ProductCarousel.js";
|
||||
import ManufacturerCarousel from "./ManufacturerCarousel.js";
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import { withLanguage } from '../i18n/withTranslation.js';
|
||||
|
||||
const ITEM_WIDTH = 130 + 16; // 130px width + 16px gap
|
||||
const AUTO_SCROLL_SPEED = 0.5; // px per frame (~60fps, so ~30px/sec)
|
||||
const AUTOSCROLL_RESTART_DELAY = 5000; // 5 seconds of inactivity before restarting autoscroll
|
||||
const SCROLLBAR_FLASH_DURATION = 3000; // 3 seconds to show the virtual scrollbar
|
||||
|
||||
class SharedCarousel extends React.Component {
|
||||
_isMounted = false;
|
||||
categories = [];
|
||||
originalCategories = [];
|
||||
animationFrame = null;
|
||||
autoScrollActive = true;
|
||||
translateX = 0;
|
||||
inactivityTimer = null;
|
||||
scrollbarTimer = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { i18n } = props;
|
||||
|
||||
// Don't load categories in constructor - will be loaded in componentDidMount with correct language
|
||||
this.state = {
|
||||
categories: [],
|
||||
currentLanguage: (i18n && i18n.language) || 'de',
|
||||
showScrollbar: false,
|
||||
};
|
||||
|
||||
this.carouselTrackRef = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language;
|
||||
|
||||
// ALWAYS reload categories to ensure correct language
|
||||
console.log("SharedCarousel componentDidMount: ALWAYS RELOADING categories for language", currentLanguage);
|
||||
window.categoryService.get(209, currentLanguage).then((response) => {
|
||||
console.log("SharedCarousel categoryService.get response for language '" + currentLanguage + "':", response);
|
||||
if (this._isMounted && response.children && response.children.length > 0) {
|
||||
console.log("SharedCarousel: Setting categories with", response.children.length, "items");
|
||||
console.log("SharedCarousel: First category name:", response.children[0]?.name);
|
||||
this.originalCategories = response.children;
|
||||
// Duplicate for seamless looping
|
||||
this.categories = [...response.children, ...response.children];
|
||||
this.setState({ categories: this.categories });
|
||||
this.startAutoScroll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
console.log("componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage);
|
||||
if (prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) {
|
||||
this.setState({ categories: [] }, () => {
|
||||
window.categoryService.get(209, this.props.languageContext?.currentLanguage || this.props.i18n.language).then((response) => {
|
||||
console.log("response", response);
|
||||
if (response.children && response.children.length > 0) {
|
||||
this.originalCategories = response.children;
|
||||
this.categories = [...response.children, ...response.children];
|
||||
this.setState({ categories: this.categories });
|
||||
this.startAutoScroll();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
this.stopAutoScroll();
|
||||
this.clearInactivityTimer();
|
||||
this.clearScrollbarTimer();
|
||||
}
|
||||
|
||||
startAutoScroll = () => {
|
||||
this.autoScrollActive = true;
|
||||
if (!this.animationFrame) {
|
||||
this.animationFrame = requestAnimationFrame(this.handleAutoScroll);
|
||||
}
|
||||
};
|
||||
|
||||
stopAutoScroll = () => {
|
||||
this.autoScrollActive = false;
|
||||
if (this.animationFrame) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
this.animationFrame = null;
|
||||
}
|
||||
};
|
||||
|
||||
clearInactivityTimer = () => {
|
||||
if (this.inactivityTimer) {
|
||||
clearTimeout(this.inactivityTimer);
|
||||
this.inactivityTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
clearScrollbarTimer = () => {
|
||||
if (this.scrollbarTimer) {
|
||||
clearTimeout(this.scrollbarTimer);
|
||||
this.scrollbarTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
startInactivityTimer = () => {
|
||||
this.clearInactivityTimer();
|
||||
this.inactivityTimer = setTimeout(() => {
|
||||
if (this._isMounted) {
|
||||
this.startAutoScroll();
|
||||
}
|
||||
}, AUTOSCROLL_RESTART_DELAY);
|
||||
};
|
||||
|
||||
showScrollbarFlash = () => {
|
||||
this.clearScrollbarTimer();
|
||||
this.setState({ showScrollbar: true });
|
||||
|
||||
this.scrollbarTimer = setTimeout(() => {
|
||||
if (this._isMounted) {
|
||||
this.setState({ showScrollbar: false });
|
||||
}
|
||||
}, SCROLLBAR_FLASH_DURATION);
|
||||
};
|
||||
|
||||
handleAutoScroll = () => {
|
||||
if (!this.autoScrollActive || this.originalCategories.length === 0) return;
|
||||
|
||||
this.translateX -= AUTO_SCROLL_SPEED;
|
||||
this.updateTrackTransform();
|
||||
|
||||
const originalItemCount = this.originalCategories.length;
|
||||
const maxScroll = ITEM_WIDTH * originalItemCount;
|
||||
|
||||
// Check if we've scrolled past the first set of items
|
||||
if (Math.abs(this.translateX) >= maxScroll) {
|
||||
// Reset to beginning seamlessly
|
||||
this.translateX = 0;
|
||||
this.updateTrackTransform();
|
||||
}
|
||||
|
||||
this.animationFrame = requestAnimationFrame(this.handleAutoScroll);
|
||||
};
|
||||
|
||||
updateTrackTransform = () => {
|
||||
if (this.carouselTrackRef.current) {
|
||||
this.carouselTrackRef.current.style.transform = `translateX(${this.translateX}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
handleLeftClick = () => {
|
||||
this.stopAutoScroll();
|
||||
this.scrollBy(1);
|
||||
this.showScrollbarFlash();
|
||||
this.startInactivityTimer();
|
||||
};
|
||||
|
||||
handleRightClick = () => {
|
||||
this.stopAutoScroll();
|
||||
this.scrollBy(-1);
|
||||
this.showScrollbarFlash();
|
||||
this.startInactivityTimer();
|
||||
};
|
||||
|
||||
scrollBy = (direction) => {
|
||||
if (this.originalCategories.length === 0) return;
|
||||
|
||||
// direction: 1 = left (scroll content right), -1 = right (scroll content left)
|
||||
const originalItemCount = this.originalCategories.length;
|
||||
const maxScroll = ITEM_WIDTH * originalItemCount;
|
||||
|
||||
this.translateX += direction * ITEM_WIDTH;
|
||||
|
||||
// Handle wrap-around when scrolling left (positive translateX)
|
||||
if (this.translateX > 0) {
|
||||
this.translateX = -(maxScroll - ITEM_WIDTH);
|
||||
}
|
||||
// Handle wrap-around when scrolling right (negative translateX beyond limit)
|
||||
else if (Math.abs(this.translateX) >= maxScroll) {
|
||||
this.translateX = 0;
|
||||
}
|
||||
|
||||
this.updateTrackTransform();
|
||||
|
||||
// Force scrollbar to update immediately after wrap-around
|
||||
if (this.state.showScrollbar) {
|
||||
this.forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
renderVirtualScrollbar = () => {
|
||||
if (!this.state.showScrollbar || this.originalCategories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const originalItemCount = this.originalCategories.length;
|
||||
const viewportWidth = 1080; // carousel container max-width
|
||||
const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH);
|
||||
|
||||
// Calculate which item is currently at the left edge (first visible)
|
||||
// Map translateX directly to item index using the same logic as scrollBy
|
||||
let currentItemIndex;
|
||||
|
||||
if (this.translateX === 0) {
|
||||
// At the beginning - item 0 is visible
|
||||
currentItemIndex = 0;
|
||||
} else if (this.translateX > 0) {
|
||||
// Wrapped to show end items (this happens when scrolling left past beginning)
|
||||
const maxScroll = ITEM_WIDTH * originalItemCount;
|
||||
const effectivePosition = maxScroll + this.translateX;
|
||||
currentItemIndex = Math.floor(effectivePosition / ITEM_WIDTH);
|
||||
} else {
|
||||
// Normal negative scrolling - calculate which item is at left edge
|
||||
currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH);
|
||||
}
|
||||
|
||||
// Ensure we stay within bounds
|
||||
currentItemIndex = Math.max(0, Math.min(currentItemIndex, originalItemCount - 1));
|
||||
|
||||
// Calculate scrollbar position: 0% when item 0 is first visible, 100% when last item is first visible
|
||||
const lastPossibleFirstItem = Math.max(0, originalItemCount - itemsInView);
|
||||
const thumbPosition = lastPossibleFirstItem > 0 ? Math.min((currentItemIndex / lastPossibleFirstItem) * 100, 100) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="virtual-scrollbar"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '5px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '200px',
|
||||
height: '4px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: '2px',
|
||||
zIndex: 1000,
|
||||
opacity: this.state.showScrollbar ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease-in-out'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="scrollbar-thumb"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: `${thumbPosition}%`,
|
||||
width: '20px',
|
||||
height: '4px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: '2px',
|
||||
transform: 'translateX(-50%)',
|
||||
transition: 'left 0.2s ease-out'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { categories } = this.state;
|
||||
|
||||
if (!categories || categories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Box
|
||||
component={Link}
|
||||
to="/Kategorien"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textDecoration: "none",
|
||||
color: "primary.main",
|
||||
mb: 2,
|
||||
transition: "all 0.3s ease",
|
||||
"&:hover": {
|
||||
transform: "translateX(5px)",
|
||||
color: "primary.dark"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: "SwashingtonCP",
|
||||
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
|
||||
}}
|
||||
>
|
||||
{t('navigation.categories')}
|
||||
</Typography>
|
||||
<ChevronRight sx={{ fontSize: "2.5rem", ml: 1 }} />
|
||||
</Box>
|
||||
|
||||
<div
|
||||
className="carousel-wrapper"
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
padding: '0 20px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
{/* Left Arrow */}
|
||||
<IconButton
|
||||
aria-label="Vorherige Kategorien anzeigen"
|
||||
onClick={this.handleLeftClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '8px',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 1000,
|
||||
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: 1000,
|
||||
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: virtual categories Neuheiten + Demnächst */}
|
||||
<ProductCarousel categoryId={["neu", "bald"]} />
|
||||
|
||||
{/* Manufacturer logo carousel */}
|
||||
<ManufacturerCarousel />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(withLanguage(SharedCarousel));
|
||||
@@ -1,53 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { withProduct } from '../context/ProductContext.js';
|
||||
import { withCategory } from '../context/CategoryContext.js';
|
||||
|
||||
// Utility function to clean product names (duplicated from ProductDetailPage to ensure consistency)
|
||||
const cleanProductName = (name) => {
|
||||
if (!name) return "";
|
||||
// Remove patterns like " (1)", " (3)", " (10)" at the end of the string
|
||||
return name.replace(/\s*\(\d+\)\s*$/, "").trim();
|
||||
};
|
||||
|
||||
class TitleUpdater extends Component {
|
||||
componentDidMount() {
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
console.log('TitleUpdater: Update triggered', {
|
||||
prevProduct: prevProps.productContext.currentProduct,
|
||||
currProduct: this.props.productContext.currentProduct,
|
||||
prevCategory: prevProps.categoryContext.currentCategory,
|
||||
currCategory: this.props.categoryContext.currentCategory
|
||||
});
|
||||
if (
|
||||
prevProps.productContext.currentProduct !== this.props.productContext.currentProduct ||
|
||||
prevProps.categoryContext.currentCategory !== this.props.categoryContext.currentCategory
|
||||
) {
|
||||
this.updateTitle();
|
||||
}
|
||||
}
|
||||
|
||||
updateTitle() {
|
||||
const { currentProduct } = this.props.productContext;
|
||||
const { currentCategory } = this.props.categoryContext;
|
||||
|
||||
console.log('TitleUpdater: Updating title with', { currentProduct, currentCategory });
|
||||
|
||||
if (currentProduct && currentProduct.name) {
|
||||
document.title = `GrowHeads.de - ${cleanProductName(currentProduct.name)}`;
|
||||
} else if (currentCategory && currentCategory.name) {
|
||||
document.title = `GrowHeads.de - ${currentCategory.name}`;
|
||||
} else {
|
||||
document.title = 'GrowHeads.de';
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default withCategory(withProduct(TitleUpdater));
|
||||
|
||||
@@ -1,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/avif' }));
|
||||
this.forceUpdate();
|
||||
}
|
||||
this.loadingImages.delete(bildId);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '160px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CircularProgress sx={{ color: '#90ffc0' }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
renderExtraCard(extra) {
|
||||
const { selectedExtras, onExtraToggle, showImage = true } = this.props;
|
||||
const isSelected = selectedExtras.includes(extra.id);
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
width: { xs: '100%', sm: '250px' },
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
border: '2px solid',
|
||||
borderColor: isSelected ? '#2e7d32' : '#e0e0e0',
|
||||
backgroundColor: isSelected ? '#f1f8e9' : '#ffffff',
|
||||
'&:hover': {
|
||||
boxShadow: 6,
|
||||
borderColor: isSelected ? '#2e7d32' : '#90caf9'
|
||||
},
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
onClick={() => onExtraToggle(extra.id)}>
|
||||
{/* Image */}
|
||||
<Card
|
||||
key={extra.id}
|
||||
sx={{
|
||||
height: '100%',
|
||||
border: '2px solid',
|
||||
borderColor: isSelected ? '#2e7d32' : '#e0e0e0',
|
||||
backgroundColor: isSelected ? '#f1f8e9' : '#ffffff',
|
||||
'&:hover': {
|
||||
boxShadow: 5,
|
||||
borderColor: isSelected ? '#2e7d32' : '#90caf9'
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
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' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Name */}
|
||||
<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>
|
||||
|
||||
<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
|
||||
</Typography>
|
||||
<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>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Keine Extras verfügbar
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{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 && (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -5,7 +5,6 @@ import CardContent from '@mui/material/CardContent';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import { withI18n } from '../../i18n/withTranslation.js';
|
||||
|
||||
class TentShapeSelector extends Component {
|
||||
// Generate plant layout based on tent shape
|
||||
@@ -91,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>
|
||||
|
||||
@@ -181,20 +180,12 @@ class TentShapeSelector extends Component {
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{this.props.t && shape.descriptionKey ? this.props.t(shape.descriptionKey) : shape.description}
|
||||
{shape.description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Chip
|
||||
label={this.props.t
|
||||
? (
|
||||
shape.minPlants === 1 && shape.maxPlants === 2 ? this.props.t("kitConfig.plants1to2") :
|
||||
shape.minPlants === 2 && shape.maxPlants === 4 ? this.props.t("kitConfig.plants2to4") :
|
||||
shape.minPlants === 4 && shape.maxPlants === 6 ? this.props.t("kitConfig.plants4to6") :
|
||||
shape.minPlants === 3 && shape.maxPlants === 6 ? this.props.t("kitConfig.plants3to6") :
|
||||
`${shape.minPlants}-${shape.maxPlants} Pflanzen`
|
||||
)
|
||||
: `${shape.minPlants}-${shape.maxPlants} Pflanzen`}
|
||||
label={`${shape.minPlants}-${shape.maxPlants} Pflanzen`}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: isSelected ? '#2e7d32' : '#f0f0f0',
|
||||
@@ -214,7 +205,7 @@ class TentShapeSelector extends Component {
|
||||
transition: 'opacity 0.3s ease'
|
||||
}}
|
||||
>
|
||||
{this.props.t ? this.props.t("kitConfig.selected") : "✓ Ausgewählt"}
|
||||
✓ Ausgewählt
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
@@ -227,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 && (
|
||||
@@ -247,4 +238,4 @@ class TentShapeSelector extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default withI18n()(TentShapeSelector);
|
||||
export default TentShapeSelector;
|
||||
@@ -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 = () => {
|
||||
// Remove existing listeners first to avoid duplicates
|
||||
this.removeSocketListeners();
|
||||
|
||||
if (window.socketManager) {
|
||||
window.socketManager.on('cartUpdated', this.handleCartUpdated);
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
// Remove existing listeners first to avoid duplicates
|
||||
this.removeSocketListeners();
|
||||
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;
|
||||
@@ -16,11 +16,9 @@ const Logo = () => {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/assets/images/sh.avif"
|
||||
src="/assets/images/sh.png"
|
||||
alt="SH Logo"
|
||||
width="108px"
|
||||
height="45px"
|
||||
style={{ width: "108px", height: "45px" }}
|
||||
style={{ height: "45px" }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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,26 +58,27 @@ 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.map(p => p.translatedProduct || p).slice(0, 8); // Limit to 8 suggestions
|
||||
const suggestions = response.products.slice(0, 8); // Limit to 8 suggestions
|
||||
setSuggestions(suggestions);
|
||||
setShowSuggestions(suggestions.length > 0);
|
||||
setSelectedIndex(-1); // Reset selection
|
||||
@@ -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 }}>
|
||||
{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>
|
||||
<Typography variant="body2" noWrap>
|
||||
{suggestion.name}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Box sx={{ p: 1, borderTop: 1, borderColor: 'divider' }}>
|
||||
<IconButton
|
||||
fullWidth
|
||||
onClick={handleEnterClick}
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
py: 1,
|
||||
color: 'primary.main',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ mr: 1 }}>
|
||||
{t('common.more')}
|
||||
</Typography>
|
||||
<KeyboardReturnIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,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;
|
||||
|
||||
@@ -5,11 +5,9 @@ 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 {
|
||||
normalizeDeliveryMethod = (deliveryMethod) => (deliveryMethod === "DPD" ? "DHL" : deliveryMethod);
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
@@ -53,6 +51,9 @@ class CartTab extends Component {
|
||||
showStripePayment: false,
|
||||
StripeComponent: null,
|
||||
isLoadingStripe: false,
|
||||
showMolliePayment: false,
|
||||
MollieComponent: null,
|
||||
isLoadingMollie: false,
|
||||
showPaymentConfirmation: false,
|
||||
orderCompleted: false,
|
||||
originalCartItems: []
|
||||
@@ -69,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;
|
||||
|
||||
@@ -109,17 +111,15 @@ class CartTab extends Component {
|
||||
// Map delivery method values if needed
|
||||
const deliveryMethodMap = {
|
||||
"standard": "DHL",
|
||||
"express": "DHL",
|
||||
"express": "DPD",
|
||||
"pickup": "Abholung"
|
||||
};
|
||||
prefillDeliveryMethod = this.normalizeDeliveryMethod(
|
||||
deliveryMethodMap[prefillDeliveryMethod] || prefillDeliveryMethod
|
||||
);
|
||||
prefillDeliveryMethod = deliveryMethodMap[prefillDeliveryMethod] || prefillDeliveryMethod;
|
||||
|
||||
// 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"
|
||||
@@ -149,6 +149,7 @@ class CartTab extends Component {
|
||||
console.log("No order template available or failed to fetch");
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
@@ -172,9 +173,7 @@ class CartTab extends Component {
|
||||
const cartItems = Array.isArray(window.cart) ? window.cart : [];
|
||||
const shouldForcePickup = CheckoutValidation.shouldForcePickupDelivery(cartItems);
|
||||
|
||||
const newDeliveryMethod = shouldForcePickup
|
||||
? "Abholung"
|
||||
: this.normalizeDeliveryMethod(this.state.deliveryMethod);
|
||||
const newDeliveryMethod = shouldForcePickup ? "Abholung" : this.state.deliveryMethod;
|
||||
const deliveryCost = this.orderService.getDeliveryCost();
|
||||
|
||||
// Get optimal payment method for the current state
|
||||
@@ -221,17 +220,8 @@ class CartTab extends Component {
|
||||
};
|
||||
|
||||
handleDeliveryMethodChange = (event) => {
|
||||
const newDeliveryMethod = this.normalizeDeliveryMethod(event.target.value);
|
||||
const newDeliveryMethod = event.target.value;
|
||||
const deliveryCost = this.orderService.getDeliveryCost();
|
||||
|
||||
// Pickup should default to in-store payment.
|
||||
if (newDeliveryMethod === "Abholung") {
|
||||
this.setState({
|
||||
deliveryMethod: newDeliveryMethod,
|
||||
paymentMethod: "cash",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get optimal payment method for the new delivery method
|
||||
const optimalPaymentMethod = CheckoutValidation.getOptimalPaymentMethod(
|
||||
@@ -305,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;
|
||||
};
|
||||
@@ -332,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
|
||||
@@ -376,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;
|
||||
const totalAmount = Math.round((subtotal + deliveryCost) * 100); // Convert to cents
|
||||
|
||||
// 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);
|
||||
|
||||
this.orderService.createMollieIntent(totalAmount, this.loadMollieComponent);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -445,6 +441,9 @@ class CartTab extends Component {
|
||||
showStripePayment,
|
||||
StripeComponent,
|
||||
isLoadingStripe,
|
||||
showMolliePayment,
|
||||
MollieComponent,
|
||||
isLoadingMollie,
|
||||
showPaymentConfirmation,
|
||||
orderCompleted,
|
||||
} = this.state;
|
||||
@@ -452,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 (
|
||||
@@ -480,7 +479,8 @@ class CartTab extends Component {
|
||||
{!showPaymentConfirmation && (
|
||||
<CartDropdown
|
||||
cartItems={cartItems}
|
||||
showDetailedSummary={showStripePayment}
|
||||
socket={this.context.socket}
|
||||
showDetailedSummary={showStripePayment || showMolliePayment}
|
||||
deliveryMethod={deliveryMethod}
|
||||
deliveryCost={deliveryCost}
|
||||
/>
|
||||
@@ -488,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 ? (
|
||||
@@ -509,14 +509,34 @@ 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}
|
||||
paymentMethod={paymentMethod}
|
||||
invoiceAddress={invoiceAddress}
|
||||
deliveryAddress={deliveryAddress}
|
||||
useSameAddress={useSameAddress}
|
||||
@@ -524,7 +544,7 @@ class CartTab extends Component {
|
||||
addressFormErrors={addressFormErrors}
|
||||
termsAccepted={termsAccepted}
|
||||
note={note}
|
||||
deliveryMethod={deliveryMethod}
|
||||
deliveryMethod={deliveryMethod}
|
||||
hasStecklinge={hasStecklinge}
|
||||
isPickupOnly={isPickupOnly}
|
||||
deliveryCost={deliveryCost}
|
||||
@@ -550,4 +570,7 @@ class CartTab extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default withI18n()(CartTab);
|
||||
// Set static contextType to access the socket
|
||||
CartTab.contextType = SocketContext;
|
||||
|
||||
export default CartTab;
|
||||
|
||||
@@ -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,10 +93,9 @@ class CheckoutForm extends Component {
|
||||
deliveryMethod={deliveryMethod}
|
||||
onChange={onDeliveryMethodChange}
|
||||
isPickupOnly={isPickupOnly || hasStecklinge}
|
||||
cartItems={cartItems}
|
||||
/>
|
||||
|
||||
{deliveryMethod === "DHL" && (
|
||||
{(deliveryMethod === "DHL" || deliveryMethod === "DPD") && (
|
||||
<>
|
||||
<FormControlLabel
|
||||
control={
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -80,14 +80,9 @@ class CheckoutValidation {
|
||||
return "wire";
|
||||
}
|
||||
|
||||
// Pickup defaults to in-store payment
|
||||
if (deliveryMethod === "Abholung") {
|
||||
return "cash";
|
||||
}
|
||||
|
||||
// Prefer wire for shippable delivery methods
|
||||
if (deliveryMethod === "DHL" || deliveryMethod === "DPD") {
|
||||
return "wire";/*stripe*/
|
||||
// Prefer stripe when available and meets minimum amount
|
||||
if (deliveryMethod === "DHL" || deliveryMethod === "DPD" || deliveryMethod === "Abholung") {
|
||||
return "stripe";
|
||||
}
|
||||
|
||||
// Fall back to wire transfer
|
||||
@@ -101,10 +96,9 @@ class CheckoutValidation {
|
||||
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
|
||||
const totalAmount = subtotal + deliveryCost;
|
||||
|
||||
// Reset "Nachnahme" when delivery is not DHL.
|
||||
// For pickup, default to in-store payment.
|
||||
// Reset payment method if it's no longer valid
|
||||
if (deliveryMethod !== "DHL" && paymentMethod === "onDelivery") {
|
||||
newPaymentMethod = deliveryMethod === "Abholung" ? "cash" : "wire";
|
||||
newPaymentMethod = "wire";
|
||||
}
|
||||
|
||||
// Allow stripe for DHL, DPD, and Abholung delivery methods, but check minimum amount
|
||||
@@ -112,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";
|
||||
}
|
||||
|
||||
@@ -3,34 +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') : '5,90 €'),
|
||||
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 ? "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: ''
|
||||
}
|
||||
@@ -39,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 }}>
|
||||
@@ -114,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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
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 {
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
} else {
|
||||
console.error("Socket context not available");
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: "Cannot connect to server. Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
// Create Mollie payment intent
|
||||
createMollieIntent(mollieOrderData) {
|
||||
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);
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: response.error || "Failed to create Mollie payment intent. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<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 align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{deliveryCost === 0 ? (
|
||||
<span style={{ color: '#2e7d32' }}>{t ? t('cart.summary.free') : 'kostenlos'}</span>
|
||||
) : (
|
||||
currencyFormatter.format(deliveryCost)
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{deliveryCost > 0 && (
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Versandkosten:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{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>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback, Fragment } 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,76 +14,45 @@ 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";
|
||||
import WireOrderGirocode from "./WireOrderGirocode.js";
|
||||
import ReadyForPickupNotice from "./ReadyForPickupNotice.js";
|
||||
import {
|
||||
isWireGirocodeEligible,
|
||||
hasPendingWirePaymentOrder,
|
||||
WIRE_PAYMENT_PENDING_EVENT,
|
||||
} from "../../utils/wireGirocodeEligibility.js";
|
||||
|
||||
// Constants
|
||||
const getStatusTranslation = (status, deliveryMethod, t) => {
|
||||
const statusMap = {
|
||||
new: t ? t('orders.status.new') : "in Bearbeitung",
|
||||
pending: t ? t('orders.status.pending') : "Neu",
|
||||
processing: t ? t('orders.status.processing') : "in Bearbeitung",
|
||||
paid: t ? t('orders.status.paid') : "Bezahlt",
|
||||
cancelled: t ? t('orders.status.cancelled') : "Storniert",
|
||||
shipped: deliveryMethod === "Abholung"
|
||||
? (t ? t('orders.status.picked_up') : "Abgeholt")
|
||||
: (t ? t('orders.status.shipped') : "Verschickt"),
|
||||
delivered: deliveryMethod === "Abholung"
|
||||
? (t ? t('orders.status.picked_up') : "Abgeholt")
|
||||
: (t ? t('orders.status.delivered') : "Geliefert"),
|
||||
awaiting_tracking: t ? t('orders.status.awaiting_tracking') : "Wird gepackt",
|
||||
ready_for_pickup: t ? t('orders.status.ready_for_pickup') : "Abholbereit",
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
const statusTranslations = {
|
||||
new: "in Bearbeitung",
|
||||
pending: "Neu",
|
||||
processing: "in Bearbeitung",
|
||||
cancelled: "Storniert",
|
||||
shipped: "Verschickt",
|
||||
delivered: "Geliefert",
|
||||
};
|
||||
|
||||
const getStatusEmoji = (status, deliveryMethod) => {
|
||||
if (deliveryMethod === "Abholung" && status === "shipped") {
|
||||
return "✅";
|
||||
}
|
||||
|
||||
if (deliveryMethod === "Abholung" && status === "delivered") {
|
||||
return "✅";
|
||||
}
|
||||
|
||||
const statusEmojis = {
|
||||
new: "⚙️",
|
||||
const statusEmojis = {
|
||||
"in Bearbeitung": "⚙️",
|
||||
pending: "⏳",
|
||||
processing: "🔄",
|
||||
paid: "🏦",
|
||||
cancelled: "❌",
|
||||
shipped: "🚚",
|
||||
delivered: "✅",
|
||||
awaiting_tracking: "📦",
|
||||
ready_for_pickup: "🏪",
|
||||
};
|
||||
return statusEmojis[status] || "❓";
|
||||
Verschickt: "🚚",
|
||||
Geliefert: "✅",
|
||||
Storniert: "❌",
|
||||
Retoure: "↩️",
|
||||
"Teil Retoure": "↪️",
|
||||
"Teil geliefert": "⚡",
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
new: "#ed6c02", // orange
|
||||
"in Bearbeitung": "#ed6c02", // orange
|
||||
pending: "#ff9800", // orange for pending
|
||||
processing: "#2196f3", // blue for processing
|
||||
paid: "#2e7d32", // green
|
||||
cancelled: "#d32f2f", // red for cancelled
|
||||
shipped: "#2e7d32", // green
|
||||
delivered: "#2e7d32", // green
|
||||
awaiting_tracking: "#1565c0", // blue — packing / pre-ship
|
||||
ready_for_pickup: "#2e7d32", // green — ready at store
|
||||
Verschickt: "#2e7d32", // green
|
||||
Geliefert: "#2e7d32", // green
|
||||
Storniert: "#d32f2f", // red
|
||||
Retoure: "#9c27b0", // purple
|
||||
"Teil Retoure": "#9c27b0", // purple
|
||||
"Teil geliefert": "#009688", // teal
|
||||
};
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
||||
@@ -93,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(
|
||||
@@ -111,52 +77,54 @@ 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);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(WIRE_PAYMENT_PENDING_EVENT, {
|
||||
detail: {
|
||||
pending: hasPendingWirePaymentOrder(response.orders),
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
setError(response.error || "Failed to fetch orders.");
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(WIRE_PAYMENT_PENDING_EVENT, {
|
||||
detail: { pending: false },
|
||||
})
|
||||
);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
}, []);
|
||||
} else {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
console.log("Socket not connected yet, waiting for connection to fetch orders");
|
||||
setLoading(false); // Stop loading when socket is not connected
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
}
|
||||
}, [orderIdFromHash, orders, handleViewDetails]);
|
||||
|
||||
const getStatusDisplay = (status, deliveryMethod) => {
|
||||
return getStatusTranslation(status, deliveryMethod, t);
|
||||
const getStatusDisplay = (status) => {
|
||||
return statusTranslations[status] || status;
|
||||
};
|
||||
|
||||
const getStatusEmoji = (status) => {
|
||||
return statusEmojis[status] || "❓";
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
@@ -166,53 +134,12 @@ 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) {
|
||||
return (
|
||||
<Box sx={{ p: { xs: 1, sm: 3 }, display: "flex", justifyContent: "center" }}>
|
||||
<Box sx={{ p: { xs: 1, sm: 3 }, display: "flex", justifyContent: "center" }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
@@ -227,145 +154,76 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: { xs: 1, sm: 3 } }}>
|
||||
<Box sx={{ p: { xs: 1, sm: 3 } }}>
|
||||
{orders.length > 0 ? (
|
||||
<TableContainer component={Paper}>
|
||||
<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, order.delivery_method);
|
||||
const total = order.items.reduce(
|
||||
const displayStatus = getStatusDisplay(order.status);
|
||||
const subtotal = order.items.reduce(
|
||||
(acc, item) => acc + item.price * item.quantity_ordered,
|
||||
0
|
||||
);
|
||||
const total = subtotal + order.delivery_cost;
|
||||
return (
|
||||
<Fragment key={order.orderId}>
|
||||
<TableRow hover>
|
||||
<TableCell>{order.orderId}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(order.created_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
color: getStatusColor(order.status),
|
||||
}}
|
||||
<TableRow key={order.orderId} hover>
|
||||
<TableCell>{order.orderId}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(order.created_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
color: getStatusColor(displayStatus),
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "1.2rem" }}>
|
||||
{getStatusEmoji(displayStatus)}
|
||||
</span>
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="span"
|
||||
sx={{ fontWeight: "medium" }}
|
||||
>
|
||||
<span style={{ fontSize: "1.2rem" }}>
|
||||
{getStatusEmoji(order.status, order.delivery_method)}
|
||||
</span>
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="span"
|
||||
sx={{ fontWeight: "medium" }}
|
||||
>
|
||||
{displayStatus}
|
||||
</Typography>
|
||||
</Box>
|
||||
{order.delivery_method === 'DHL' && order.trackingCode && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<a
|
||||
href={`https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${order.trackingCode}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: '0.85rem', color: '#d40511' }}
|
||||
>
|
||||
📦 {t ? t('orders.trackShipment') : 'Sendung verfolgen'}
|
||||
</a>
|
||||
</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{order.items
|
||||
.filter(item => {
|
||||
// Exclude delivery items - backend uses deliveryMethod ID as item name
|
||||
const itemName = item.name || '';
|
||||
return itemName !== 'DHL' &&
|
||||
itemName !== 'DPD' &&
|
||||
itemName !== 'Sperrgut' &&
|
||||
itemName !== 'Abholung';
|
||||
})
|
||||
.reduce(
|
||||
(acc, item) => acc + item.quantity_ordered,
|
||||
0
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(total)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center' }}>
|
||||
<Tooltip title={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => handleViewDetails(order.orderId)}
|
||||
aria-label={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}
|
||||
>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{isOrderCancelable(order) && (
|
||||
<Tooltip title={t ? t('orders.tooltips.cancelOrder') : 'Bestellung stornieren'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleCancelClick(order)}
|
||||
aria-label={t ? t('orders.tooltips.cancelOrder') : 'Bestellung stornieren'}
|
||||
>
|
||||
<CancelIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isWireGirocodeEligible(order) && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
sx={{
|
||||
py: 2,
|
||||
px: { xs: 1, sm: 2 },
|
||||
verticalAlign: "top",
|
||||
bgcolor: "action.hover",
|
||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
{displayStatus}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{order.items.reduce(
|
||||
(acc, item) => acc + item.quantity_ordered,
|
||||
0
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(total)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Tooltip title="Details anzeigen">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => handleViewDetails(order.orderId)}
|
||||
>
|
||||
<WireOrderGirocode order={order} t={t} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{order.status === "ready_for_pickup" && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
sx={{
|
||||
py: 2,
|
||||
px: { xs: 1, sm: 2 },
|
||||
verticalAlign: "top",
|
||||
bgcolor: "action.hover",
|
||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<ReadyForPickupNotice order={order} t={t} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
@@ -373,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
|
||||
@@ -381,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;
|
||||
|
||||
@@ -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;
|
||||